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

@@ -16,14 +16,9 @@ struct WeeklyChartCard: View {
var body: some View {
VStack(alignment: .leading, spacing: 16) {
// Header
HStack {
GradientIcon(icon: "chart.bar.fill", colors: [Color(hex: "7c3aed"), Color(hex: "00d4aa")])
Text("За неделю")
.font(.headline.weight(.semibold))
.foregroundColor(.white)
GradientIcon(icon: "chart.xyaxis.line", colors: [Theme.purple, Theme.teal])
Text("За неделю").font(.headline.weight(.semibold)).foregroundColor(.white)
Spacer()
}
@@ -31,19 +26,16 @@ struct WeeklyChartCard: View {
HStack(spacing: 4) {
ForEach(ChartType.allCases, id: \.self) { type in
Button {
withAnimation(.easeInOut(duration: 0.2)) {
selectedChart = type
UIImpactFeedbackGenerator(style: .light).impactOccurred()
}
withAnimation(.easeInOut(duration: 0.2)) { selectedChart = type }
UIImpactFeedbackGenerator(style: .light).impactOccurred()
} label: {
Text(type.rawValue)
.font(.caption.weight(.medium))
.foregroundColor(selectedChart == type ? .white : Color(hex: "8888aa"))
.padding(.horizontal, 16)
.padding(.vertical, 8)
.foregroundColor(selectedChart == type ? .white : Theme.textSecondary)
.padding(.horizontal, 16).padding(.vertical, 8)
.background(
selectedChart == type
? Color(hex: "7c3aed").opacity(0.5)
? chartColor.opacity(0.3)
: Color.clear
)
.cornerRadius(10)
@@ -51,34 +43,23 @@ struct WeeklyChartCard: View {
}
}
.padding(4)
.background(Color(hex: "1a1a3e"))
.background(Color.white.opacity(0.06))
.cornerRadius(12)
// Chart
BarChartView(
// Line Chart
LineChartView(
values: chartValues,
color: chartColor,
maxValue: chartMaxValue,
unit: chartUnit,
appeared: appeared
)
.frame(height: 160)
}
.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)
.onAppear {
withAnimation(.easeOut(duration: 0.8).delay(0.3)) {
appeared = true
}
withAnimation(.easeOut(duration: 0.8).delay(0.3)) { appeared = true }
}
}
@@ -96,17 +77,9 @@ struct WeeklyChartCard: View {
private var chartColor: Color {
switch selectedChart {
case .sleep: return Color(hex: "7c3aed")
case .hrv: return Color(hex: "00d4aa")
case .steps: return Color(hex: "ffa502")
}
}
private var chartMaxValue: Double {
switch selectedChart {
case .sleep: return 10
case .hrv: return 120
case .steps: return 12000
case .sleep: return Theme.purple
case .hrv: return Theme.teal
case .steps: return Theme.orange
}
}
@@ -119,68 +92,124 @@ struct WeeklyChartCard: View {
}
}
// MARK: - Bar Chart
// MARK: - Line Chart
struct BarChartView: View {
struct LineChartView: View {
let values: [(date: String, value: Double)]
let color: Color
let maxValue: Double
let unit: String
let appeared: Bool
private var maxVal: Double {
let m = values.map(\.value).max() ?? 1
return m > 0 ? m * 1.15 : 1
}
private var minVal: Double {
let m = values.map(\.value).min() ?? 0
return max(m * 0.85, 0)
}
var body: some View {
GeometryReader { geo in
let barWidth = max((geo.size.width - CGFloat(values.count - 1) * 8) / CGFloat(max(values.count, 1)), 10)
let chartHeight = geo.size.height - 30
let w = geo.size.width
let h = geo.size.height - 24 // space for labels
let count = max(values.count - 1, 1)
HStack(alignment: .bottom, spacing: 8) {
ForEach(Array(values.enumerated()), id: \.offset) { index, item in
VStack(spacing: 4) {
// Value label
if item.value > 0 {
Text(formatValue(item.value))
.font(.system(size: 9, weight: .medium))
.foregroundColor(Color(hex: "8888aa"))
}
// Bar
RoundedRectangle(cornerRadius: 6)
.fill(
LinearGradient(
colors: [color, color.opacity(0.5)],
startPoint: .top,
endPoint: .bottom
)
)
.frame(
width: barWidth,
height: appeared
? max(CGFloat(item.value / maxValue) * chartHeight, 4)
: 4
)
.animation(
.spring(response: 0.6, dampingFraction: 0.7).delay(Double(index) * 0.05),
value: appeared
)
// Date label
Text(item.date)
.font(.system(size: 10))
.foregroundColor(Color(hex: "8888aa"))
ZStack(alignment: .topLeading) {
// Grid lines
ForEach(0..<4, id: \.self) { i in
let y = h * CGFloat(i) / 3.0
Path { path in
path.move(to: CGPoint(x: 0, y: y))
path.addLine(to: CGPoint(x: w, y: y))
}
.frame(maxWidth: .infinity)
.stroke(Color.white.opacity(0.04), lineWidth: 1)
}
if values.count >= 2 {
// Gradient fill under line
Path { path in
for (i, val) in values.enumerated() {
let x = w * CGFloat(i) / CGFloat(count)
let y = h * (1 - CGFloat((val.value - minVal) / max(maxVal - minVal, 1)))
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: w, y: h))
path.addLine(to: CGPoint(x: 0, y: h))
path.closeSubpath()
}
.fill(
LinearGradient(
colors: [color.opacity(appeared ? 0.25 : 0), color.opacity(0)],
startPoint: .top, endPoint: .bottom
)
)
.animation(.easeOut(duration: 1), value: appeared)
// Line
Path { path in
for (i, val) in values.enumerated() {
let x = w * CGFloat(i) / CGFloat(count)
let y = h * (1 - CGFloat((val.value - minVal) / max(maxVal - minVal, 1)))
if i == 0 { path.move(to: CGPoint(x: x, y: y)) }
else { path.addLine(to: CGPoint(x: x, y: y)) }
}
}
.trim(from: 0, to: appeared ? 1 : 0)
.stroke(
LinearGradient(colors: [color, color.opacity(0.6)], startPoint: .leading, endPoint: .trailing),
style: StrokeStyle(lineWidth: 2.5, lineCap: .round, lineJoin: .round)
)
.shadow(color: color.opacity(0.5), radius: 6, y: 2)
.animation(.easeOut(duration: 1), value: appeared)
// Dots
ForEach(Array(values.enumerated()), id: \.offset) { i, val in
let x = w * CGFloat(i) / CGFloat(count)
let y = h * (1 - CGFloat((val.value - minVal) / max(maxVal - minVal, 1)))
Circle()
.fill(color)
.frame(width: 6, height: 6)
.shadow(color: color.opacity(0.6), radius: 4)
.position(x: x, y: y)
.opacity(appeared ? 1 : 0)
.animation(.easeOut(duration: 0.4).delay(Double(i) * 0.08), value: appeared)
}
}
// Date labels at bottom
HStack(spacing: 0) {
ForEach(Array(values.enumerated()), id: \.offset) { _, val in
Text(val.date)
.font(.system(size: 9))
.foregroundColor(Theme.textSecondary)
.frame(maxWidth: .infinity)
}
}
.offset(y: h + 6)
// Value labels on right
VStack {
Text(formatValue(maxVal))
Spacer()
Text(formatValue((maxVal + minVal) / 2))
Spacer()
Text(formatValue(minVal))
}
.font(.system(size: 8))
.foregroundColor(Color.white.opacity(0.2))
.frame(height: h)
.offset(x: w - 28)
}
}
}
private func formatValue(_ value: Double) -> String {
if value >= 1000 {
return String(format: "%.1fк", value / 1000)
} else if value == floor(value) {
return "\(Int(value))\(unit)"
} else {
return String(format: "%.1f\(unit)", value)
}
if value >= 1000 { return String(format: "%.0fк", value / 1000) }
if value == floor(value) { return "\(Int(value))\(unit)" }
return String(format: "%.1f", value)
}
}