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>
250 lines
11 KiB
Swift
250 lines
11 KiB
Swift
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
|
|
}
|
|
}
|