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>
216 lines
7.8 KiB
Swift
216 lines
7.8 KiB
Swift
import SwiftUI
|
||
|
||
// MARK: - Weekly Chart Card
|
||
|
||
struct WeeklyChartCard: View {
|
||
let heatmapData: [HeatmapEntry]
|
||
|
||
enum ChartType: String, CaseIterable {
|
||
case sleep = "Сон"
|
||
case hrv = "HRV"
|
||
case steps = "Шаги"
|
||
}
|
||
|
||
@State private var selectedChart: ChartType = .sleep
|
||
@State private var appeared = false
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 16) {
|
||
HStack {
|
||
GradientIcon(icon: "chart.xyaxis.line", colors: [Theme.purple, Theme.teal])
|
||
Text("За неделю").font(.headline.weight(.semibold)).foregroundColor(.white)
|
||
Spacer()
|
||
}
|
||
|
||
// Segmented picker
|
||
HStack(spacing: 4) {
|
||
ForEach(ChartType.allCases, id: \.self) { type in
|
||
Button {
|
||
withAnimation(.easeInOut(duration: 0.2)) { selectedChart = type }
|
||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||
} label: {
|
||
Text(type.rawValue)
|
||
.font(.caption.weight(.medium))
|
||
.foregroundColor(selectedChart == type ? .white : Theme.textSecondary)
|
||
.padding(.horizontal, 16).padding(.vertical, 8)
|
||
.background(
|
||
selectedChart == type
|
||
? chartColor.opacity(0.3)
|
||
: Color.clear
|
||
)
|
||
.cornerRadius(10)
|
||
}
|
||
}
|
||
}
|
||
.padding(4)
|
||
.background(Color.white.opacity(0.06))
|
||
.cornerRadius(12)
|
||
|
||
// Line Chart
|
||
LineChartView(
|
||
values: chartValues,
|
||
color: chartColor,
|
||
unit: chartUnit,
|
||
appeared: appeared
|
||
)
|
||
.frame(height: 160)
|
||
}
|
||
.padding(20)
|
||
.glassCard(cornerRadius: 20)
|
||
.padding(.horizontal)
|
||
.onAppear {
|
||
withAnimation(.easeOut(duration: 0.8).delay(0.3)) { appeared = true }
|
||
}
|
||
}
|
||
|
||
private var chartValues: [(date: String, value: Double)] {
|
||
heatmapData.map { entry in
|
||
let val: Double
|
||
switch selectedChart {
|
||
case .sleep: val = entry.sleep ?? 0
|
||
case .hrv: val = entry.hrv ?? 0
|
||
case .steps: val = Double(entry.steps ?? 0)
|
||
}
|
||
return (date: entry.displayDate, value: val)
|
||
}
|
||
}
|
||
|
||
private var chartColor: Color {
|
||
switch selectedChart {
|
||
case .sleep: return Theme.purple
|
||
case .hrv: return Theme.teal
|
||
case .steps: return Theme.orange
|
||
}
|
||
}
|
||
|
||
private var chartUnit: String {
|
||
switch selectedChart {
|
||
case .sleep: return "ч"
|
||
case .hrv: return "мс"
|
||
case .steps: return ""
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Line Chart
|
||
|
||
struct LineChartView: View {
|
||
let values: [(date: String, value: Double)]
|
||
let color: Color
|
||
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 w = geo.size.width
|
||
let h = geo.size.height - 24 // space for labels
|
||
let count = max(values.count - 1, 1)
|
||
|
||
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))
|
||
}
|
||
.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: "%.0fк", value / 1000) }
|
||
if value == floor(value) { return "\(Int(value))\(unit)" }
|
||
return String(format: "%.1f", value)
|
||
}
|
||
}
|