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

@@ -0,0 +1,249 @@
import SwiftUI
struct SleepDetailView: View {
let sleep: SleepData
@StateObject private var healthKit = HealthKitService()
@State private var segments: [SleepSegment] = []
@State private var isLoading = true
@Environment(\.dismiss) var dismiss
var total: Double { sleep.totalSleep ?? 0 }
var deep: Double { sleep.deep ?? 0 }
var rem: Double { sleep.rem ?? 0 }
var core: Double { sleep.core ?? 0 }
var phases: [(name: String, value: Double, color: Color, icon: String)] {
[
("Глубокий", deep, SleepPhaseType.deep.color, "moon.zzz.fill"),
("REM", rem, SleepPhaseType.rem.color, "brain.head.profile"),
("Базовый", core, SleepPhaseType.core.color, "moon.fill"),
]
}
var sleepStart: Date? { segments.first?.start }
var sleepEnd: Date? { segments.last?.end }
var body: some View {
ZStack {
Color(hex: "06060f").ignoresSafeArea()
ScrollView(showsIndicators: false) {
VStack(spacing: 20) {
// Header
HStack {
Button(action: { dismiss() }) {
Image(systemName: "xmark.circle.fill")
.font(.title2).foregroundColor(Theme.textSecondary)
}
Spacer()
Text("Анализ сна").font(.headline).foregroundColor(.white)
Spacer()
Color.clear.frame(width: 28)
}
.padding(.horizontal).padding(.top, 16)
// Total
VStack(spacing: 8) {
Text(String(format: "%.1f ч", total))
.font(.system(size: 48, weight: .bold, design: .rounded))
.foregroundColor(Theme.purple)
if let start = sleepStart, let end = sleepEnd {
Text("\(fmt(start))\(fmt(end))")
.font(.callout).foregroundColor(Theme.textSecondary)
}
}
.padding(.vertical, 4)
// Phase cards
HStack(spacing: 12) {
ForEach(phases, id: \.name) { phase in
VStack(spacing: 6) {
Image(systemName: phase.icon).font(.caption).foregroundColor(phase.color)
Text(fmtDuration(phase.value)).font(.callout.bold().monospacedDigit()).foregroundColor(.white)
Text(phase.name).font(.caption2).foregroundColor(Theme.textSecondary)
if total > 0 {
Text("\(Int(phase.value / total * 100))%").font(.caption2.bold()).foregroundColor(phase.color)
}
}
.frame(maxWidth: .infinity).padding(.vertical, 12)
.glassCard(cornerRadius: 14)
}
}
.padding(.horizontal)
// Hypnogram
if isLoading {
ProgressView().tint(Theme.purple).padding(.top, 20)
} else if !segments.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text("Гипнограмма").font(.subheadline.bold()).foregroundColor(.white)
HypnogramView(segments: segments)
.frame(height: 180)
}
.padding(16)
.glassCard(cornerRadius: 16)
.padding(.horizontal)
} else {
VStack(spacing: 8) {
Image(systemName: "moon.zzz").font(.title).foregroundColor(Theme.textSecondary)
Text("График недоступен").font(.subheadline).foregroundColor(Theme.textSecondary)
}.padding(.top, 20)
}
// Stacked bar
VStack(alignment: .leading, spacing: 8) {
Text("Распределение").font(.subheadline.bold()).foregroundColor(.white)
GeometryReader { geo in
HStack(spacing: 2) {
ForEach(phases, id: \.name) { phase in
let frac = total > 0 ? phase.value / total : 0
RoundedRectangle(cornerRadius: 4)
.fill(phase.color)
.frame(width: max(geo.size.width * CGFloat(frac), frac > 0 ? 4 : 0))
.shadow(color: phase.color.opacity(0.4), radius: 4)
}
}
}
.frame(height: 14)
}
.padding(16)
.glassCard(cornerRadius: 16)
.padding(.horizontal)
// Legend
HStack(spacing: 16) {
ForEach([SleepPhaseType.awake, .rem, .core, .deep], id: \.rawValue) { phase in
HStack(spacing: 4) {
Circle().fill(phase.color).frame(width: 8, height: 8)
Text(phase.rawValue).font(.caption2).foregroundColor(Theme.textSecondary)
}
}
}
Spacer(minLength: 40)
}
}
}
.task {
if healthKit.isAvailable {
try? await healthKit.requestAuthorization()
segments = await healthKit.fetchSleepSegments()
}
isLoading = false
}
}
private func fmt(_ date: Date) -> String {
let f = DateFormatter(); f.dateFormat = "HH:mm"; return f.string(from: date)
}
private func fmtDuration(_ h: Double) -> String {
let hrs = Int(h); let mins = Int((h - Double(hrs)) * 60)
if hrs > 0 { return "\(hrs)ч \(mins)м" }
return "\(mins)м"
}
}
// MARK: - Hypnogram (sleep stages chart)
struct HypnogramView: View {
let segments: [SleepSegment]
// Phase levels: awake=top, rem, core, deep=bottom
private func yLevel(_ phase: SleepPhaseType) -> CGFloat {
switch phase {
case .awake: return 0.0
case .rem: return 0.33
case .core: return 0.66
case .deep: return 1.0
}
}
private var timeStart: Date { segments.first?.start ?? Date() }
private var timeEnd: Date { segments.last?.end ?? Date() }
private var totalSpan: TimeInterval { max(timeEnd.timeIntervalSince(timeStart), 1) }
var body: some View {
GeometryReader { geo in
let w = geo.size.width
let chartH = geo.size.height - 36 // space for labels
ZStack(alignment: .topLeading) {
// Grid lines + labels
ForEach(0..<4, id: \.self) { i in
let y = chartH * CGFloat(i) / 3.0
Path { p in p.move(to: CGPoint(x: 0, y: y)); p.addLine(to: CGPoint(x: w, y: y)) }
.stroke(Color.white.opacity(0.05), lineWidth: 1)
let labels = ["Пробуждение", "REM", "Базовый", "Глубокий"]
Text(labels[i])
.font(.system(size: 8))
.foregroundColor(Color.white.opacity(0.25))
.position(x: 35, y: y)
}
// Filled step areas
ForEach(segments) { seg in
let x1 = w * CGFloat(seg.start.timeIntervalSince(timeStart) / totalSpan)
let x2 = w * CGFloat(seg.end.timeIntervalSince(timeStart) / totalSpan)
let segW = max(x2 - x1, 1)
let y = yLevel(seg.phase) * chartH
// Fill from phase level to bottom
Rectangle()
.fill(seg.phase.color.opacity(0.15))
.frame(width: segW, height: chartH - y)
.position(x: x1 + segW / 2, y: y + (chartH - y) / 2)
// Top edge highlight
Rectangle()
.fill(seg.phase.color)
.frame(width: segW, height: 3)
.shadow(color: seg.phase.color.opacity(0.6), radius: 4, y: 0)
.position(x: x1 + segW / 2, y: y)
}
// Step line connecting phases
Path { path in
for (i, seg) in segments.enumerated() {
let x = w * CGFloat(seg.start.timeIntervalSince(timeStart) / totalSpan)
let y = yLevel(seg.phase) * chartH
let xEnd = w * CGFloat(seg.end.timeIntervalSince(timeStart) / totalSpan)
if i == 0 { path.move(to: CGPoint(x: x, y: y)) }
else { path.addLine(to: CGPoint(x: x, y: y)) }
path.addLine(to: CGPoint(x: xEnd, y: y))
}
}
.stroke(Color.white.opacity(0.5), style: StrokeStyle(lineWidth: 1.5))
// Time labels at bottom
let hours = timeLabels()
ForEach(hours, id: \.1) { (date, label) in
let x = w * CGFloat(date.timeIntervalSince(timeStart) / totalSpan)
Text(label)
.font(.system(size: 9))
.foregroundColor(Theme.textSecondary)
.position(x: x, y: chartH + 18)
}
}
}
}
private func timeLabels() -> [(Date, String)] {
let cal = Calendar.current
let f = DateFormatter(); f.dateFormat = "HH:mm"
var labels: [(Date, String)] = []
var date = cal.date(bySetting: .minute, value: 0, of: timeStart) ?? timeStart
if date < timeStart { date = cal.date(byAdding: .hour, value: 1, to: date) ?? date }
while date < timeEnd {
labels.append((date, f.string(from: date)))
date = cal.date(byAdding: .hour, value: 1, to: date) ?? timeEnd
}
// Keep max 6 labels to avoid overlap
if labels.count > 6 {
let step = labels.count / 5
labels = stride(from: 0, to: labels.count, by: step).map { labels[$0] }
}
return labels
}
}