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:
249
PulseHealth/Views/Health/SleepDetailView.swift
Normal file
249
PulseHealth/Views/Health/SleepDetailView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user