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

@@ -9,24 +9,20 @@ struct GradientIcon: View {
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.2) },
startPoint: .topLeading,
endPoint: .bottomTrailing
)
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
)
LinearGradient(colors: colors, startPoint: .topLeading, endPoint: .bottomTrailing)
)
}
}
@@ -41,79 +37,26 @@ struct MetricCardView: View {
let subtitle: String
let color: Color
var gradientColors: [Color]? = nil
var progress: Double? = nil
var progressMax: Double = 1.0
@State private var appeared = false
var body: some View {
VStack(alignment: .leading, spacing: 12) {
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 == false {
Text(subtitle)
.font(.caption)
.foregroundColor(Color(hex: "8888aa"))
.lineLimit(2)
}
if let progress = progress {
VStack(spacing: 4) {
GeometryReader { geo in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 4)
.fill(Color.white.opacity(0.08))
RoundedRectangle(cornerRadius: 4)
.fill(
LinearGradient(
colors: gradientColors ?? [color, color.opacity(0.6)],
startPoint: .leading,
endPoint: .trailing
)
)
.frame(width: geo.size.width * min(CGFloat(progress / progressMax), 1.0))
}
}
.frame(height: 6)
HStack {
Spacer()
Text("\(Int(progress / progressMax * 100))% от цели")
.font(.system(size: 10))
.foregroundColor(Color(hex: "8888aa"))
}
}
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)
.background(
RoundedRectangle(cornerRadius: 20)
.fill(.ultraThinMaterial)
.overlay(
RoundedRectangle(cornerRadius: 20)
.fill(Color(hex: "12122a").opacity(0.7))
)
)
.shadow(color: .black.opacity(0.15), radius: 8, y: 4)
.glassCard(cornerRadius: 20)
.opacity(appeared ? 1 : 0)
.offset(y: appeared ? 0 : 15)
.onAppear {
withAnimation(.easeOut(duration: 0.5).delay(0.1)) {
appeared = true
}
}
.onAppear { withAnimation(.easeOut(duration: 0.5).delay(0.1)) { appeared = true } }
}
}
@@ -124,90 +67,32 @@ struct SleepCard: View {
@State private var appeared = false
var totalHours: Double { sleep.totalSleep ?? 0 }
var deepMin: Int { Int((sleep.deep ?? 0) * 60) }
var remHours: String { formatHours(sleep.rem ?? 0) }
var coreHours: String { formatHours(sleep.core ?? 0) }
var body: some View {
VStack(alignment: .leading, spacing: 12) {
VStack(alignment: .leading, spacing: 10) {
HStack {
GradientIcon(icon: "moon.fill", colors: [Color(hex: "7c3aed"), Color(hex: "a78bfa")])
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))
Text(String(format: "%.1f ч", totalHours))
.font(.title2.bold())
.foregroundColor(.white)
Text("Сон")
.font(.subheadline.weight(.medium))
.foregroundColor(.white.opacity(0.7))
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 12) {
SleepPhase(label: "Deep", value: "\(deepMin)мин", color: Color(hex: "7c3aed"))
SleepPhase(label: "REM", value: remHours, color: Color(hex: "a78bfa"))
SleepPhase(label: "Core", value: coreHours, color: Color(hex: "c4b5fd"))
}
.font(.system(size: 10))
}
// Progress to 9h goal
GeometryReader { geo in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 4).fill(Color.white.opacity(0.08))
RoundedRectangle(cornerRadius: 4)
.fill(Color.white.opacity(0.08))
RoundedRectangle(cornerRadius: 4)
.fill(
LinearGradient(
colors: [Color(hex: "7c3aed"), Color(hex: "a78bfa")],
startPoint: .leading,
endPoint: .trailing
)
)
.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)
.background(
RoundedRectangle(cornerRadius: 20)
.fill(.ultraThinMaterial)
.overlay(
RoundedRectangle(cornerRadius: 20)
.fill(Color(hex: "12122a").opacity(0.7))
)
)
.shadow(color: .black.opacity(0.15), radius: 8, y: 4)
.glassCard(cornerRadius: 20)
.opacity(appeared ? 1 : 0)
.offset(y: appeared ? 0 : 15)
.onAppear {
withAnimation(.easeOut(duration: 0.5).delay(0.15)) {
appeared = true
}
}
}
private func formatHours(_ h: Double) -> String {
if h < 1 { return "\(Int(h * 60))мин" }
return String(format: "%.0fч", h)
}
}
struct SleepPhase: View {
let label: String
let value: String
let color: Color
var body: some View {
VStack(alignment: .leading, spacing: 2) {
Text(label)
.foregroundColor(Color(hex: "8888aa"))
Text(value)
.foregroundColor(color)
.fontWeight(.medium)
}
.onAppear { withAnimation(.easeOut(duration: 0.5).delay(0.15)) { appeared = true } }
}
}
@@ -219,68 +104,40 @@ struct StepsCard: View {
@State private var appeared = false
var progress: Double { Double(steps) / Double(goal) }
var percent: Int { Int(progress * 100) }
var body: some View {
VStack(alignment: .leading, spacing: 12) {
VStack(alignment: .leading, spacing: 10) {
HStack {
GradientIcon(icon: "figure.walk", colors: [Color(hex: "ffa502"), Color(hex: "ff6348")])
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))
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(Color.white.opacity(0.08))
RoundedRectangle(cornerRadius: 4)
.fill(
LinearGradient(
colors: [Color(hex: "ffa502"), Color(hex: "ff6348")],
startPoint: .leading,
endPoint: .trailing
)
)
.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("\(percent)% от цели")
.font(.system(size: 10))
.foregroundColor(Color(hex: "8888aa"))
Text("\(Int(progress * 100))% от цели")
.font(.system(size: 10)).foregroundColor(Theme.textSecondary)
}
.padding(16)
.background(
RoundedRectangle(cornerRadius: 20)
.fill(.ultraThinMaterial)
.overlay(
RoundedRectangle(cornerRadius: 20)
.fill(Color(hex: "12122a").opacity(0.7))
)
)
.shadow(color: .black.opacity(0.15), radius: 8, y: 4)
.glassCard(cornerRadius: 20)
.opacity(appeared ? 1 : 0)
.offset(y: appeared ? 0 : 15)
.onAppear {
withAnimation(.easeOut(duration: 0.5).delay(0.25)) {
appeared = true
}
}
.onAppear { withAnimation(.easeOut(duration: 0.5).delay(0.25)) { appeared = true } }
}
private func formatSteps(_ n: Int) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.groupingSeparator = " "
return formatter.string(from: NSNumber(value: n)) ?? "\(n)"
let f = NumberFormatter(); f.numberStyle = .decimal; f.groupingSeparator = " "
return f.string(from: NSNumber(value: n)) ?? "\(n)"
}
}
@@ -289,81 +146,121 @@ struct StepsCard: View {
struct InsightsCard: View {
let readiness: ReadinessResponse?
let latest: LatestHealthResponse?
@State private var appeared = false
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", "Отличный день для тренировки!", Color(hex: "00d4aa")))
} else if r.score < 60 {
result.append(("bed.double.fill", "Сегодня лучше отдохнуть", Color(hex: "ff4757")))
}
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", "Мало сна — постарайся лечь раньше", Color(hex: "7c3aed")))
result.append(("moon.zzz.fill", "Мало сна — постарайся лечь раньше", Theme.purple))
}
if let hrv = latest?.hrv?.avg, hrv > 50 {
result.append(("heart.fill", "HRV в норме — хороший знак", Color(hex: "00d4aa")))
result.append(("heart.fill", "HRV в норме — хороший знак", Theme.teal))
} else if let hrv = latest?.hrv?.avg, hrv > 0 {
result.append(("exclamationmark.triangle.fill", "HRV ниже нормы — следи за стрессом", Color(hex: "ffa502")))
result.append(("exclamationmark.triangle.fill", "HRV ниже нормы — следи за стрессом", Theme.orange))
}
if let steps = latest?.steps?.total, steps > 0 && steps < 5000 {
result.append(("figure.walk", "Мало шагов — прогуляйся!", Color(hex: "ffa502")))
result.append(("figure.walk", "Мало шагов — прогуляйся!", Theme.orange))
}
if result.isEmpty {
result.append(("sparkles", "Данные обновятся после синхронизации", Color(hex: "8888aa")))
result.append(("sparkles", "Данные обновятся после синхронизации", Theme.textSecondary))
}
return result
}
var body: some View {
VStack(alignment: .leading, spacing: 14) {
HStack {
GradientIcon(icon: "lightbulb.fill", colors: [Color(hex: "ffa502"), Color(hex: "ff6348")])
Text("Инсайты")
.font(.headline.weight(.semibold))
.foregroundColor(.white)
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)
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)
.background(
RoundedRectangle(cornerRadius: 20)
.fill(.ultraThinMaterial)
.overlay(
RoundedRectangle(cornerRadius: 20)
.fill(Color(hex: "12122a").opacity(0.7))
)
)
.shadow(color: .black.opacity(0.2), radius: 10, y: 5)
.glassCard(cornerRadius: 20)
.padding(.horizontal)
.opacity(appeared ? 1 : 0)
.offset(y: appeared ? 0 : 20)
.onAppear {
withAnimation(.easeOut(duration: 0.5).delay(0.3)) {
appeared = true
}
}
}
}
// 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)м"
}
}