From 17b82a874faec52bd0bee439a2ad0f66cc4fd746 Mon Sep 17 00:00:00 2001 From: Cosmo Date: Wed, 25 Mar 2026 12:32:55 +0000 Subject: [PATCH] feat: add habit creation, savings tabs, fix dashboard, fix tab order --- PulseHealth/Models/SavingsModels.swift | 24 +++ PulseHealth/Services/APIService.swift | 4 + .../Views/Dashboard/DashboardView.swift | 7 +- PulseHealth/Views/Habits/AddHabitView.swift | 162 ++++++++++++++++++ PulseHealth/Views/Habits/HabitsView.swift | 10 ++ PulseHealth/Views/Savings/SavingsView.swift | 162 +++++++++++++++--- 6 files changed, 339 insertions(+), 30 deletions(-) create mode 100644 PulseHealth/Views/Habits/AddHabitView.swift diff --git a/PulseHealth/Models/SavingsModels.swift b/PulseHealth/Models/SavingsModels.swift index fd66ce3..b57cbb9 100644 --- a/PulseHealth/Models/SavingsModels.swift +++ b/PulseHealth/Models/SavingsModels.swift @@ -55,6 +55,30 @@ struct SavingsCategory: Codable, Identifiable { } } +struct SavingsTransaction: Codable, Identifiable { + let id: Int + var categoryId: Int? + var userId: Int? + var amount: Double + var type: String // "deposit" или "withdrawal" + var description: String? + var date: String? + var createdAt: String? + var categoryName: String? + var userName: String? + + var isDeposit: Bool { type == "deposit" } + + enum CodingKeys: String, CodingKey { + case id, amount, type, description, date + case categoryId = "category_id" + case userId = "user_id" + case createdAt = "created_at" + case categoryName = "category_name" + case userName = "user_name" + } +} + struct SavingsStats: Codable { var totalBalance: Double? var totalDeposits: Double? diff --git a/PulseHealth/Services/APIService.swift b/PulseHealth/Services/APIService.swift index 51b4cde..36ceee1 100644 --- a/PulseHealth/Services/APIService.swift +++ b/PulseHealth/Services/APIService.swift @@ -120,6 +120,10 @@ class APIService { return try await fetch("/savings/stats", token: token) } + func getSavingsTransactions(token: String, limit: Int = 50) async throws -> [SavingsTransaction] { + return try await fetch("/savings/transactions?limit=\(limit)", token: token) + } + func getFinanceCategories(token: String) async throws -> [FinanceCategory] { return try await fetch("/finance/categories", token: token) } diff --git a/PulseHealth/Views/Dashboard/DashboardView.swift b/PulseHealth/Views/Dashboard/DashboardView.swift index ba28b39..b814d06 100644 --- a/PulseHealth/Views/Dashboard/DashboardView.swift +++ b/PulseHealth/Views/Dashboard/DashboardView.swift @@ -5,7 +5,6 @@ struct DashboardView: View { @State private var tasks: [PulseTask] = [] @State private var habits: [Habit] = [] @State private var readiness: ReadinessResponse? - @State private var summary: FinanceSummary? @State private var isLoading = true var greeting: String { @@ -66,8 +65,8 @@ struct DashboardView: View { HStack(spacing: 12) { StatCard(icon: "checkmark.circle.fill", value: "\(pendingTasks.count)", label: "Задач", color: "00d4aa") StatCard(icon: "flame.fill", value: "\(completedHabitsToday)/\(habits.count)", label: "Привычек", color: "ffa502") - if let s = summary, let balance = s.balance { - StatCard(icon: "rublesign.circle.fill", value: "\(Int(balance))₽", label: "Финансы", color: "7c3aed") + if let r = readiness { + StatCard(icon: "heart.fill", value: "\(r.score)", label: "Готовность", color: r.score >= 80 ? "00d4aa" : r.score >= 60 ? "ffa502" : "ff4757") } } .padding(.horizontal) @@ -109,11 +108,9 @@ struct DashboardView: View { async let t = APIService.shared.getTodayTasks(token: authManager.token) async let h = APIService.shared.getHabits(token: authManager.token) async let r = HealthAPIService.shared.getReadiness(apiKey: authManager.healthApiKey) - async let s = APIService.shared.getFinanceSummary(token: authManager.token) tasks = (try? await t) ?? [] habits = (try? await h) ?? [] readiness = try? await r - summary = try? await s isLoading = false } diff --git a/PulseHealth/Views/Habits/AddHabitView.swift b/PulseHealth/Views/Habits/AddHabitView.swift new file mode 100644 index 0000000..5ed8a0a --- /dev/null +++ b/PulseHealth/Views/Habits/AddHabitView.swift @@ -0,0 +1,162 @@ +import SwiftUI + +struct AddHabitView: View { + @Binding var isPresented: Bool + @EnvironmentObject var authManager: AuthManager + let onAdded: () async -> Void + + @State private var name = "" + @State private var description = "" + @State private var frequency = "daily" + @State private var selectedIcon = "🔥" + @State private var selectedColor = "#00d4aa" + @State private var isLoading = false + + let frequencies: [(String, String, String)] = [ + ("daily", "Каждый день", "calendar"), + ("weekly", "Каждую неделю", "calendar.badge.clock"), + ("monthly", "Каждый месяц", "calendar.badge.plus") + ] + + let icons = ["🔥", "💪", "🏃", "📚", "💧", "🧘", "🎯", "⭐️", "🌟", "✅", "🏋️", "🚴", "🍎", "😴", "🧠", "🎨", "🎵", "💊", "🌿", "💰"] + + let colors = ["#00d4aa", "#7c3aed", "#ff4757", "#ffa502", "#6366f1", "#ec4899", "#14b8a6", "#f59e0b", "#10b981", "#3b82f6"] + + var body: some View { + ZStack { + Color(hex: "0a0a1a").ignoresSafeArea() + + VStack(spacing: 0) { + // Handle + RoundedRectangle(cornerRadius: 3) + .fill(Color.white.opacity(0.2)) + .frame(width: 40, height: 4) + .padding(.top, 12) + + // Header + HStack { + Button("Отмена") { isPresented = false } + .foregroundColor(Color(hex: "8888aa")) + Spacer() + Text("Новая привычка").font(.headline).foregroundColor(.white) + Spacer() + Button(action: save) { + if isLoading { ProgressView().tint(Color(hex: "00d4aa")).scaleEffect(0.8) } + else { Text("Добавить").foregroundColor(name.isEmpty ? Color(hex: "8888aa") : Color(hex: "00d4aa")).fontWeight(.semibold) } + } + .disabled(name.isEmpty || isLoading) + } + .padding(.horizontal, 20).padding(.vertical, 16) + + Divider().background(Color.white.opacity(0.1)) + + ScrollView { + VStack(spacing: 20) { + // Preview + HStack(spacing: 16) { + ZStack { + Circle() + .fill(Color(hex: String(selectedColor.dropFirst())).opacity(0.25)) + .frame(width: 56, height: 56) + Text(selectedIcon).font(.title2) + } + VStack(alignment: .leading, spacing: 4) { + Text(name.isEmpty ? "Название привычки" : name) + .font(.callout.bold()) + .foregroundColor(name.isEmpty ? Color(hex: "8888aa") : .white) + Text(frequencies.first { $0.0 == frequency }?.1 ?? "").font(.caption).foregroundColor(Color(hex: "8888aa")) + } + Spacer() + } + .padding(16) + .background(RoundedRectangle(cornerRadius: 16).fill(Color.white.opacity(0.05))) + + // Name + VStack(alignment: .leading, spacing: 8) { + Label("Название", systemImage: "pencil").font(.caption).foregroundColor(Color(hex: "8888aa")) + TextField("Например: Читать 30 минут", text: $name) + .foregroundColor(.white).padding(14) + .background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.07))) + } + + // Frequency + VStack(alignment: .leading, spacing: 8) { + Label("Периодичность", systemImage: "calendar").font(.caption).foregroundColor(Color(hex: "8888aa")) + VStack(spacing: 6) { + ForEach(frequencies, id: \.0) { f in + Button(action: { frequency = f.0 }) { + HStack { + Image(systemName: f.2).foregroundColor(frequency == f.0 ? Color(hex: "00d4aa") : Color(hex: "8888aa")) + Text(f.1).foregroundColor(frequency == f.0 ? .white : Color(hex: "8888aa")) + Spacer() + if frequency == f.0 { + Image(systemName: "checkmark").foregroundColor(Color(hex: "00d4aa")) + } + } + .padding(14) + .background(RoundedRectangle(cornerRadius: 12).fill(frequency == f.0 ? Color(hex: "00d4aa").opacity(0.15) : Color.white.opacity(0.05))) + } + } + } + } + + // Icon picker + VStack(alignment: .leading, spacing: 8) { + Label("Иконка", systemImage: "face.smiling").font(.caption).foregroundColor(Color(hex: "8888aa")) + LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 5), spacing: 8) { + ForEach(icons, id: \.self) { icon in + Button(action: { selectedIcon = icon }) { + Text(icon).font(.title2) + .frame(width: 44, height: 44) + .background(Circle().fill(selectedIcon == icon ? Color(hex: "00d4aa").opacity(0.25) : Color.white.opacity(0.05))) + .overlay(Circle().stroke(selectedIcon == icon ? Color(hex: "00d4aa") : Color.clear, lineWidth: 2)) + } + } + } + } + + // Color picker + VStack(alignment: .leading, spacing: 8) { + Label("Цвет", systemImage: "paintpalette").font(.caption).foregroundColor(Color(hex: "8888aa")) + HStack(spacing: 10) { + ForEach(colors, id: \.self) { color in + Button(action: { selectedColor = color }) { + Circle() + .fill(Color(hex: String(color.dropFirst()))) + .frame(width: 32, height: 32) + .overlay(Circle().stroke(.white, lineWidth: selectedColor == color ? 2 : 0)) + .scaleEffect(selectedColor == color ? 1.15 : 1.0) + .animation(.easeInOut(duration: 0.15), value: selectedColor) + } + } + } + } + } + .padding(20) + } + } + } + } + + func save() { + isLoading = true + Task { + var req = URLRequest(url: URL(string: "https://api.digital-home.site/habits")!) + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.setValue("Bearer \(authManager.token)", forHTTPHeaderField: "Authorization") + let body: [String: Any] = [ + "name": name, + "description": description, + "frequency": frequency, + "icon": selectedIcon, + "color": selectedColor, + "target_count": 1 + ] + req.httpBody = try? JSONSerialization.data(withJSONObject: body) + _ = try? await URLSession.shared.data(for: req) + await onAdded() + await MainActor.run { isPresented = false } + } + } +} diff --git a/PulseHealth/Views/Habits/HabitsView.swift b/PulseHealth/Views/Habits/HabitsView.swift index 026f495..120e2fa 100644 --- a/PulseHealth/Views/Habits/HabitsView.swift +++ b/PulseHealth/Views/Habits/HabitsView.swift @@ -4,6 +4,7 @@ struct HabitsView: View { @EnvironmentObject var authManager: AuthManager @State private var habits: [Habit] = [] @State private var isLoading = true + @State private var showAddHabit = false var completedCount: Int { habits.filter { $0.completedToday == true }.count } @@ -17,6 +18,9 @@ struct HabitsView: View { Text("\(completedCount)/\(habits.count) выполнено").font(.subheadline).foregroundColor(Color(hex: "8888aa")) } Spacer() + Button(action: { showAddHabit = true }) { + Image(systemName: "plus.circle.fill").font(.title2).foregroundColor(Color(hex: "00d4aa")) + } }.padding() // Progress bar @@ -57,6 +61,12 @@ struct HabitsView: View { } .task { await loadHabits() } .refreshable { await loadHabits(refresh: true) } + .sheet(isPresented: $showAddHabit) { + AddHabitView(isPresented: $showAddHabit) { await loadHabits(refresh: true) } + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + .presentationBackground(Color(hex: "0a0a1a")) + } } func loadHabits(refresh: Bool = false) async { diff --git a/PulseHealth/Views/Savings/SavingsView.swift b/PulseHealth/Views/Savings/SavingsView.swift index 5922361..3f023dd 100644 --- a/PulseHealth/Views/Savings/SavingsView.swift +++ b/PulseHealth/Views/Savings/SavingsView.swift @@ -1,37 +1,56 @@ import SwiftUI struct SavingsView: View { + @EnvironmentObject var authManager: AuthManager + @State private var selectedTab = 0 + + var body: some View { + ZStack { + Color(hex: "0a0a1a").ignoresSafeArea() + VStack(spacing: 0) { + // Header + HStack { + Text("Накопления").font(.title.bold()).foregroundColor(.white) + Spacer() + }.padding() + + // Segment control + Picker("", selection: $selectedTab) { + Text("Обзор").tag(0) + Text("Транзакции").tag(1) + } + .pickerStyle(.segmented) + .padding(.horizontal) + .padding(.bottom, 12) + + if selectedTab == 0 { + SavingsOverviewTab() + } else { + SavingsTransactionsTab() + } + } + } + } +} + +struct SavingsOverviewTab: View { @EnvironmentObject var authManager: AuthManager @State private var categories: [SavingsCategory] = [] @State private var stats: SavingsStats? @State private var isLoading = true var body: some View { - ZStack { - Color(hex: "0a0a1a").ignoresSafeArea() - VStack(spacing: 0) { - HStack { - Text("Накопления").font(.title.bold()).foregroundColor(.white) - Spacer() - }.padding() - - if isLoading { - ProgressView().tint(Color(hex: "00d4aa")).padding(.top, 40) - Spacer() - } else { - ScrollView { - VStack(spacing: 16) { - if let s = stats { - SavingsTotalCard(stats: s) - } - - VStack(spacing: 10) { - ForEach(categories) { cat in - SavingsCategoryCard(category: cat) - } - } - .padding(.horizontal) - } + Group { + if isLoading { + ProgressView().tint(Color(hex: "00d4aa")).padding(.top, 40) + Spacer() + } else { + ScrollView { + VStack(spacing: 16) { + if let s = stats { SavingsTotalCard(stats: s) } + VStack(spacing: 10) { + ForEach(categories) { cat in SavingsCategoryCard(category: cat) } + }.padding(.horizontal) } } } @@ -50,6 +69,97 @@ struct SavingsView: View { } } +struct SavingsTransactionsTab: View { + @EnvironmentObject var authManager: AuthManager + @State private var transactions: [SavingsTransaction] = [] + @State private var isLoading = true + + var body: some View { + Group { + if isLoading { + ProgressView().tint(Color(hex: "00d4aa")).padding(.top, 40) + Spacer() + } else if transactions.isEmpty { + VStack(spacing: 12) { + Text("💸").font(.system(size: 50)) + Text("Нет транзакций").foregroundColor(Color(hex: "8888aa")) + }.padding(.top, 60) + Spacer() + } else { + List { + ForEach(transactions) { tx in + SavingsTransactionRow(transaction: tx) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + } + } + .task { await loadData() } + .refreshable { await loadData(refresh: true) } + } + + func loadData(refresh: Bool = false) async { + if !refresh { isLoading = true } + transactions = (try? await APIService.shared.getSavingsTransactions(token: authManager.token)) ?? [] + isLoading = false + } +} + +struct SavingsTransactionRow: View { + let transaction: SavingsTransaction + + var body: some View { + HStack(spacing: 12) { + ZStack { + Circle() + .fill((transaction.isDeposit ? Color(hex: "00d4aa") : Color(hex: "ff4757")).opacity(0.15)) + .frame(width: 40, height: 40) + Image(systemName: transaction.isDeposit ? "arrow.down.circle.fill" : "arrow.up.circle.fill") + .foregroundColor(transaction.isDeposit ? Color(hex: "00d4aa") : Color(hex: "ff4757")) + } + + VStack(alignment: .leading, spacing: 3) { + Text(transaction.categoryName ?? "Без категории") + .font(.callout).foregroundColor(.white) + HStack(spacing: 6) { + if let userName = transaction.userName { + Text(userName).font(.caption).foregroundColor(Color(hex: "8888aa")) + } + if let date = transaction.date { + Text(formatDate(date)).font(.caption2).foregroundColor(Color(hex: "8888aa")) + } + } + } + + Spacer() + + Text("\(transaction.isDeposit ? "+" : "-")\(formatAmount(transaction.amount))") + .font(.callout.bold()) + .foregroundColor(transaction.isDeposit ? Color(hex: "00d4aa") : Color(hex: "ff4757")) + } + .padding(12) + .background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.04))) + .padding(.horizontal) + .padding(.vertical, 2) + } + + func formatAmount(_ v: Double) -> String { + if v >= 1_000_000 { return String(format: "%.1f млн ₽", v / 1_000_000) } + return String(format: "%.0f ₽", v) + } + + func formatDate(_ s: String) -> String { + let parts = s.prefix(10).split(separator: "-") + guard parts.count == 3 else { return String(s.prefix(10)) } + return "\(parts[2]).\(parts[1]).\(parts[0])" + } +} + +// MARK: - SavingsTotalCard + struct SavingsTotalCard: View { let stats: SavingsStats @@ -98,6 +208,8 @@ struct SavingsTotalCard: View { } } +// MARK: - SavingsCategoryCard + struct SavingsCategoryCard: View { let category: SavingsCategory @State private var appeared = false