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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user