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>
267 lines
10 KiB
Swift
267 lines
10 KiB
Swift
import SwiftUI
|
|
|
|
// MARK: - Gradient Icon
|
|
|
|
struct GradientIcon: View {
|
|
let icon: String
|
|
let colors: [Color]
|
|
var size: CGFloat = 36
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
Circle()
|
|
.fill(colors.first?.opacity(0.25) ?? Color.clear)
|
|
.frame(width: size * 1.2, height: size * 1.2)
|
|
.blur(radius: 10)
|
|
Circle()
|
|
.fill(
|
|
LinearGradient(colors: colors.map { $0.opacity(0.15) },
|
|
startPoint: .topLeading, endPoint: .bottomTrailing)
|
|
)
|
|
.frame(width: size, height: size)
|
|
Image(systemName: icon)
|
|
.font(.system(size: size * 0.4))
|
|
.foregroundStyle(
|
|
LinearGradient(colors: colors, startPoint: .topLeading, endPoint: .bottomTrailing)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Base Metric Card
|
|
|
|
struct MetricCardView: View {
|
|
let icon: String
|
|
let title: String
|
|
let value: String
|
|
let subtitle: String
|
|
let color: Color
|
|
var gradientColors: [Color]? = nil
|
|
|
|
@State private var appeared = false
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
HStack {
|
|
GradientIcon(icon: icon, colors: gradientColors ?? [color, color.opacity(0.6)])
|
|
Spacer()
|
|
}
|
|
Text(value).font(.title2.bold()).foregroundColor(.white)
|
|
Text(title).font(.subheadline.weight(.medium)).foregroundColor(.white.opacity(0.7))
|
|
if !subtitle.isEmpty {
|
|
Text(subtitle).font(.caption).foregroundColor(Theme.textSecondary).lineLimit(2)
|
|
}
|
|
}
|
|
.padding(16)
|
|
.glassCard(cornerRadius: 20)
|
|
.opacity(appeared ? 1 : 0)
|
|
.offset(y: appeared ? 0 : 15)
|
|
.onAppear { withAnimation(.easeOut(duration: 0.5).delay(0.1)) { appeared = true } }
|
|
}
|
|
}
|
|
|
|
// MARK: - Sleep Card
|
|
|
|
struct SleepCard: View {
|
|
let sleep: SleepData
|
|
@State private var appeared = false
|
|
|
|
var totalHours: Double { sleep.totalSleep ?? 0 }
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
HStack {
|
|
GradientIcon(icon: "moon.fill", colors: [Theme.purple, Color(hex: "a78bfa")])
|
|
Spacer()
|
|
}
|
|
Text(String(format: "%.1f ч", totalHours)).font(.title2.bold()).foregroundColor(.white)
|
|
Text("Сон").font(.subheadline.weight(.medium)).foregroundColor(.white.opacity(0.7))
|
|
|
|
GeometryReader { geo in
|
|
ZStack(alignment: .leading) {
|
|
RoundedRectangle(cornerRadius: 4).fill(Color.white.opacity(0.08))
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.fill(LinearGradient(colors: [Theme.purple, Color(hex: "a78bfa")], startPoint: .leading, endPoint: .trailing))
|
|
.frame(width: geo.size.width * min(CGFloat(totalHours / 9.0), 1.0))
|
|
.shadow(color: Theme.purple.opacity(0.5), radius: 4, y: 0)
|
|
}
|
|
}
|
|
.frame(height: 6)
|
|
}
|
|
.padding(16)
|
|
.glassCard(cornerRadius: 20)
|
|
.opacity(appeared ? 1 : 0)
|
|
.offset(y: appeared ? 0 : 15)
|
|
.onAppear { withAnimation(.easeOut(duration: 0.5).delay(0.15)) { appeared = true } }
|
|
}
|
|
}
|
|
|
|
// MARK: - Steps Card
|
|
|
|
struct StepsCard: View {
|
|
let steps: Int
|
|
let goal: Int = 8000
|
|
@State private var appeared = false
|
|
|
|
var progress: Double { Double(steps) / Double(goal) }
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
HStack {
|
|
GradientIcon(icon: "figure.walk", colors: [Theme.orange, Color(hex: "ff6348")])
|
|
Spacer()
|
|
}
|
|
Text(formatSteps(steps)).font(.title2.bold()).foregroundColor(.white)
|
|
Text("Шаги").font(.subheadline.weight(.medium)).foregroundColor(.white.opacity(0.7))
|
|
|
|
GeometryReader { geo in
|
|
ZStack(alignment: .leading) {
|
|
RoundedRectangle(cornerRadius: 4).fill(Color.white.opacity(0.08))
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.fill(LinearGradient(colors: [Theme.orange, Color(hex: "ff6348")], startPoint: .leading, endPoint: .trailing))
|
|
.frame(width: geo.size.width * min(CGFloat(progress), 1.0))
|
|
.shadow(color: Theme.orange.opacity(0.5), radius: 4, y: 0)
|
|
}
|
|
}
|
|
.frame(height: 6)
|
|
|
|
Text("\(Int(progress * 100))% от цели")
|
|
.font(.system(size: 10)).foregroundColor(Theme.textSecondary)
|
|
}
|
|
.padding(16)
|
|
.glassCard(cornerRadius: 20)
|
|
.opacity(appeared ? 1 : 0)
|
|
.offset(y: appeared ? 0 : 15)
|
|
.onAppear { withAnimation(.easeOut(duration: 0.5).delay(0.25)) { appeared = true } }
|
|
}
|
|
|
|
private func formatSteps(_ n: Int) -> String {
|
|
let f = NumberFormatter(); f.numberStyle = .decimal; f.groupingSeparator = " "
|
|
return f.string(from: NSNumber(value: n)) ?? "\(n)"
|
|
}
|
|
}
|
|
|
|
// MARK: - Insights Card
|
|
|
|
struct InsightsCard: View {
|
|
let readiness: ReadinessResponse?
|
|
let latest: LatestHealthResponse?
|
|
|
|
var insights: [(icon: String, text: String, color: Color)] {
|
|
var result: [(String, String, Color)] = []
|
|
if let r = readiness {
|
|
if r.score >= 80 { result.append(("bolt.fill", "Отличный день для тренировки!", Theme.teal)) }
|
|
else if r.score < 60 { result.append(("bed.double.fill", "Сегодня лучше отдохнуть", Theme.red)) }
|
|
}
|
|
if let sleep = latest?.sleep?.totalSleep, sleep < 7 {
|
|
result.append(("moon.zzz.fill", "Мало сна — постарайся лечь раньше", Theme.purple))
|
|
}
|
|
if let hrv = latest?.hrv?.avg, hrv > 50 {
|
|
result.append(("heart.fill", "HRV в норме — хороший знак", Theme.teal))
|
|
} else if let hrv = latest?.hrv?.avg, hrv > 0 {
|
|
result.append(("exclamationmark.triangle.fill", "HRV ниже нормы — следи за стрессом", Theme.orange))
|
|
}
|
|
if let steps = latest?.steps?.total, steps > 0 && steps < 5000 {
|
|
result.append(("figure.walk", "Мало шагов — прогуляйся!", Theme.orange))
|
|
}
|
|
if result.isEmpty {
|
|
result.append(("sparkles", "Данные обновятся после синхронизации", Theme.textSecondary))
|
|
}
|
|
return result
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
HStack {
|
|
GradientIcon(icon: "lightbulb.fill", colors: [Theme.orange, Color(hex: "ff6348")])
|
|
Text("Инсайты").font(.headline.weight(.semibold)).foregroundColor(.white)
|
|
Spacer()
|
|
}
|
|
ForEach(Array(insights.enumerated()), id: \.offset) { _, insight in
|
|
HStack(spacing: 12) {
|
|
Image(systemName: insight.icon).font(.subheadline).foregroundColor(insight.color).frame(width: 24)
|
|
Text(insight.text).font(.subheadline).foregroundColor(.white.opacity(0.85)).lineLimit(2)
|
|
}
|
|
}
|
|
}
|
|
.padding(20)
|
|
.glassCard(cornerRadius: 20)
|
|
.padding(.horizontal)
|
|
}
|
|
}
|
|
|
|
// MARK: - Sleep Phases Card
|
|
|
|
struct SleepPhasesCard: View {
|
|
let sleep: SleepData
|
|
@State private var appeared = false
|
|
|
|
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)] {
|
|
[
|
|
("Глубокий", deep, Theme.purple),
|
|
("Быстрый (REM)", rem, Color(hex: "a78bfa")),
|
|
("Базовый", core, Color(hex: "c4b5fd")),
|
|
]
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
HStack {
|
|
GradientIcon(icon: "bed.double.fill", colors: [Theme.purple, Color(hex: "a78bfa")])
|
|
Text("Фазы сна").font(.headline.weight(.semibold)).foregroundColor(.white)
|
|
Spacer()
|
|
Text(String(format: "%.1f ч", total))
|
|
.font(.callout.bold()).foregroundColor(Theme.purple)
|
|
}
|
|
|
|
// Stacked bar
|
|
GeometryReader { geo in
|
|
HStack(spacing: 2) {
|
|
ForEach(phases, id: \.name) { phase in
|
|
let fraction = total > 0 ? phase.value / total : 0
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.fill(phase.color)
|
|
.frame(width: max(geo.size.width * CGFloat(fraction), fraction > 0 ? 4 : 0))
|
|
.shadow(color: phase.color.opacity(0.4), radius: 4, y: 0)
|
|
}
|
|
}
|
|
}
|
|
.frame(height: 12)
|
|
|
|
// Phase details
|
|
ForEach(phases, id: \.name) { phase in
|
|
HStack(spacing: 12) {
|
|
Circle().fill(phase.color).frame(width: 8, height: 8)
|
|
Text(phase.name).font(.callout).foregroundColor(.white)
|
|
Spacer()
|
|
Text(fmtDuration(phase.value))
|
|
.font(.callout.bold().monospacedDigit()).foregroundColor(phase.color)
|
|
if total > 0 {
|
|
Text("\(Int(phase.value / total * 100))%")
|
|
.font(.caption).foregroundColor(Theme.textSecondary)
|
|
.frame(width: 32, alignment: .trailing)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(20)
|
|
.glassCard(cornerRadius: 20)
|
|
.padding(.horizontal)
|
|
.opacity(appeared ? 1 : 0)
|
|
.offset(y: appeared ? 0 : 15)
|
|
.onAppear { withAnimation(.easeOut(duration: 0.5).delay(0.2)) { appeared = true } }
|
|
}
|
|
|
|
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)м"
|
|
}
|
|
}
|