From e7af51af10dfb7c704a961506abe78b03a39be85 Mon Sep 17 00:00:00 2001 From: Cosmo Date: Wed, 25 Mar 2026 17:14:59 +0000 Subject: [PATCH] feat: full Pulse Mobile implementation - all modules - Phase 0: project.yml fixes (CODE_SIGN_ENTITLEMENTS confirmed) - Phase 1: Enhanced models (HabitModels, TaskModels, FinanceModels, SavingsModels, UserModels) - Phase 1: Enhanced APIService with all endpoints (habits/log/stats, tasks/uncomplete, finance/analytics, savings/*) - Phase 2: DashboardView rewrite - day progress bar, 4 stat cards, habit/task lists with Undo (3 sec) - Phase 3: TrackerView - HabitListView (streak badge, swipe delete, archive), TaskListView (priority, overdue), StatisticsView (heatmap 84 days, line chart, bar chart via Swift Charts) - Phase 4: FinanceView rewrite - month picker, summary card, top expenses progress bars, pie chart, line chart, transactions by day, analytics tab with bar chart + month comparison - Phase 5: SavingsView rewrite - overview with overdue block, categories tab with type icons, operations tab with category filter + add sheet - Phase 6: SettingsView - dark/light theme, profile edit, telegram chat id, notifications toggle + time, timezone picker, logout - Added: AddHabitView with weekly day selector + interval days - Added: AddTaskView with icon/color/due date picker - Haptic feedback on all toggle actions --- PulseHealth/Models/FinanceModels.swift | 60 ++ PulseHealth/Models/HabitModels.swift | 122 ++- PulseHealth/Models/SavingsModels.swift | 61 +- PulseHealth/Models/TaskModels.swift | 65 +- PulseHealth/Models/UserModels.swift | 41 + PulseHealth/Services/APIService.swift | 132 ++- .../Views/Dashboard/DashboardView.swift | 440 +++++++--- PulseHealth/Views/Finance/FinanceView.swift | 495 ++++++++++-- PulseHealth/Views/Habits/AddHabitView.swift | 125 +-- PulseHealth/Views/Habits/HabitRowView.swift | 10 +- PulseHealth/Views/Habits/HabitsView.swift | 26 +- PulseHealth/Views/MainTabView.swift | 28 +- PulseHealth/Views/Profile/ProfileView.swift | 117 +-- PulseHealth/Views/Savings/SavingsView.swift | 749 +++++++++++++----- PulseHealth/Views/Settings/SettingsView.swift | 293 +++++++ PulseHealth/Views/Tasks/AddTaskView.swift | 121 ++- PulseHealth/Views/Tracker/TrackerView.swift | 701 ++++++++++++++++ project.yml | 1 + 18 files changed, 2959 insertions(+), 628 deletions(-) create mode 100644 PulseHealth/Models/UserModels.swift create mode 100644 PulseHealth/Views/Settings/SettingsView.swift create mode 100644 PulseHealth/Views/Tracker/TrackerView.swift diff --git a/PulseHealth/Models/FinanceModels.swift b/PulseHealth/Models/FinanceModels.swift index 49c22fa..c0da868 100644 --- a/PulseHealth/Models/FinanceModels.swift +++ b/PulseHealth/Models/FinanceModels.swift @@ -1,5 +1,7 @@ import Foundation +// MARK: - FinanceTransaction + struct FinanceTransaction: Codable, Identifiable { let id: Int var amount: Double @@ -9,6 +11,17 @@ struct FinanceTransaction: Codable, Identifiable { var date: String? var createdAt: String? + var isIncome: Bool { type == "income" } + + var dateFormatted: String { + guard let d = date else { return "" } + let parts = String(d.prefix(10)).split(separator: "-") + guard parts.count == 3 else { return String(d.prefix(10)) } + return "\(parts[2]).\(parts[1]).\(parts[0])" + } + + var dateOnly: String { String(date?.prefix(10) ?? "") } + enum CodingKeys: String, CodingKey { case id, amount, description, type, date case categoryId = "category_id" @@ -16,6 +29,8 @@ struct FinanceTransaction: Codable, Identifiable { } } +// MARK: - FinanceCategory + struct FinanceCategory: Codable, Identifiable { let id: Int var name: String @@ -24,21 +39,66 @@ struct FinanceCategory: Codable, Identifiable { var type: String } +// MARK: - FinanceSummary + struct FinanceSummary: Codable { var totalIncome: Double? var totalExpense: Double? var balance: Double? var carriedOver: Double? var month: String? + var byCategory: [CategorySpend]? + var daily: [DailySpend]? enum CodingKeys: String, CodingKey { case balance, month case totalIncome = "total_income" case totalExpense = "total_expense" case carriedOver = "carried_over" + case byCategory = "by_category" + case daily } } +struct CategorySpend: Codable, Identifiable { + var id: Int { categoryId ?? 0 } + var categoryId: Int? + var categoryName: String? + var total: Double? + var icon: String? + var color: String? + + enum CodingKeys: String, CodingKey { + case total, icon, color + case categoryId = "category_id" + case categoryName = "category_name" + } +} + +struct DailySpend: Codable, Identifiable { + var id: String { date } + let date: String + let total: Double? + let income: Double? + let expense: Double? +} + +// MARK: - FinanceAnalytics + +struct FinanceAnalytics: Codable { + var currentMonth: FinanceSummary? + var previousMonth: FinanceSummary? + var byCategory: [CategorySpend]? + + enum CodingKeys: String, CodingKey { + case currentMonth = "current_month" + case previousMonth = "previous_month" + case byCategory = "by_category" + } +} + +// MARK: - CreateTransactionRequest + struct CreateTransactionRequest: Codable { var amount: Double var categoryId: Int? diff --git a/PulseHealth/Models/HabitModels.swift b/PulseHealth/Models/HabitModels.swift index c11cbcf..a1bc038 100644 --- a/PulseHealth/Models/HabitModels.swift +++ b/PulseHealth/Models/HabitModels.swift @@ -1,16 +1,22 @@ import Foundation -enum HabitFrequency: String, Codable { - case daily, weekly, monthly +// MARK: - Habit Frequency + +enum HabitFrequency: String, Codable, CaseIterable { + case daily, weekly, monthly, interval, custom var displayName: String { switch self { case .daily: return "Ежедневно" case .weekly: return "Еженедельно" case .monthly: return "Ежемесячно" + case .interval: return "Через N дней" + case .custom: return "Особое" } } } +// MARK: - Habit + struct Habit: Codable, Identifiable { let id: Int var name: String @@ -25,6 +31,13 @@ struct Habit: Codable, Identifiable { var longestStreak: Int? var completedToday: Bool? var totalCompleted: Int? + var isArchived: Bool? + var startDate: String? + var createdAt: String? + var updatedAt: String? + + var accentColorHex: String { color ?? "00d4aa" } + var displayIcon: String { icon ?? "🔥" } enum CodingKeys: String, CodingKey { case id, name, description, icon, color, frequency @@ -35,9 +48,105 @@ struct Habit: Codable, Identifiable { case longestStreak = "longest_streak" case completedToday = "completed_today" case totalCompleted = "total_completed" + case isArchived = "is_archived" + case startDate = "start_date" + case createdAt = "created_at" + case updatedAt = "updated_at" + } + + var frequencyLabel: String { + switch frequency { + case .daily: return "Ежедневно" + case .weekly: + guard let days = targetDays, !days.isEmpty else { return "Еженедельно" } + let names = ["Вс","Пн","Вт","Ср","Чт","Пт","Сб"] + return days.sorted().compactMap { names[safe: $0] }.joined(separator: ", ") + case .interval: + let n = targetCount ?? 1 + return "Каждые \(n) дн." + case .monthly: return "Ежемесячно" + case .custom: return "Особое" + } } } +// MARK: - HabitLog + +struct HabitLog: Codable, Identifiable { + let id: Int + let habitId: Int? + let completedAt: String? + let note: String? + + var dateOnly: String { String(completedAt?.prefix(10) ?? "") } + + enum CodingKeys: String, CodingKey { + case id + case habitId = "habit_id" + case completedAt = "completed_at" + case note + } +} + +// MARK: - HabitFreeze + +struct HabitFreeze: Codable, Identifiable { + let id: Int + let habitId: Int? + let startDate: String + let endDate: String + + enum CodingKeys: String, CodingKey { + case id + case habitId = "habit_id" + case startDate = "start_date" + case endDate = "end_date" + } +} + +// MARK: - HabitStats + +struct HabitStats: Codable { + let currentStreak: Int + let longestStreak: Int + let thisMonth: Int + let totalCompleted: Int? + let completionRate: Double? + + var completionPercent: Int { Int((completionRate ?? 0) * 100) } + + enum CodingKeys: String, CodingKey { + case currentStreak = "current_streak" + case longestStreak = "longest_streak" + case thisMonth = "this_month" + case totalCompleted = "total_completed" + case completionRate = "completion_rate" + } +} + +// MARK: - HabitsOverallStats + +struct HabitsOverallStats: Codable { + let todayCompleted: Int? + let activeHabits: Int? + + enum CodingKeys: String, CodingKey { + case todayCompleted = "today_completed" + case activeHabits = "active_habits" + } +} + +// MARK: - CompletionDataPoint (for charts) + +struct CompletionDataPoint: Identifiable { + let id = UUID() + let date: Date + let rate: Double + let label: String +} + +// MARK: - HabitLogRequest + struct HabitLogRequest: Codable { var completedAt: String? var note: String? @@ -46,3 +155,12 @@ struct HabitLogRequest: Codable { case note } } + +// MARK: - Safe Array Subscript + +extension Array { + subscript(safe index: Int) -> Element? { + guard index >= 0, index < count else { return nil } + return self[index] + } +} diff --git a/PulseHealth/Models/SavingsModels.swift b/PulseHealth/Models/SavingsModels.swift index b57cbb9..bc988c2 100644 --- a/PulseHealth/Models/SavingsModels.swift +++ b/PulseHealth/Models/SavingsModels.swift @@ -16,28 +16,40 @@ struct SavingsCategory: Codable, Identifiable { var depositStartDate: String? var depositEndDate: String? var recurringAmount: Double? - + var recurringDay: Int? + var icon: String { + if isCredit == true { return "creditcard.fill" } if isDeposit == true { return "percent" } if isAccount == true { return "building.columns.fill" } if isRecurring == true { return "arrow.clockwise" } return "banknote.fill" } - - var color: String { + + var colorHex: String { + if isCredit == true { return "ff4757" } if isDeposit == true { return "ffa502" } if isAccount == true { return "7c3aed" } if isRecurring == true { return "00d4aa" } return "8888aa" } - + var typeLabel: String { + if isCredit == true { return "Кредит \(Int(interestRate ?? 0))%" } if isDeposit == true { return "Вклад \(Int(interestRate ?? 0))%" } if isAccount == true { return "Счёт" } - if isRecurring == true { return "Накопления" } - return "Копилка" + if isRecurring == true { return "Регулярные" } + return "Накопление" } - + + var typeEmoji: String { + if isCredit == true { return "💳" } + if isDeposit == true { return "🏦" } + if isAccount == true { return "🏧" } + if isRecurring == true { return "🔄" } + return "💰" + } + enum CodingKeys: String, CodingKey { case id, name, description case isDeposit = "is_deposit" @@ -52,6 +64,7 @@ struct SavingsCategory: Codable, Identifiable { case depositStartDate = "deposit_start_date" case depositEndDate = "deposit_end_date" case recurringAmount = "recurring_amount" + case recurringDay = "recurring_day" } } @@ -66,9 +79,18 @@ struct SavingsTransaction: Codable, Identifiable { var createdAt: String? var categoryName: String? var userName: String? - + var isDeposit: Bool { type == "deposit" } - + + var dateFormatted: String { + guard let d = date else { return "" } + let parts = String(d.prefix(10)).split(separator: "-") + guard parts.count == 3 else { return String(d.prefix(10)) } + return "\(parts[2]).\(parts[1]).\(parts[0])" + } + + var dateOnly: String { String(date?.prefix(10) ?? "") } + enum CodingKeys: String, CodingKey { case id, amount, type, description, date case categoryId = "category_id" @@ -84,11 +106,30 @@ struct SavingsStats: Codable { var totalDeposits: Double? var totalWithdrawals: Double? var categoriesCount: Int? - + var monthlyPayments: Double? + var overdueAmount: Double? + var overdueCount: Int? + enum CodingKeys: String, CodingKey { case totalBalance = "total_balance" case totalDeposits = "total_deposits" case totalWithdrawals = "total_withdrawals" case categoriesCount = "categories_count" + case monthlyPayments = "monthly_payments" + case overdueAmount = "overdue_amount" + case overdueCount = "overdue_count" + } +} + +struct CreateSavingsTransactionRequest: Codable { + var categoryId: Int + var amount: Double + var type: String + var description: String? + var date: String? + + enum CodingKeys: String, CodingKey { + case amount, type, description, date + case categoryId = "category_id" } } diff --git a/PulseHealth/Models/TaskModels.swift b/PulseHealth/Models/TaskModels.swift index 8bb036c..58e0210 100644 --- a/PulseHealth/Models/TaskModels.swift +++ b/PulseHealth/Models/TaskModels.swift @@ -1,5 +1,7 @@ import Foundation +// MARK: - PulseTask + struct PulseTask: Codable, Identifiable { let id: Int var title: String @@ -11,6 +13,9 @@ struct PulseTask: Codable, Identifiable { var dueDate: String? var reminderTime: String? var createdAt: String? + var isRecurring: Bool? + var recurrenceType: String? + var recurrenceInterval: Int? var priorityColor: String { switch priority { @@ -31,22 +36,80 @@ struct PulseTask: Codable, Identifiable { } } + var isOverdue: Bool { + guard !completed, let due = dueDate else { return false } + let dateStr = String(due.prefix(10)) + let today = ISO8601DateFormatter().string(from: Date()).prefix(10) + return dateStr < today + } + + var dueDateFormatted: String? { + guard let due = dueDate else { return nil } + let dateStr = String(due.prefix(10)) + let todayStr = String(ISO8601DateFormatter().string(from: Date()).prefix(10)) + let tomorrowDate = Calendar.current.date(byAdding: .day, value: 1, to: Date())! + let tomorrowStr = String(ISO8601DateFormatter().string(from: tomorrowDate).prefix(10)) + if dateStr == todayStr { return "Сегодня" } + if dateStr == tomorrowStr { return "Завтра" } + let parts = dateStr.split(separator: "-") + guard parts.count == 3 else { return dateStr } + return "\(parts[2]).\(parts[1]).\(parts[0])" + } + enum CodingKeys: String, CodingKey { case id, title, description, completed, priority, icon, color case dueDate = "due_date" case reminderTime = "reminder_time" case createdAt = "created_at" + case isRecurring = "is_recurring" + case recurrenceType = "recurrence_type" + case recurrenceInterval = "recurrence_interval" } } +// MARK: - TaskStats + +struct TaskStats: Codable { + let totalTasks: Int? + let completedTasks: Int? + let todayTasks: Int? + let todayCompleted: Int? + + enum CodingKeys: String, CodingKey { + case totalTasks = "total_tasks" + case completedTasks = "completed_tasks" + case todayTasks = "today_tasks" + case todayCompleted = "today_completed" + } +} + +// MARK: - CreateTaskRequest + struct CreateTaskRequest: Codable { var title: String var description: String? var priority: Int? var dueDate: String? + var icon: String? + var color: String? enum CodingKeys: String, CodingKey { - case title, description, priority + case title, description, priority, icon, color + case dueDate = "due_date" + } +} + +// MARK: - UpdateTaskRequest + +struct UpdateTaskRequest: Codable { + var title: String? + var description: String? + var priority: Int? + var dueDate: String? + var completed: Bool? + + enum CodingKeys: String, CodingKey { + case title, description, priority, completed case dueDate = "due_date" } } diff --git a/PulseHealth/Models/UserModels.swift b/PulseHealth/Models/UserModels.swift new file mode 100644 index 0000000..956da70 --- /dev/null +++ b/PulseHealth/Models/UserModels.swift @@ -0,0 +1,41 @@ +import Foundation + +// MARK: - UserProfile + +struct UserProfile: Codable { + var telegramChatId: String? + var morningNotification: Bool? + var eveningNotification: Bool? + var morningTime: String? + var eveningTime: String? + var timezone: String? + + enum CodingKeys: String, CodingKey { + case telegramChatId = "telegram_chat_id" + case morningNotification = "morning_notification" + case eveningNotification = "evening_notification" + case morningTime = "morning_time" + case eveningTime = "evening_time" + case timezone + } +} + +// MARK: - UpdateProfileRequest + +struct UpdateProfileRequest: Codable { + var telegramChatId: String? + var morningNotification: Bool? + var eveningNotification: Bool? + var morningTime: String? + var eveningTime: String? + var timezone: String? + + enum CodingKeys: String, CodingKey { + case telegramChatId = "telegram_chat_id" + case morningNotification = "morning_notification" + case eveningNotification = "evening_notification" + case morningTime = "morning_time" + case eveningTime = "evening_time" + case timezone + } +} diff --git a/PulseHealth/Services/APIService.swift b/PulseHealth/Services/APIService.swift index 36ceee1..3cf0e5f 100644 --- a/PulseHealth/Services/APIService.swift +++ b/PulseHealth/Services/APIService.swift @@ -1,5 +1,7 @@ import Foundation +// MARK: - APIError + enum APIError: Error, LocalizedError { case unauthorized case networkError(String) @@ -8,7 +10,7 @@ enum APIError: Error, LocalizedError { var errorDescription: String? { switch self { - case .unauthorized: return "Неверный email или пароль" + case .unauthorized: return "Сессия истекла. Войдите снова." case .networkError(let m): return "Ошибка сети: \(m)" case .decodingError(let m): return "Ошибка данных: \(m)" case .serverError(let c, let m): return "Ошибка \(c): \(m)" @@ -16,6 +18,8 @@ enum APIError: Error, LocalizedError { } } +// MARK: - APIService + class APIService { static let shared = APIService() let baseURL = "https://api.digital-home.site" @@ -39,8 +43,13 @@ class APIService { let msg = String(data: data, encoding: .utf8) ?? "Unknown" throw APIError.serverError(http.statusCode, msg) } - do { return try JSONDecoder().decode(T.self, from: data) } - catch { throw APIError.decodingError(error.localizedDescription) } + let decoder = JSONDecoder() + do { return try decoder.decode(T.self, from: data) } + catch { + // Debug: print first 200 chars of response + let snippet = String(data: data, encoding: .utf8)?.prefix(200) ?? "" + throw APIError.decodingError("\(error.localizedDescription) | Response: \(snippet)") + } } // MARK: - Auth @@ -59,6 +68,17 @@ class APIService { return try await fetch("/auth/me", token: token) } + // MARK: - Profile + + func getProfile(token: String) async throws -> UserProfile { + return try await fetch("/profile", token: token) + } + + func updateProfile(token: String, request: UpdateProfileRequest) async throws -> UserProfile { + let body = try JSONEncoder().encode(request) + return try await fetch("/profile", method: "PUT", token: token, body: body) + } + // MARK: - Tasks func getTasks(token: String) async throws -> [PulseTask] { @@ -75,33 +95,80 @@ class APIService { return try await fetch("/tasks", method: "POST", token: token, body: body) } + @discardableResult + func updateTask(token: String, id: Int, request: UpdateTaskRequest) async throws -> PulseTask { + let body = try JSONEncoder().encode(request) + return try await fetch("/tasks/\(id)", method: "PUT", token: token, body: body) + } + func completeTask(token: String, id: Int) async throws { let _: EmptyResponse = try await fetch("/tasks/\(id)/complete", method: "POST", token: token) } + func uncompleteTask(token: String, id: Int) async throws { + let _: EmptyResponse = try await fetch("/tasks/\(id)/uncomplete", method: "POST", token: token) + } + func deleteTask(token: String, id: Int) async throws { let _: EmptyResponse = try await fetch("/tasks/\(id)", method: "DELETE", token: token) } // MARK: - Habits - func getHabits(token: String) async throws -> [Habit] { - return try await fetch("/habits", token: token) + func getHabits(token: String, includeArchived: Bool = false) async throws -> [Habit] { + let query = includeArchived ? "?archived=true" : "" + return try await fetch("/habits\(query)", token: token) } - func logHabit(token: String, id: Int) async throws { - let body = try JSONEncoder().encode(HabitLogRequest()) + @discardableResult + func createHabit(token: String, body: Data) async throws -> Habit { + return try await fetch("/habits", method: "POST", token: token, body: body) + } + + @discardableResult + func updateHabit(token: String, id: Int, body: Data) async throws -> Habit { + return try await fetch("/habits/\(id)", method: "PUT", token: token, body: body) + } + + func deleteHabit(token: String, id: Int) async throws { + let _: EmptyResponse = try await fetch("/habits/\(id)", method: "DELETE", token: token) + } + + func logHabit(token: String, id: Int, date: String? = nil) async throws { + var params: [String: Any] = [:] + if let d = date { params["completed_at"] = d } + let body = try JSONSerialization.data(withJSONObject: params) let _: EmptyResponse = try await fetch("/habits/\(id)/log", method: "POST", token: token, body: body) } + func unlogHabit(token: String, habitId: Int, logId: Int) async throws { + let _: EmptyResponse = try await fetch("/habits/\(habitId)/logs/\(logId)", method: "DELETE", token: token) + } + + func getHabitLogs(token: String, habitId: Int, days: Int = 90) async throws -> [HabitLog] { + return try await fetch("/habits/\(habitId)/logs?days=\(days)", token: token) + } + + func getHabitStats(token: String, habitId: Int) async throws -> HabitStats { + return try await fetch("/habits/\(habitId)/stats", token: token) + } + + func getHabitsStats(token: String) async throws -> HabitsOverallStats { + return try await fetch("/habits/stats", token: token) + } + // MARK: - Finance - func getFinanceSummary(token: String) async throws -> FinanceSummary { - return try await fetch("/finance/summary", token: token) + func getFinanceSummary(token: String, month: Int? = nil, year: Int? = nil) async throws -> FinanceSummary { + var query = "" + if let m = month, let y = year { query = "?month=\(m)&year=\(y)" } + return try await fetch("/finance/summary\(query)", token: token) } - func getTransactions(token: String) async throws -> [FinanceTransaction] { - return try await fetch("/finance/transactions", token: token) + func getTransactions(token: String, month: Int? = nil, year: Int? = nil) async throws -> [FinanceTransaction] { + var query = "" + if let m = month, let y = year { query = "?month=\(m)&year=\(y)" } + return try await fetch("/finance/transactions\(query)", token: token) } @discardableResult @@ -110,22 +177,57 @@ class APIService { return try await fetch("/finance/transactions", method: "POST", token: token, body: body) } + func deleteTransaction(token: String, id: Int) async throws { + let _: EmptyResponse = try await fetch("/finance/transactions/\(id)", method: "DELETE", token: token) + } + + func getFinanceCategories(token: String) async throws -> [FinanceCategory] { + return try await fetch("/finance/categories", token: token) + } + + func getFinanceAnalytics(token: String, month: Int? = nil, year: Int? = nil) async throws -> FinanceAnalytics { + var query = "" + if let m = month, let y = year { query = "?month=\(m)&year=\(y)" } + return try await fetch("/finance/analytics\(query)", token: token) + } + // MARK: - Savings func getSavingsCategories(token: String) async throws -> [SavingsCategory] { return try await fetch("/savings/categories", token: token) } + @discardableResult + func createSavingsCategory(token: String, body: Data) async throws -> SavingsCategory { + return try await fetch("/savings/categories", method: "POST", token: token, body: body) + } + + func updateSavingsCategory(token: String, id: Int, body: Data) async throws { + let _: SavingsCategory = try await fetch("/savings/categories/\(id)", method: "PUT", token: token, body: body) + } + + func deleteSavingsCategory(token: String, id: Int) async throws { + let _: EmptyResponse = try await fetch("/savings/categories/\(id)", method: "DELETE", token: token) + } + func getSavingsStats(token: String) async throws -> SavingsStats { 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 getSavingsTransactions(token: String, categoryId: Int? = nil, limit: Int = 50) async throws -> [SavingsTransaction] { + var query = "?limit=\(limit)" + if let c = categoryId { query += "&category_id=\(c)" } + return try await fetch("/savings/transactions\(query)", token: token) } - func getFinanceCategories(token: String) async throws -> [FinanceCategory] { - return try await fetch("/finance/categories", token: token) + @discardableResult + func createSavingsTransaction(token: String, request: CreateSavingsTransactionRequest) async throws -> SavingsTransaction { + let body = try JSONEncoder().encode(request) + return try await fetch("/savings/transactions", method: "POST", token: token, body: body) + } + + func deleteSavingsTransaction(token: String, id: Int) async throws { + let _: EmptyResponse = try await fetch("/savings/transactions/\(id)", method: "DELETE", token: token) } } diff --git a/PulseHealth/Views/Dashboard/DashboardView.swift b/PulseHealth/Views/Dashboard/DashboardView.swift index b814d06..f3f0c9b 100644 --- a/PulseHealth/Views/Dashboard/DashboardView.swift +++ b/PulseHealth/Views/Dashboard/DashboardView.swift @@ -2,10 +2,22 @@ import SwiftUI struct DashboardView: View { @EnvironmentObject var authManager: AuthManager - @State private var tasks: [PulseTask] = [] - @State private var habits: [Habit] = [] - @State private var readiness: ReadinessResponse? + @State private var todayTasks: [PulseTask] = [] + @State private var todayHabits: [Habit] = [] + @State private var habitsStats: HabitsOverallStats? @State private var isLoading = true + @State private var showAddSheet = false + @State private var addMode: AddMode = .task + @State private var errorMessage: String? + @State private var showError = false + + // Undo state + @State private var recentlyLoggedHabitId: Int? + @State private var recentlyLoggedHabitLogDate: String? + @State private var recentlyCompletedTaskId: Int? + @State private var undoTimer: Timer? + + enum AddMode { case task, habit } var greeting: String { let h = Calendar.current.component(.hour, from: Date()) @@ -17,162 +29,386 @@ struct DashboardView: View { } } - var pendingTasks: [PulseTask] { tasks.filter { !$0.completed } } - var completedHabitsToday: Int { habits.filter { $0.completedToday == true }.count } + var completedHabitsToday: Int { todayHabits.filter { $0.completedToday == true }.count } + var totalHabitsToday: Int { todayHabits.count } + var dayProgress: Double { + guard totalHabitsToday > 0 else { return 0 } + return Double(completedHabitsToday) / Double(totalHabitsToday) + } + + var activeTodayTasks: [PulseTask] { todayTasks.filter { !$0.completed } } + var completedTodayTasksCount: Int { todayTasks.filter { $0.completed }.count } var body: some View { - ZStack { + ZStack(alignment: .bottomTrailing) { Color(hex: "0a0a1a").ignoresSafeArea() ScrollView { VStack(spacing: 20) { - // Header + // MARK: Header HStack { VStack(alignment: .leading, spacing: 4) { - Text(greeting + ", " + authManager.userName + "!") + Text("\(greeting), \(authManager.userName)!") .font(.title2.bold()).foregroundColor(.white) Text(Date(), style: .date) .font(.subheadline).foregroundColor(Color(hex: "8888aa")) } Spacer() - - // Logout - Button { - UIImpactFeedbackGenerator(style: .light).impactOccurred() - authManager.logout() - } label: { - ZStack { - Circle() - .fill(Color(hex: "1a1a3e")) - .frame(width: 42, height: 42) - Image(systemName: "rectangle.portrait.and.arrow.right") - .font(.system(size: 14, weight: .medium)) - .foregroundColor(Color(hex: "8888aa")) - } - } } .padding(.horizontal) .padding(.top) if isLoading { - ProgressView().tint(Color(hex: "00d4aa")).padding(.top, 40) + ProgressView().tint(Color(hex: "0D9488")).padding(.top, 40) } else { - // Readiness Score mini card - if let r = readiness { - ReadinessMiniCard(readiness: r) - } - - // Stats row - 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 r = readiness { - StatCard(icon: "heart.fill", value: "\(r.score)", label: "Готовность", color: r.score >= 80 ? "00d4aa" : r.score >= 60 ? "ffa502" : "ff4757") + // MARK: Day Progress + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Прогресс дня") + .font(.subheadline).foregroundColor(Color(hex: "8888aa")) + Spacer() + Text("\(completedHabitsToday)/\(totalHabitsToday) привычек") + .font(.caption).foregroundColor(Color(hex: "0D9488")) } + GeometryReader { geo in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 4) + .fill(Color.white.opacity(0.1)) + RoundedRectangle(cornerRadius: 4) + .fill(LinearGradient(colors: [Color(hex: "0D9488"), Color(hex: "14b8a6")], startPoint: .leading, endPoint: .trailing)) + .frame(width: geo.size.width * dayProgress) + .animation(.easeInOut(duration: 0.5), value: dayProgress) + } + } + .frame(height: 8) } .padding(.horizontal) - // Today's tasks - if !pendingTasks.isEmpty { - VStack(alignment: .leading, spacing: 12) { - Text("Задачи на сегодня").font(.headline).foregroundColor(.white).padding(.horizontal) - ForEach(pendingTasks.prefix(3)) { task in - TaskRowView(task: task) { - await completeTask(task) - } + // MARK: Stat Cards + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) { + DashStatCard(icon: "checkmark.circle.fill", value: "\(completedHabitsToday)", label: "Выполнено сегодня", color: "0D9488") + DashStatCard(icon: "flame.fill", value: "\(habitsStats?.activeHabits ?? totalHabitsToday)", label: "Активных привычек", color: "ffa502") + DashStatCard(icon: "calendar", value: "\(todayTasks.count)", label: "Задач на сегодня", color: "6366f1") + DashStatCard(icon: "checkmark.seal.fill", value: "\(completedTodayTasksCount)", label: "Задач выполнено", color: "10b981") + } + .padding(.horizontal) + + // MARK: Today's Habits + if !todayHabits.isEmpty { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("Привычки сегодня") + .font(.headline).foregroundColor(.white) + Spacer() + Text("\(completedHabitsToday)/\(totalHabitsToday)") + .font(.caption).foregroundColor(Color(hex: "8888aa")) + } + .padding(.horizontal) + + ForEach(todayHabits) { habit in + DashHabitRow( + habit: habit, + isUndoVisible: recentlyLoggedHabitId == habit.id, + onToggle: { await toggleHabit(habit) }, + onUndo: { await undoHabitLog(habit) } + ) } } + } else { + EmptyState(icon: "flame.fill", text: "Нет привычек на сегодня") } - // Habits progress - if !habits.isEmpty { - VStack(alignment: .leading, spacing: 12) { - Text("Привычки сегодня").font(.headline).foregroundColor(.white).padding(.horizontal) - ForEach(habits.prefix(4)) { habit in - HabitRowView(habit: habit) { - await logHabit(habit) - } + // MARK: Today's Tasks + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("Задачи на сегодня") + .font(.headline).foregroundColor(.white) + Spacer() + Button(action: { addMode = .task; showAddSheet = true }) { + Image(systemName: "plus.circle.fill") + .foregroundColor(Color(hex: "0D9488")) + } + } + .padding(.horizontal) + + if todayTasks.isEmpty { + EmptyState(icon: "checkmark.circle", text: "Нет задач на сегодня") + } else { + ForEach(todayTasks) { task in + DashTaskRow( + task: task, + isUndoVisible: recentlyCompletedTaskId == task.id, + onToggle: { await toggleTask(task) }, + onUndo: { await undoTask(task) } + ) } } } } - Spacer(minLength: 20) + Spacer(minLength: 80) } } .refreshable { await loadData(refresh: true) } + + // MARK: FAB + Button(action: { addMode = .task; showAddSheet = true }) { + ZStack { + Circle() + .fill(LinearGradient(colors: [Color(hex: "0D9488"), Color(hex: "14b8a6")], startPoint: .topLeading, endPoint: .bottomTrailing)) + .frame(width: 56, height: 56) + .shadow(color: Color(hex: "0D9488").opacity(0.4), radius: 8, y: 4) + Image(systemName: "plus").font(.title2.bold()).foregroundColor(.white) + } + } + .padding(.bottom, 90) + .padding(.trailing, 20) } .task { await loadData() } + .sheet(isPresented: $showAddSheet) { + if addMode == .task { + AddTaskView(isPresented: $showAddSheet) { await loadData(refresh: true) } + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + .presentationBackground(Color(hex: "0a0a1a")) + } else { + AddHabitView(isPresented: $showAddSheet) { await loadData(refresh: true) } + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + .presentationBackground(Color(hex: "0a0a1a")) + } + } + .alert("Ошибка", isPresented: $showError) { + Button("OK", role: .cancel) {} + } message: { Text(errorMessage ?? "") } } + // MARK: - Data Loading + func loadData(refresh: Bool = false) async { if !refresh { isLoading = true } - 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) - tasks = (try? await t) ?? [] - habits = (try? await h) ?? [] - readiness = try? await r + async let tasks = APIService.shared.getTodayTasks(token: authManager.token) + async let habits = APIService.shared.getHabits(token: authManager.token) + async let stats = APIService.shared.getHabitsStats(token: authManager.token) + todayTasks = (try? await tasks) ?? [] + todayHabits = (try? await habits) ?? [] + habitsStats = try? await stats isLoading = false } - func completeTask(_ task: PulseTask) async { - try? await APIService.shared.completeTask(token: authManager.token, id: task.id) - await loadData() + // MARK: - Actions + + func toggleHabit(_ habit: Habit) async { + if habit.completedToday == true { + // Already done — undo will handle it + return + } + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + do { + let today = todayDateString() + try await APIService.shared.logHabit(token: authManager.token, id: habit.id, date: today) + recentlyLoggedHabitId = habit.id + recentlyLoggedHabitLogDate = today + await loadData(refresh: true) + scheduleUndoClear() + } catch { + errorMessage = error.localizedDescription; showError = true + } } - func logHabit(_ habit: Habit) async { - try? await APIService.shared.logHabit(token: authManager.token, id: habit.id) - await loadData() + func undoHabitLog(_ habit: Habit) async { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + // Get logs and find today's log to delete + do { + let logs = try await APIService.shared.getHabitLogs(token: authManager.token, habitId: habit.id, days: 1) + let today = todayDateString() + if let log = logs.first(where: { $0.dateOnly == today }) { + try await APIService.shared.unlogHabit(token: authManager.token, habitId: habit.id, logId: log.id) + } + } catch {} + recentlyLoggedHabitId = nil + await loadData(refresh: true) + } + + func toggleTask(_ task: PulseTask) async { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + do { + if task.completed { + try await APIService.shared.uncompleteTask(token: authManager.token, id: task.id) + } else { + try await APIService.shared.completeTask(token: authManager.token, id: task.id) + recentlyCompletedTaskId = task.id + scheduleUndoClear() + } + await loadData(refresh: true) + } catch { + errorMessage = error.localizedDescription; showError = true + } + } + + func undoTask(_ task: PulseTask) async { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + do { + try await APIService.shared.uncompleteTask(token: authManager.token, id: task.id) + } catch {} + recentlyCompletedTaskId = nil + await loadData(refresh: true) + } + + func scheduleUndoClear() { + undoTimer?.invalidate() + undoTimer = Timer.scheduledTimer(withTimeInterval: 3, repeats: false) { _ in + recentlyLoggedHabitId = nil + recentlyCompletedTaskId = nil + } + } + + func todayDateString() -> String { + let df = DateFormatter() + df.dateFormat = "yyyy-MM-dd" + return df.string(from: Date()) } } -// MARK: - StatCard +// MARK: - DashStatCard -struct StatCard: View { +struct DashStatCard: View { let icon: String let value: String let label: String let color: String var body: some View { - VStack(spacing: 6) { - Image(systemName: icon).foregroundColor(Color(hex: color)).font(.title3) - Text(value).font(.headline.bold()).foregroundColor(.white) - Text(label).font(.caption).foregroundColor(Color(hex: "8888aa")) + VStack(spacing: 8) { + Image(systemName: icon) + .foregroundColor(Color(hex: color)) + .font(.title2) + Text(value) + .font(.title3.bold()).foregroundColor(.white) + Text(label) + .font(.caption).foregroundColor(Color(hex: "8888aa")) + .multilineTextAlignment(.center) } .frame(maxWidth: .infinity) - .padding(12) - .background(RoundedRectangle(cornerRadius: 16).fill(Color.white.opacity(0.05))) - } -} - -// MARK: - ReadinessMiniCard - -struct ReadinessMiniCard: View { - let readiness: ReadinessResponse - - var statusColor: Color { - readiness.score >= 80 ? Color(hex: "00d4aa") : - readiness.score >= 60 ? Color(hex: "ffa502") : - Color(hex: "ff4757") - } - - var body: some View { - HStack(spacing: 16) { - ZStack { - Circle().stroke(Color.white.opacity(0.1), lineWidth: 6).frame(width: 60, height: 60) - Circle().trim(from: 0, to: CGFloat(readiness.score) / 100) - .stroke(statusColor, style: StrokeStyle(lineWidth: 6, lineCap: .round)) - .frame(width: 60, height: 60).rotationEffect(.degrees(-90)) - Text("\(readiness.score)").font(.headline.bold()).foregroundColor(statusColor) - } - VStack(alignment: .leading, spacing: 4) { - Text("Готовность").font(.subheadline).foregroundColor(Color(hex: "8888aa")) - Text(readiness.recommendation).font(.callout).foregroundColor(.white).lineLimit(2) - } - Spacer() - } .padding(16) .background(RoundedRectangle(cornerRadius: 16).fill(Color.white.opacity(0.05))) - .padding(.horizontal) + } +} + +// MARK: - DashHabitRow + +struct DashHabitRow: View { + let habit: Habit + let isUndoVisible: Bool + let onToggle: () async -> Void + let onUndo: () async -> Void + + var accentColor: Color { Color(hex: habit.accentColorHex.replacingOccurrences(of: "#", with: "")) } + var isDone: Bool { habit.completedToday == true } + + var body: some View { + HStack(spacing: 14) { + ZStack { + Circle().fill(accentColor.opacity(isDone ? 0.3 : 0.1)).frame(width: 44, height: 44) + Text(habit.displayIcon).font(.title3) + } + VStack(alignment: .leading, spacing: 3) { + Text(habit.name) + .font(.callout.weight(.medium)).foregroundColor(.white) + HStack(spacing: 6) { + Text(habit.frequencyLabel).font(.caption).foregroundColor(Color(hex: "8888aa")) + if let streak = habit.currentStreak, streak > 0 { + Text("🔥 \(streak)").font(.caption).foregroundColor(Color(hex: "ffa502")) + } + } + } + Spacer() + if isUndoVisible { + Button(action: { Task { await onUndo() } }) { + Text("Отмена").font(.caption.bold()) + .foregroundColor(Color(hex: "ffa502")) + .padding(.horizontal, 10).padding(.vertical, 6) + .background(RoundedRectangle(cornerRadius: 8).fill(Color(hex: "ffa502").opacity(0.15))) + } + } + Button(action: { guard !isDone else { return }; Task { await onToggle() } }) { + Image(systemName: isDone ? "checkmark.circle.fill" : "circle") + .font(.title2) + .foregroundColor(isDone ? accentColor : Color(hex: "8888aa")) + } + } + .padding(14) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(isDone ? accentColor.opacity(0.08) : Color.white.opacity(0.04)) + .overlay(RoundedRectangle(cornerRadius: 16).stroke(isDone ? accentColor.opacity(0.3) : Color.clear, lineWidth: 1)) + ) + .padding(.horizontal) + .padding(.vertical, 2) + } +} + +// MARK: - DashTaskRow + +struct DashTaskRow: View { + let task: PulseTask + let isUndoVisible: Bool + let onToggle: () async -> Void + let onUndo: () async -> Void + + var body: some View { + HStack(spacing: 12) { + Button(action: { Task { await onToggle() } }) { + Image(systemName: task.completed ? "checkmark.circle.fill" : "circle") + .font(.title3) + .foregroundColor(task.completed ? Color(hex: "0D9488") : Color(hex: "8888aa")) + } + VStack(alignment: .leading, spacing: 3) { + Text(task.title) + .foregroundColor(task.completed ? Color(hex: "8888aa") : .white) + .strikethrough(task.completed) + .font(.callout) + HStack(spacing: 6) { + if let due = task.dueDateFormatted { + Text(due) + .font(.caption2) + .foregroundColor(task.isOverdue ? Color(hex: "ff4757") : Color(hex: "ffa502")) + } + if let p = task.priority, p > 1 { + Circle().fill(Color(hex: task.priorityColor)).frame(width: 6, height: 6) + } + if task.isRecurring == true { + Image(systemName: "arrow.clockwise") + .font(.caption2).foregroundColor(Color(hex: "8888aa")) + } + } + } + Spacer() + if isUndoVisible { + Button(action: { Task { await onUndo() } }) { + Text("Отмена").font(.caption.bold()) + .foregroundColor(Color(hex: "ffa502")) + .padding(.horizontal, 10).padding(.vertical, 6) + .background(RoundedRectangle(cornerRadius: 8).fill(Color(hex: "ffa502").opacity(0.15))) + } + } + } + .padding(12) + .background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.05))) + .padding(.horizontal) + .padding(.vertical, 2) + } +} + +// MARK: - EmptyState (reusable) + +struct EmptyState: View { + let icon: String + let text: String + var body: some View { + VStack(spacing: 8) { + Image(systemName: icon).font(.system(size: 32)).foregroundColor(Color(hex: "334155")) + Text(text).font(.subheadline).foregroundColor(Color(hex: "8888aa")) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 24) } } diff --git a/PulseHealth/Views/Finance/FinanceView.swift b/PulseHealth/Views/Finance/FinanceView.swift index 9ee19a1..1418e11 100644 --- a/PulseHealth/Views/Finance/FinanceView.swift +++ b/PulseHealth/Views/Finance/FinanceView.swift @@ -1,128 +1,483 @@ import SwiftUI +import Charts + +// MARK: - FinanceView struct FinanceView: View { @EnvironmentObject var authManager: AuthManager - @State private var summary: FinanceSummary? - @State private var transactions: [FinanceTransaction] = [] - @State private var categories: [FinanceCategory] = [] - @State private var isLoading = true - @State private var showAddTransaction = false + @State private var selectedTab = 0 + @State private var selectedMonth = Calendar.current.component(.month, from: Date()) + @State private var selectedYear = Calendar.current.component(.year, from: Date()) var body: some View { ZStack { Color(hex: "0a0a1a").ignoresSafeArea() VStack(spacing: 0) { + // Header with month picker HStack { Text("Финансы").font(.title.bold()).foregroundColor(.white) Spacer() - Button(action: { showAddTransaction = true }) { - Image(systemName: "plus.circle.fill").font(.title2).foregroundColor(Color(hex: "00d4aa")) + HStack(spacing: 8) { + Button(action: { prevMonth() }) { + Image(systemName: "chevron.left").foregroundColor(Color(hex: "8888aa")) + } + Text(monthLabel()) + .font(.subheadline).foregroundColor(.white) + .frame(minWidth: 80) + Button(action: { nextMonth() }) { + Image(systemName: "chevron.right").foregroundColor(Color(hex: "8888aa")) + } } - }.padding() + } + .padding(.horizontal) + .padding(.top, 16) + .padding(.bottom, 12) + Picker("", selection: $selectedTab) { + Text("Обзор").tag(0) + Text("Транзакции").tag(1) + Text("Аналитика").tag(2) + } + .pickerStyle(.segmented) + .padding(.horizontal) + .padding(.bottom, 12) + + switch selectedTab { + case 0: FinanceOverviewTab(month: selectedMonth, year: selectedYear) + case 1: FinanceTransactionsTab(month: selectedMonth, year: selectedYear) + default: FinanceAnalyticsTab(month: selectedMonth, year: selectedYear) + } + } + } + } + + func monthLabel() -> String { + let df = DateFormatter() + df.dateFormat = "LLLL yyyy" + df.locale = Locale(identifier: "ru_RU") + var comps = DateComponents(); comps.month = selectedMonth; comps.year = selectedYear + if let d = Calendar.current.date(from: comps) { return df.string(from: d) } + return "\(selectedMonth)/\(selectedYear)" + } + + func prevMonth() { + if selectedMonth == 1 { selectedMonth = 12; selectedYear -= 1 } + else { selectedMonth -= 1 } + } + + func nextMonth() { + if selectedMonth == 12 { selectedMonth = 1; selectedYear += 1 } + else { selectedMonth += 1 } + } +} + +// MARK: - FinanceOverviewTab + +struct FinanceOverviewTab: View { + @EnvironmentObject var authManager: AuthManager + let month: Int + let year: Int + @State private var summary: FinanceSummary? + @State private var categories: [FinanceCategory] = [] + @State private var isLoading = true + + var expenseByCategory: [CategorySpend] { + (summary?.byCategory ?? []).filter { ($0.total ?? 0) > 0 }.sorted { ($0.total ?? 0) > ($1.total ?? 0) } + } + + var dailyPoints: [DailySpend] { summary?.daily ?? [] } + + var body: some View { + ScrollView { + VStack(spacing: 16) { if isLoading { - ProgressView().tint(Color(hex: "00d4aa")).padding(.top, 40) - Spacer() + ProgressView().tint(Color(hex: "0D9488")).padding(.top, 40) } else { - ScrollView { - VStack(spacing: 16) { - // Summary card - if let s = summary { - FinanceSummaryCard(summary: s) - } + // Summary Card + if let s = summary { FinanceSummaryCard2(summary: s) } - // Recent transactions - if !transactions.isEmpty { - VStack(alignment: .leading, spacing: 8) { - Text("Последние операции") - .font(.headline).foregroundColor(.white).padding(.horizontal) - ForEach(transactions.prefix(20)) { tx in - TransactionRowView(transaction: tx, categories: categories) + // Top Expenses + if !expenseByCategory.isEmpty { + VStack(alignment: .leading, spacing: 10) { + Text("Топ расходов").font(.subheadline.bold()).foregroundColor(.white) + ForEach(expenseByCategory.prefix(5)) { cat in + let total = summary?.totalExpense ?? 1 + let pct = (cat.total ?? 0) / max(total, 1) + VStack(spacing: 4) { + HStack { + Text(cat.icon ?? "💸").font(.subheadline) + Text(cat.categoryName ?? "—").font(.callout).foregroundColor(.white) + Spacer() + Text(formatAmt(cat.total ?? 0)).font(.callout.bold()).foregroundColor(Color(hex: "ff4757")) + Text("\(Int(pct * 100))%").font(.caption2).foregroundColor(Color(hex: "8888aa")) + } + GeometryReader { geo in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 3).fill(Color.white.opacity(0.07)) + RoundedRectangle(cornerRadius: 3) + .fill(Color(hex: "ff4757").opacity(0.7)) + .frame(width: geo.size.width * CGFloat(pct)) + } + } + .frame(height: 5) + } + } + } + .padding(16) + .background(RoundedRectangle(cornerRadius: 16).fill(Color.white.opacity(0.04))) + .padding(.horizontal) + } + + // Pie Chart + if expenseByCategory.count > 1 { + VStack(alignment: .leading, spacing: 8) { + Text("Расходы по категориям").font(.subheadline.bold()).foregroundColor(.white) + Chart(expenseByCategory) { cat in + SectorMark( + angle: .value("Сумма", cat.total ?? 0), + innerRadius: .ratio(0.55), + angularInset: 2 + ) + .foregroundStyle(by: .value("Кат.", cat.categoryName ?? "—")) + .cornerRadius(4) + } + .frame(height: 200) + .chartForegroundStyleScale(range: Gradient(colors: [ + Color(hex: "0D9488"), Color(hex: "6366f1"), Color(hex: "f59e0b"), + Color(hex: "ec4899"), Color(hex: "14b8a6"), Color(hex: "8b5cf6") + ])) + } + .padding(16) + .background(RoundedRectangle(cornerRadius: 16).fill(Color.white.opacity(0.04))) + .padding(.horizontal) + } + + // Daily Line Chart + if !dailyPoints.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("Ежедневные траты").font(.subheadline.bold()).foregroundColor(.white) + let df: DateFormatter = { + let d = DateFormatter(); d.dateFormat = "yyyy-MM-dd"; return d + }() + Chart(dailyPoints.compactMap { p -> (Date, Double)? in + guard let d = df.date(from: p.date) else { return nil } + return (d, p.expense ?? p.total ?? 0) + }, id: \.0) { item in + AreaMark(x: .value("День", item.0), y: .value("Сумма", item.1)) + .foregroundStyle(LinearGradient(colors: [Color(hex: "ff4757").opacity(0.4), Color.clear], startPoint: .top, endPoint: .bottom)) + LineMark(x: .value("День", item.0), y: .value("Сумма", item.1)) + .foregroundStyle(Color(hex: "ff4757")) + .lineStyle(StrokeStyle(lineWidth: 2)) + } + .chartXAxis { + AxisMarks(values: .stride(by: .day, count: 5)) { _ in + AxisValueLabel(format: .dateTime.day()).foregroundStyle(Color(hex: "8888aa")) + AxisGridLine().foregroundStyle(Color.white.opacity(0.05)) + } + } + .chartYAxis { + AxisMarks { v in + AxisGridLine().foregroundStyle(Color.white.opacity(0.05)) + AxisValueLabel().foregroundStyle(Color(hex: "8888aa")) + } + } + .frame(height: 140) + } + .padding(16) + .background(RoundedRectangle(cornerRadius: 16).fill(Color.white.opacity(0.04))) + .padding(.horizontal) + } + } + Spacer(minLength: 80) + } + .padding(.top, 8) + } + .task { await load() } + .onChange(of: month) { _ in Task { await load() } } + .onChange(of: year) { _ in Task { await load() } } + .refreshable { await load(refresh: true) } + } + + func load(refresh: Bool = false) async { + if !refresh { isLoading = true } + async let s = APIService.shared.getFinanceSummary(token: authManager.token, month: month, year: year) + async let c = APIService.shared.getFinanceCategories(token: authManager.token) + summary = try? await s + categories = (try? await c) ?? [] + isLoading = false + } + + func formatAmt(_ v: Double) -> String { + v >= 1000 ? String(format: "%.0f ₽", v) : String(format: "%.0f ₽", v) + } +} + +// MARK: - FinanceSummaryCard2 + +struct FinanceSummaryCard2: View { + let summary: FinanceSummary + var body: some View { + VStack(spacing: 16) { + VStack(spacing: 4) { + Text("Баланс месяца").font(.subheadline).foregroundColor(Color(hex: "8888aa")) + Text(formatAmt(summary.balance ?? 0)) + .font(.system(size: 34, weight: .bold)) + .foregroundColor((summary.balance ?? 0) >= 0 ? Color(hex: "0D9488") : Color(hex: "ff4757")) + } + HStack { + VStack(spacing: 4) { + Text("Доходы").font(.caption).foregroundColor(Color(hex: "8888aa")) + Text("+\(formatAmt(summary.totalIncome ?? 0))") + .font(.callout.bold()).foregroundColor(Color(hex: "0D9488")) + } + Spacer() + VStack(spacing: 4) { + Text("Расходы").font(.caption).foregroundColor(Color(hex: "8888aa")) + Text("-\(formatAmt(summary.totalExpense ?? 0))") + .font(.callout.bold()).foregroundColor(Color(hex: "ff4757")) + } + Spacer() + VStack(spacing: 4) { + Text("Перенос").font(.caption).foregroundColor(Color(hex: "8888aa")) + Text("\(formatAmt(summary.carriedOver ?? 0))") + .font(.callout.bold()).foregroundColor(.white) + } + } + } + .padding(20) + .background( + LinearGradient(colors: [Color(hex: "1a1a3e"), Color(hex: "12122a")], startPoint: .topLeading, endPoint: .bottomTrailing) + ) + .cornerRadius(20) + .overlay(RoundedRectangle(cornerRadius: 20).stroke(Color(hex: "0D9488").opacity(0.3), lineWidth: 1)) + .padding(.horizontal) + } + + func formatAmt(_ v: Double) -> String { String(format: "%.0f ₽", v) } +} + +// MARK: - FinanceTransactionsTab + +struct FinanceTransactionsTab: View { + @EnvironmentObject var authManager: AuthManager + let month: Int + let year: Int + @State private var transactions: [FinanceTransaction] = [] + @State private var categories: [FinanceCategory] = [] + @State private var isLoading = true + @State private var showAdd = false + + var groupedByDay: [(key: String, value: [FinanceTransaction])] { + let grouped = Dictionary(grouping: transactions) { $0.dateOnly } + return grouped.sorted { $0.key > $1.key } + } + + var body: some View { + ZStack(alignment: .bottomTrailing) { + Group { + if isLoading { + ProgressView().tint(Color(hex: "0D9488")).padding(.top, 40) + Spacer() + } else if transactions.isEmpty { + VStack { EmptyState(icon: "creditcard", text: "Нет транзакций"); Spacer() } + } else { + List { + ForEach(groupedByDay, id: \.key) { section in + Section(header: + Text(formatSectionDate(section.key)) + .font(.caption).foregroundColor(Color(hex: "8888aa")) + ) { + ForEach(section.value) { tx in + FinanceTxRow(transaction: tx, categories: categories) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } + .onDelete { idx in + let toDelete = idx.map { section.value[$0] } + Task { + for tx in toDelete { try? await APIService.shared.deleteTransaction(token: authManager.token, id: tx.id) } + await load(refresh: true) } } } } } + .listStyle(.plain) + .scrollContentBackground(.hidden) } } + .refreshable { await load(refresh: true) } + + Button(action: { showAdd = true }) { + ZStack { + Circle() + .fill(LinearGradient(colors: [Color(hex: "0D9488"), Color(hex: "14b8a6")], startPoint: .topLeading, endPoint: .bottomTrailing)) + .frame(width: 56, height: 56) + .shadow(color: Color(hex: "0D9488").opacity(0.4), radius: 8, y: 4) + Image(systemName: "plus").font(.title2.bold()).foregroundColor(.white) + } + } + .padding(.bottom, 90) + .padding(.trailing, 20) } - .sheet(isPresented: $showAddTransaction) { - AddTransactionView(isPresented: $showAddTransaction, categories: categories) { await loadData() } + .task { await load() } + .onChange(of: month) { _ in Task { await load() } } + .onChange(of: year) { _ in Task { await load() } } + .sheet(isPresented: $showAdd) { + AddTransactionView(isPresented: $showAdd, categories: categories) { await load(refresh: true) } .presentationDetents([.medium, .large]) .presentationDragIndicator(.visible) .presentationBackground(Color(hex: "0a0a1a")) } - .task { await loadData() } - .refreshable { await loadData(refresh: true) } } - func loadData(refresh: Bool = false) async { + func load(refresh: Bool = false) async { if !refresh { isLoading = true } - async let s = APIService.shared.getFinanceSummary(token: authManager.token) - async let t = APIService.shared.getTransactions(token: authManager.token) + async let t = APIService.shared.getTransactions(token: authManager.token, month: month, year: year) async let c = APIService.shared.getFinanceCategories(token: authManager.token) - summary = try? await s transactions = (try? await t) ?? [] categories = (try? await c) ?? [] isLoading = false } -} -// MARK: - FinanceSummaryCard - -struct FinanceSummaryCard: View { - let summary: FinanceSummary - - var body: some View { - VStack(spacing: 16) { - HStack(spacing: 20) { - VStack(spacing: 4) { - Text("Доходы").font(.caption).foregroundColor(Color(hex: "8888aa")) - Text("+\(Int(summary.totalIncome ?? 0))₽").font(.headline).foregroundColor(Color(hex: "00d4aa")) - } - Spacer() - VStack(spacing: 4) { - Text("Баланс").font(.subheadline).foregroundColor(Color(hex: "8888aa")) - Text("\(Int(summary.balance ?? 0))₽").font(.title2.bold()).foregroundColor(.white) - } - Spacer() - VStack(spacing: 4) { - Text("Расходы").font(.caption).foregroundColor(Color(hex: "8888aa")) - Text("-\(Int(summary.totalExpense ?? 0))₽").font(.headline).foregroundColor(Color(hex: "ff4757")) - } - } - } - .padding(20) - .background(RoundedRectangle(cornerRadius: 20).fill(Color.white.opacity(0.05))) - .padding(.horizontal) + func formatSectionDate(_ s: String) -> String { + let parts = s.split(separator: "-") + guard parts.count == 3 else { return s } + return "\(parts[2]).\(parts[1]).\(parts[0])" } } -// MARK: - TransactionRowView +// MARK: - FinanceTxRow -struct TransactionRowView: View { +struct FinanceTxRow: View { let transaction: FinanceTransaction let categories: [FinanceCategory] - - var category: FinanceCategory? { categories.first { $0.id == transaction.categoryId } } + var cat: FinanceCategory? { categories.first { $0.id == transaction.categoryId } } var isIncome: Bool { transaction.type == "income" } - var body: some View { - HStack { - Text(category?.icon ?? (isIncome ? "💰" : "💸")).font(.title2) + HStack(spacing: 12) { + ZStack { + Circle() + .fill((isIncome ? Color(hex: "0D9488") : Color(hex: "ff4757")).opacity(0.12)) + .frame(width: 40, height: 40) + Text(cat?.icon ?? (isIncome ? "💰" : "💸")).font(.title3) + } VStack(alignment: .leading, spacing: 2) { - Text(transaction.description ?? category?.name ?? "Операция") + Text(transaction.description ?? cat?.name ?? "Операция") .font(.callout).foregroundColor(.white) - Text(transaction.date ?? "").font(.caption).foregroundColor(Color(hex: "8888aa")) + Text(transaction.dateFormatted).font(.caption2).foregroundColor(Color(hex: "8888aa")) } Spacer() - Text("\(isIncome ? "+" : "-")\(Int(transaction.amount))₽") + Text("\(isIncome ? "+" : "-")\(formatAmt(transaction.amount))") .font(.callout.bold()) - .foregroundColor(isIncome ? Color(hex: "00d4aa") : Color(hex: "ff4757")) + .foregroundColor(isIncome ? Color(hex: "0D9488") : Color(hex: "ff4757")) } .padding(12) .background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.04))) .padding(.horizontal) .padding(.vertical, 2) } + func formatAmt(_ v: Double) -> String { String(format: "%.0f ₽", v) } +} + +// MARK: - FinanceAnalyticsTab + +struct FinanceAnalyticsTab: View { + @EnvironmentObject var authManager: AuthManager + let month: Int + let year: Int + @State private var analytics: FinanceAnalytics? + @State private var isLoading = true + + var body: some View { + ScrollView { + VStack(spacing: 16) { + if isLoading { + ProgressView().tint(Color(hex: "0D9488")).padding(.top, 40) + } else { + // Bar chart by category + if let cats = analytics?.byCategory, !cats.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("Расходы по категориям").font(.subheadline.bold()).foregroundColor(.white) + Chart(cats.sorted { ($0.total ?? 0) > ($1.total ?? 0) }.prefix(8).map { $0 }) { cat in + BarMark( + x: .value("Сумма", cat.total ?? 0), + y: .value("Кат.", cat.categoryName ?? "—") + ) + .foregroundStyle(Color(hex: "ff4757")) + .cornerRadius(4) + .annotation(position: .trailing) { + Text(formatAmt(cat.total ?? 0)).font(.caption2).foregroundColor(Color(hex: "8888aa")) + } + } + .chartXAxis { + AxisMarks { _ in AxisGridLine().foregroundStyle(Color.white.opacity(0.05)) } + } + .frame(height: 250) + } + .padding(16) + .background(RoundedRectangle(cornerRadius: 16).fill(Color.white.opacity(0.04))) + .padding(.horizontal) + } + + // Month comparison + if let cur = analytics?.currentMonth, let prev = analytics?.previousMonth { + MonthComparisonCard(current: cur, previous: prev) + } + + if analytics?.byCategory == nil && analytics?.currentMonth == nil { + EmptyState(icon: "chart.bar", text: "Нет данных для аналитики") + } + } + Spacer(minLength: 80) + } + .padding(.top, 8) + } + .task { await load() } + .onChange(of: month) { _ in Task { await load() } } + .onChange(of: year) { _ in Task { await load() } } + .refreshable { await load(refresh: true) } + } + + func load(refresh: Bool = false) async { + if !refresh { isLoading = true } + analytics = try? await APIService.shared.getFinanceAnalytics(token: authManager.token, month: month, year: year) + isLoading = false + } + + func formatAmt(_ v: Double) -> String { v >= 1000 ? String(format: "%.0f", v) : String(format: "%.0f", v) } +} + +// MARK: - MonthComparisonCard + +struct MonthComparisonCard: View { + let current: FinanceSummary + let previous: FinanceSummary + var diff: Double { (current.totalExpense ?? 0) - (previous.totalExpense ?? 0) } + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Сравнение с прошлым месяцем").font(.subheadline.bold()).foregroundColor(.white) + HStack { + VStack(spacing: 4) { + Text("Этот месяц").font(.caption).foregroundColor(Color(hex: "8888aa")) + Text(formatAmt(current.totalExpense ?? 0)).font(.headline.bold()).foregroundColor(Color(hex: "ff4757")) + } + Spacer() + VStack(spacing: 4) { + Text("Прошлый").font(.caption).foregroundColor(Color(hex: "8888aa")) + Text(formatAmt(previous.totalExpense ?? 0)).font(.headline.bold()).foregroundColor(Color(hex: "8888aa")) + } + Spacer() + VStack(spacing: 4) { + Text("Изменение").font(.caption).foregroundColor(Color(hex: "8888aa")) + Text("\(diff > 0 ? "+" : "")\(formatAmt(diff))").font(.headline.bold()) + .foregroundColor(diff > 0 ? Color(hex: "ff4757") : Color(hex: "0D9488")) + } + } + } + .padding(16) + .background(RoundedRectangle(cornerRadius: 16).fill(Color.white.opacity(0.04))) + .padding(.horizontal) + } + func formatAmt(_ v: Double) -> String { String(format: "%.0f ₽", v) } } diff --git a/PulseHealth/Views/Habits/AddHabitView.swift b/PulseHealth/Views/Habits/AddHabitView.swift index 5ed8a0a..0b82646 100644 --- a/PulseHealth/Views/Habits/AddHabitView.swift +++ b/PulseHealth/Views/Habits/AddHabitView.swift @@ -4,52 +4,50 @@ 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 selectedColor = "#0D9488" @State private var isLoading = false - + @State private var intervalDays = "2" + @State private var selectedWeekdays: Set = [1,2,3,4,5] // Mon-Fri + let frequencies: [(String, String, String)] = [ ("daily", "Каждый день", "calendar"), - ("weekly", "Каждую неделю", "calendar.badge.clock"), + ("weekly", "По дням недели", "calendar.badge.clock"), + ("interval", "Каждые N дней", "repeat"), ("monthly", "Каждый месяц", "calendar.badge.plus") ] - - let icons = ["🔥", "💪", "🏃", "📚", "💧", "🧘", "🎯", "⭐️", "🌟", "✅", "🏋️", "🚴", "🍎", "😴", "🧠", "🎨", "🎵", "💊", "🌿", "💰"] - - let colors = ["#00d4aa", "#7c3aed", "#ff4757", "#ffa502", "#6366f1", "#ec4899", "#14b8a6", "#f59e0b", "#10b981", "#3b82f6"] - + + let weekdayNames = ["Вс","Пн","Вт","Ср","Чт","Пт","Сб"] + + let icons = ["🔥", "💪", "🏃", "📚", "💧", "🧘", "🎯", "⭐️", "🌟", "✅", + "🏋️", "🚴", "🍎", "😴", "🧠", "🎨", "🎵", "💊", "🌿", "💰", + "✍️", "🧹", "🏊", "🚶", "🎮", "📝", "🌅", "🥗", "🧃", "🫁"] + + let colors = ["#0D9488", "#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 + .fill(Color.white.opacity(0.2)).frame(width: 40, height: 4).padding(.top, 12) HStack { - Button("Отмена") { isPresented = false } - .foregroundColor(Color(hex: "8888aa")) + 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) + if isLoading { ProgressView().tint(Color(hex: "0D9488")).scaleEffect(0.8) } + else { Text("Добавить").foregroundColor(name.isEmpty ? Color(hex: "8888aa") : Color(hex: "0D9488")).fontWeight(.semibold) } + }.disabled(name.isEmpty || isLoading) } .padding(.horizontal, 20).padding(.vertical, 16) - Divider().background(Color.white.opacity(0.1)) - ScrollView { VStack(spacing: 20) { // Preview @@ -64,13 +62,14 @@ struct AddHabitView: View { 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")) + 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")) @@ -78,7 +77,7 @@ struct AddHabitView: View { .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")) @@ -86,35 +85,62 @@ struct AddHabitView: View { 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")) + Image(systemName: f.2).foregroundColor(frequency == f.0 ? Color(hex: "0D9488") : 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")) - } + if frequency == f.0 { Image(systemName: "checkmark").foregroundColor(Color(hex: "0D9488")) } } .padding(14) - .background(RoundedRectangle(cornerRadius: 12).fill(frequency == f.0 ? Color(hex: "00d4aa").opacity(0.15) : Color.white.opacity(0.05))) + .background(RoundedRectangle(cornerRadius: 12).fill(frequency == f.0 ? Color(hex: "0D9488").opacity(0.15) : Color.white.opacity(0.05))) } } } + // Weekly day selector + if frequency == "weekly" { + HStack(spacing: 8) { + ForEach(0..<7) { i in + Button(action: { + if selectedWeekdays.contains(i) { if selectedWeekdays.count > 1 { selectedWeekdays.remove(i) } } + else { selectedWeekdays.insert(i) } + }) { + Text(weekdayNames[i]) + .font(.caption.bold()) + .foregroundColor(selectedWeekdays.contains(i) ? .black : .white) + .frame(width: 32, height: 32) + .background(Circle().fill(selectedWeekdays.contains(i) ? Color(hex: "0D9488") : Color.white.opacity(0.08))) + } + } + } + } + // Interval days + if frequency == "interval" { + HStack { + Text("Каждые").foregroundColor(Color(hex: "8888aa")).font(.callout) + TextField("2", text: $intervalDays).keyboardType(.numberPad) + .foregroundColor(.white) + .frame(width: 50) + .padding(10) + .background(RoundedRectangle(cornerRadius: 8).fill(Color.white.opacity(0.07))) + Text("дней").foregroundColor(Color(hex: "8888aa")).font(.callout) + } + } } - + // 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) { + LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 6), 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)) + Text(icon).font(.title3) + .frame(width: 40, height: 40) + .background(Circle().fill(selectedIcon == icon ? Color(hex: "0D9488").opacity(0.25) : Color.white.opacity(0.05))) + .overlay(Circle().stroke(selectedIcon == icon ? Color(hex: "0D9488") : Color.clear, lineWidth: 2)) } } } } - + // Color picker VStack(alignment: .leading, spacing: 8) { Label("Цвет", systemImage: "paintpalette").font(.caption).foregroundColor(Color(hex: "8888aa")) @@ -131,21 +157,16 @@ struct AddHabitView: View { } } } - } - .padding(20) + }.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] = [ + var body: [String: Any] = [ "name": name, "description": description, "frequency": frequency, @@ -153,8 +174,14 @@ struct AddHabitView: View { "color": selectedColor, "target_count": 1 ] - req.httpBody = try? JSONSerialization.data(withJSONObject: body) - _ = try? await URLSession.shared.data(for: req) + if frequency == "weekly" { + body["target_days"] = Array(selectedWeekdays).sorted() + } + if frequency == "interval" { + body["target_count"] = Int(intervalDays) ?? 2 + } + let reqBody = try? JSONSerialization.data(withJSONObject: body) + try? await APIService.shared.createHabit(token: authManager.token, body: reqBody ?? Data()) await onAdded() await MainActor.run { isPresented = false } } diff --git a/PulseHealth/Views/Habits/HabitRowView.swift b/PulseHealth/Views/Habits/HabitRowView.swift index a8a7bb3..fee78f1 100644 --- a/PulseHealth/Views/Habits/HabitRowView.swift +++ b/PulseHealth/Views/Habits/HabitRowView.swift @@ -4,29 +4,25 @@ struct HabitRowView: View { let habit: Habit let onLog: () async -> Void - var accentColor: Color { Color(hex: habit.color ?? "00d4aa") } + var accentColor: Color { Color(hex: habit.accentColorHex.replacingOccurrences(of: "#", with: "")) } var isDone: Bool { habit.completedToday == true } var body: some View { HStack(spacing: 14) { - // Icon ZStack { Circle().fill(accentColor.opacity(isDone ? 0.3 : 0.1)).frame(width: 44, height: 44) - Text(habit.icon ?? "🔥").font(.title3) + Text(habit.displayIcon).font(.title3) } - VStack(alignment: .leading, spacing: 4) { Text(habit.name).font(.callout.weight(.medium)).foregroundColor(.white) HStack(spacing: 8) { - Text(habit.frequency.displayName).font(.caption).foregroundColor(Color(hex: "8888aa")) + Text(habit.frequencyLabel).font(.caption).foregroundColor(Color(hex: "8888aa")) if let streak = habit.currentStreak, streak > 0 { Text("🔥 \(streak)").font(.caption).foregroundColor(Color(hex: "ffa502")) } } } - Spacer() - Button(action: { guard !isDone else { return }; Task { await onLog() } }) { Image(systemName: isDone ? "checkmark.circle.fill" : "circle") .font(.title2) diff --git a/PulseHealth/Views/Habits/HabitsView.swift b/PulseHealth/Views/Habits/HabitsView.swift index 120e2fa..5a092f4 100644 --- a/PulseHealth/Views/Habits/HabitsView.swift +++ b/PulseHealth/Views/Habits/HabitsView.swift @@ -1,5 +1,7 @@ import SwiftUI +// HabitsView is now used by TrackerView (HabitListView). +// This file kept for backward compat but MainTabView uses TrackerView. struct HabitsView: View { @EnvironmentObject var authManager: AuthManager @State private var habits: [Habit] = [] @@ -19,43 +21,36 @@ struct HabitsView: View { } Spacer() Button(action: { showAddHabit = true }) { - Image(systemName: "plus.circle.fill").font(.title2).foregroundColor(Color(hex: "00d4aa")) + Image(systemName: "plus.circle.fill").font(.title2).foregroundColor(Color(hex: "0D9488")) } }.padding() - // Progress bar if !habits.isEmpty { GeometryReader { geo in ZStack(alignment: .leading) { RoundedRectangle(cornerRadius: 4).fill(Color.white.opacity(0.1)) - RoundedRectangle(cornerRadius: 4).fill(Color(hex: "00d4aa")) + RoundedRectangle(cornerRadius: 4).fill(Color(hex: "0D9488")) .frame(width: geo.size.width * CGFloat(completedCount) / CGFloat(max(habits.count, 1))) } } - .frame(height: 6) - .padding(.horizontal) - .padding(.bottom, 16) + .frame(height: 6).padding(.horizontal).padding(.bottom, 16) } if isLoading { - ProgressView().tint(Color(hex: "00d4aa")).padding(.top, 40) - Spacer() + ProgressView().tint(Color(hex: "0D9488")).padding(.top, 40); Spacer() } else if habits.isEmpty { VStack(spacing: 12) { Text("🔥").font(.system(size: 50)) Text("Нет привычек").foregroundColor(Color(hex: "8888aa")) - }.padding(.top, 60) - Spacer() + }.padding(.top, 60); Spacer() } else { List { ForEach(habits) { habit in HabitRowView(habit: habit) { await logHabit(habit) } - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) + .listRowBackground(Color.clear).listRowSeparator(.hidden) } } - .listStyle(.plain) - .scrollContentBackground(.hidden) + .listStyle(.plain).scrollContentBackground(.hidden) } } } @@ -63,8 +58,7 @@ struct HabitsView: View { .refreshable { await loadHabits(refresh: true) } .sheet(isPresented: $showAddHabit) { AddHabitView(isPresented: $showAddHabit) { await loadHabits(refresh: true) } - .presentationDetents([.large]) - .presentationDragIndicator(.visible) + .presentationDetents([.large]).presentationDragIndicator(.visible) .presentationBackground(Color(hex: "0a0a1a")) } } diff --git a/PulseHealth/Views/MainTabView.swift b/PulseHealth/Views/MainTabView.swift index bd7395d..8c7baf5 100644 --- a/PulseHealth/Views/MainTabView.swift +++ b/PulseHealth/Views/MainTabView.swift @@ -2,28 +2,32 @@ import SwiftUI struct MainTabView: View { @EnvironmentObject var authManager: AuthManager + @AppStorage("colorScheme") private var colorSchemeRaw: String = "dark" + + var preferredColorScheme: ColorScheme? { + colorSchemeRaw == "light" ? .light : .dark + } var body: some View { TabView { DashboardView() .tabItem { Label("Главная", systemImage: "house.fill") } - TasksView() - .tabItem { Label("Задачи", systemImage: "checkmark.circle.fill") } + TrackerView() + .tabItem { Label("Трекер", systemImage: "chart.bar.fill") } - HabitsView() - .tabItem { Label("Привычки", systemImage: "flame.fill") } - - HealthView() - .tabItem { Label("Здоровье", systemImage: "heart.fill") } + if authManager.userId == 1 { + FinanceView() + .tabItem { Label("Финансы", systemImage: "creditcard.fill") } + } SavingsView() - .tabItem { Label("Накопления", systemImage: "chart.bar.fill") } + .tabItem { Label("Накопления", systemImage: "building.columns.fill") } - ProfileView() - .tabItem { Label("Профиль", systemImage: "person.fill") } + SettingsView() + .tabItem { Label("Настройки", systemImage: "gearshape.fill") } } - .accentColor(Color(hex: "00d4aa")) - .preferredColorScheme(.dark) + .accentColor(Color(hex: "0D9488")) + .preferredColorScheme(preferredColorScheme) } } diff --git a/PulseHealth/Views/Profile/ProfileView.swift b/PulseHealth/Views/Profile/ProfileView.swift index 15dc3af..bf90385 100644 --- a/PulseHealth/Views/Profile/ProfileView.swift +++ b/PulseHealth/Views/Profile/ProfileView.swift @@ -1,77 +1,13 @@ import SwiftUI +// ProfileView is now replaced by SettingsView in MainTabView. +// This file is kept for legacy support (ChangePasswordView is here). + struct ProfileView: View { @EnvironmentObject var authManager: AuthManager @State private var showChangePassword = false - var body: some View { - ZStack { - Color(hex: "0a0a1a").ignoresSafeArea() - VStack(spacing: 0) { - // Header - VStack(spacing: 12) { - ZStack { - Circle().fill(Color(hex: "00d4aa").opacity(0.2)).frame(width: 80, height: 80) - Text(String(authManager.userName.prefix(1)).uppercased()) - .font(.largeTitle.bold()).foregroundColor(Color(hex: "00d4aa")) - } - Text(authManager.userName).font(.title2.bold()).foregroundColor(.white) - } - .padding(.top, 40).padding(.bottom, 32) - - // Settings list - VStack(spacing: 2) { - ProfileRow(icon: "lock.fill", title: "Сменить пароль", color: "7c3aed") { - showChangePassword = true - } - ProfileRow(icon: "heart.fill", title: "Health API ключ", subtitle: authManager.healthApiKey, color: "ff4757") {} - Divider().background(Color.white.opacity(0.1)).padding(.vertical, 8) - ProfileRow(icon: "rectangle.portrait.and.arrow.right", title: "Выйти", color: "ff4757", isDestructive: true) { - authManager.logout() - } - } - .padding(.horizontal) - - Spacer() - - Text("Pulse v1.0").font(.caption).foregroundColor(Color(hex: "8888aa")).padding(.bottom, 20) - } - } - .sheet(isPresented: $showChangePassword) { - ChangePasswordView(isPresented: $showChangePassword) - } - } -} - -struct ProfileRow: View { - let icon: String - let title: String - var subtitle: String? = nil - let color: String - var isDestructive: Bool = false - let action: () -> Void - - var body: some View { - Button(action: action) { - HStack(spacing: 14) { - ZStack { - RoundedRectangle(cornerRadius: 8).fill(Color(hex: color).opacity(0.2)).frame(width: 36, height: 36) - Image(systemName: icon).foregroundColor(Color(hex: color)).font(.subheadline) - } - VStack(alignment: .leading, spacing: 2) { - Text(title).foregroundColor(isDestructive ? Color(hex: "ff4757") : .white).font(.callout) - if let sub = subtitle { - Text(sub).font(.caption).foregroundColor(Color(hex: "8888aa")) - } - } - Spacer() - if !isDestructive { - Image(systemName: "chevron.right").foregroundColor(Color(hex: "8888aa")).font(.caption) - } - } - .padding(14) - .background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.05))) - } + SettingsView() } } @@ -86,17 +22,19 @@ struct ChangePasswordView: View { @State private var success = false var body: some View { - NavigationView { - ZStack { - Color(hex: "0a0a1a").ignoresSafeArea() - VStack(spacing: 16) { - if success { - VStack(spacing: 12) { - Text("✅").font(.system(size: 50)) - Text("Пароль изменён!").font(.title2.bold()).foregroundColor(.white) - }.padding(.top, 40) - Button("Закрыть") { isPresented = false }.foregroundColor(Color(hex: "00d4aa")) - } else { + ZStack { + Color(hex: "0a0a1a").ignoresSafeArea() + VStack(spacing: 16) { + RoundedRectangle(cornerRadius: 3).fill(Color.white.opacity(0.2)).frame(width: 40, height: 4).padding(.top, 12) + Text("Смена пароля").font(.title3.bold()).foregroundColor(.white).padding(.top, 4) + if success { + VStack(spacing: 12) { + Text("✅").font(.system(size: 50)) + Text("Пароль изменён!").font(.title2.bold()).foregroundColor(.white) + }.padding(.top, 40) + Button("Закрыть") { isPresented = false }.foregroundColor(Color(hex: "0D9488")) + } else { + VStack(spacing: 12) { SecureField("Текущий пароль", text: $oldPassword) .padding().background(Color.white.opacity(0.08)).cornerRadius(12).foregroundColor(.white) SecureField("Новый пароль", text: $newPassword) @@ -111,16 +49,14 @@ struct ChangePasswordView: View { else { Text("Сменить").font(.headline).foregroundColor(.black) } } .frame(maxWidth: .infinity).padding() - .background(Color(hex: "00d4aa")).cornerRadius(12) + .background(Color(hex: "0D9488")).cornerRadius(12) .disabled(oldPassword.isEmpty || newPassword.isEmpty || isLoading) } - Spacer() - }.padding() - } - .navigationTitle("Смена пароля") - .navigationBarTitleDisplayMode(.inline) - .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Отмена") { isPresented = false } } } - .preferredColorScheme(.dark) + Button("Отмена") { isPresented = false } + .foregroundColor(Color(hex: "8888aa")).padding(.top, 4) + } + Spacer() + }.padding() } } @@ -136,11 +72,8 @@ struct ChangePasswordView: View { req.httpBody = try? JSONEncoder().encode(["old_password": oldPassword, "new_password": newPassword]) let (_, response) = (try? await URLSession.shared.data(for: req)) ?? (Data(), nil) await MainActor.run { - if (response as? HTTPURLResponse)?.statusCode == 200 { - success = true - } else { - errorMessage = "Неверный текущий пароль" - } + if (response as? HTTPURLResponse)?.statusCode == 200 { success = true } + else { errorMessage = "Неверный текущий пароль" } isLoading = false } } diff --git a/PulseHealth/Views/Savings/SavingsView.swift b/PulseHealth/Views/Savings/SavingsView.swift index 3f023dd..3ffcfca 100644 --- a/PulseHealth/Views/Savings/SavingsView.swift +++ b/PulseHealth/Views/Savings/SavingsView.swift @@ -1,65 +1,160 @@ import SwiftUI +// MARK: - SavingsView + 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 + } + .padding(.horizontal) + .padding(.top, 16) + .padding(.bottom, 12) + Picker("", selection: $selectedTab) { Text("Обзор").tag(0) - Text("Транзакции").tag(1) + Text("Категории").tag(1) + Text("Операции").tag(2) } .pickerStyle(.segmented) .padding(.horizontal) .padding(.bottom, 12) - - if selectedTab == 0 { - SavingsOverviewTab() - } else { - SavingsTransactionsTab() + + switch selectedTab { + case 0: SavingsOverviewTab2() + case 1: SavingsCategoriesTab() + default: SavingsOperationsTab() } } } } } -struct SavingsOverviewTab: View { +// MARK: - SavingsOverviewTab2 + +struct SavingsOverviewTab2: View { @EnvironmentObject var authManager: AuthManager @State private var categories: [SavingsCategory] = [] @State private var stats: SavingsStats? @State private var isLoading = true - + + var recurringCategories: [SavingsCategory] { categories.filter { $0.isRecurring == true } } + var hasOverdue: Bool { (stats?.overdueCount ?? 0) > 0 } + var body: some View { - 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) + ScrollView { + VStack(spacing: 16) { + if isLoading { + ProgressView().tint(Color(hex: "0D9488")).padding(.top, 40) + } else { + // Total Balance Card + if let s = stats { + VStack(spacing: 16) { + VStack(spacing: 6) { + Text("Общий баланс").font(.subheadline).foregroundColor(Color(hex: "8888aa")) + Text(formatAmt(s.totalBalance ?? 0)) + .font(.system(size: 36, weight: .bold)).foregroundColor(.white) + } + HStack { + VStack(spacing: 4) { + Text("Пополнения").font(.caption).foregroundColor(Color(hex: "8888aa")) + Text("+\(formatAmt(s.totalDeposits ?? 0))") + .font(.callout.bold()).foregroundColor(Color(hex: "0D9488")) + } + Spacer() + VStack(spacing: 4) { + Text("Снятия").font(.caption).foregroundColor(Color(hex: "8888aa")) + Text("-\(formatAmt(s.totalWithdrawals ?? 0))") + .font(.callout.bold()).foregroundColor(Color(hex: "ff4757")) + } + Spacer() + VStack(spacing: 4) { + Text("Категорий").font(.caption).foregroundColor(Color(hex: "8888aa")) + Text("\(s.categoriesCount ?? 0)") + .font(.callout.bold()).foregroundColor(.white) + } + } + } + .padding(20) + .background(LinearGradient(colors: [Color(hex: "1a1a3e"), Color(hex: "12122a")], startPoint: .topLeading, endPoint: .bottomTrailing)) + .cornerRadius(20) + .overlay(RoundedRectangle(cornerRadius: 20).stroke(Color(hex: "0D9488").opacity(0.3), lineWidth: 1)) + .padding(.horizontal) + } + + // Overdue block + if hasOverdue, let s = stats { + HStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(Color(hex: "ff4757")) + .font(.title3) + VStack(alignment: .leading, spacing: 2) { + Text("Просроченные платежи").font(.callout.bold()).foregroundColor(Color(hex: "ff4757")) + Text("\(s.overdueCount ?? 0) платежей на сумму \(formatAmt(s.overdueAmount ?? 0))") + .font(.caption).foregroundColor(.white.opacity(0.7)) + } + Spacer() + } + .padding(16) + .background(RoundedRectangle(cornerRadius: 14).fill(Color(hex: "ff4757").opacity(0.12))) + .overlay(RoundedRectangle(cornerRadius: 14).stroke(Color(hex: "ff4757").opacity(0.3), lineWidth: 1)) + .padding(.horizontal) + } + + // Categories progress + if !categories.isEmpty { + VStack(alignment: .leading, spacing: 10) { + Text("Прогресс по категориям") + .font(.subheadline.bold()).foregroundColor(.white).padding(.horizontal) + ForEach(categories.filter { $0.isClosed != true }) { cat in + SavingsCategoryCard(category: cat) + } + } + } + + // Monthly payments + if !recurringCategories.isEmpty { + VStack(alignment: .leading, spacing: 10) { + Text("Ежемесячные платежи") + .font(.subheadline.bold()).foregroundColor(.white).padding(.horizontal) + ForEach(recurringCategories) { cat in + HStack(spacing: 12) { + ZStack { + Circle().fill(Color(hex: cat.colorHex).opacity(0.15)).frame(width: 40, height: 40) + Image(systemName: cat.icon).foregroundColor(Color(hex: cat.colorHex)).font(.body) + } + VStack(alignment: .leading, spacing: 2) { + Text(cat.name).font(.callout).foregroundColor(.white) + if let day = cat.recurringDay { + Text("\(day) числа каждого месяца").font(.caption2).foregroundColor(Color(hex: "8888aa")) + } + } + Spacer() + Text(formatAmt(cat.recurringAmount ?? 0)) + .font(.callout.bold()).foregroundColor(Color(hex: cat.colorHex)) + } + .padding(14) + .background(RoundedRectangle(cornerRadius: 14).fill(Color.white.opacity(0.04))) + .padding(.horizontal) + } + } } } + Spacer(minLength: 80) } + .padding(.top, 8) } - .task { await loadData() } - .refreshable { await loadData(refresh: true) } + .task { await load() } + .refreshable { await load(refresh: true) } } - - func loadData(refresh: Bool = false) async { + + func load(refresh: Bool = false) async { if !refresh { isLoading = true } async let cats = APIService.shared.getSavingsCategories(token: authManager.token) async let st = APIService.shared.getSavingsStats(token: authManager.token) @@ -67,228 +162,466 @@ struct SavingsOverviewTab: View { stats = try? await st isLoading = false } + + func formatAmt(_ v: Double) -> String { + v >= 1_000_000 ? String(format: "%.2f млн ₽", v / 1_000_000) : String(format: "%.0f ₽", v) + } } -struct SavingsTransactionsTab: View { +// MARK: - SavingsCategoriesTab + +struct SavingsCategoriesTab: View { @EnvironmentObject var authManager: AuthManager - @State private var transactions: [SavingsTransaction] = [] + @State private var categories: [SavingsCategory] = [] @State private var isLoading = true - + @State private var showAdd = false + @State private var editingCategory: SavingsCategory? + + var active: [SavingsCategory] { categories.filter { $0.isClosed != true } } + var closed: [SavingsCategory] { categories.filter { $0.isClosed == 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) + ZStack(alignment: .bottomTrailing) { + Group { + if isLoading { + ProgressView().tint(Color(hex: "0D9488")).padding(.top, 40) + Spacer() + } else if active.isEmpty { + VStack { EmptyState(icon: "building.columns", text: "Нет категорий"); Spacer() } + } else { + List { + Section(header: Text("Активные").foregroundColor(Color(hex: "8888aa"))) { + ForEach(active) { cat in + SavingsCategoryRow(category: cat) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .onTapGesture { editingCategory = cat } + } + .onDelete { idx in + let toDelete = idx.map { active[$0] } + Task { + for c in toDelete { try? await APIService.shared.deleteSavingsCategory(token: authManager.token, id: c.id) } + await load(refresh: true) + } + } + } + if !closed.isEmpty { + Section(header: Text("Закрытые").foregroundColor(Color(hex: "8888aa"))) { + ForEach(closed) { cat in + SavingsCategoryRow(category: cat) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } + } + } } + .listStyle(.plain) + .scrollContentBackground(.hidden) } - .listStyle(.plain) - .scrollContentBackground(.hidden) } + .refreshable { await load(refresh: true) } + + Button(action: { showAdd = true }) { + ZStack { + Circle() + .fill(LinearGradient(colors: [Color(hex: "0D9488"), Color(hex: "14b8a6")], startPoint: .topLeading, endPoint: .bottomTrailing)) + .frame(width: 56, height: 56) + .shadow(color: Color(hex: "0D9488").opacity(0.4), radius: 8, y: 4) + Image(systemName: "plus").font(.title2.bold()).foregroundColor(.white) + } + } + .padding(.bottom, 90) + .padding(.trailing, 20) + } + .task { await load() } + .sheet(isPresented: $showAdd) { + AddSavingsCategoryView(isPresented: $showAdd) { await load(refresh: true) } + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + .presentationBackground(Color(hex: "0a0a1a")) } - .task { await loadData() } - .refreshable { await loadData(refresh: true) } } - - func loadData(refresh: Bool = false) async { + + func load(refresh: Bool = false) async { if !refresh { isLoading = true } - transactions = (try? await APIService.shared.getSavingsTransactions(token: authManager.token)) ?? [] + categories = (try? await APIService.shared.getSavingsCategories(token: authManager.token)) ?? [] isLoading = false } } -struct SavingsTransactionRow: View { +// MARK: - SavingsCategoryRow + +struct SavingsCategoryRow: View { + let category: SavingsCategory + var body: some View { + HStack(spacing: 12) { + ZStack { + Circle().fill(Color(hex: category.colorHex).opacity(0.15)).frame(width: 44, height: 44) + Image(systemName: category.icon).foregroundColor(Color(hex: category.colorHex)).font(.title3) + } + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 4) { + Text(category.typeEmoji) + Text(category.name).font(.callout.bold()).foregroundColor(.white) + } + Text(category.typeLabel).font(.caption).foregroundColor(Color(hex: "8888aa")) + } + Spacer() + Text(formatAmt(category.currentAmount ?? 0)) + .font(.callout.bold()).foregroundColor(Color(hex: category.colorHex)) + } + .padding(14) + .background(RoundedRectangle(cornerRadius: 14).fill(Color.white.opacity(0.04))) + .padding(.horizontal) + .padding(.vertical, 2) + } + func formatAmt(_ v: Double) -> String { + v >= 1_000_000 ? String(format: "%.2f млн ₽", v / 1_000_000) : String(format: "%.0f ₽", v) + } +} + +// MARK: - AddSavingsCategoryView + +struct AddSavingsCategoryView: View { + @Binding var isPresented: Bool + @EnvironmentObject var authManager: AuthManager + let onAdded: () async -> Void + @State private var name = "" + @State private var isDeposit = false + @State private var isRecurring = false + @State private var isAccount = false + @State private var recurringAmount = "" + @State private var interestRate = "" + @State private var isLoading = false + + var body: some View { + ZStack { + Color(hex: "0a0a1a").ignoresSafeArea() + VStack(spacing: 0) { + RoundedRectangle(cornerRadius: 3).fill(Color.white.opacity(0.2)).frame(width: 40, height: 4).padding(.top, 12) + HStack { + Button("Отмена") { isPresented = false }.foregroundColor(Color(hex: "8888aa")) + Spacer() + Text("Новая категория").font(.headline).foregroundColor(.white) + Spacer() + Button(action: save) { + if isLoading { ProgressView().tint(Color(hex: "0D9488")).scaleEffect(0.8) } + else { Text("Добавить").foregroundColor(name.isEmpty ? Color(hex: "8888aa") : Color(hex: "0D9488")).fontWeight(.semibold) } + }.disabled(name.isEmpty || isLoading) + } + .padding(.horizontal, 20).padding(.vertical, 16) + Divider().background(Color.white.opacity(0.1)) + ScrollView { + VStack(spacing: 16) { + fieldLabel("Название") { + TextField("Например: На машину", text: $name) + .foregroundColor(.white).padding(14) + .background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.07))) + } + VStack(alignment: .leading, spacing: 8) { + Label("Тип", systemImage: "tag.fill").font(.caption).foregroundColor(Color(hex: "8888aa")) + HStack(spacing: 8) { + TypeButton(label: "💰 Накопление", selected: !isDeposit && !isRecurring && !isAccount) { isDeposit = false; isRecurring = false; isAccount = false } + TypeButton(label: "🏦 Вклад", selected: isDeposit) { isDeposit = true; isRecurring = false; isAccount = false } + } + HStack(spacing: 8) { + TypeButton(label: "🔄 Регулярные", selected: isRecurring) { isDeposit = false; isRecurring = true; isAccount = false } + TypeButton(label: "🏧 Счёт", selected: isAccount) { isDeposit = false; isRecurring = false; isAccount = true } + } + } + if isRecurring { + fieldLabel("Сумма / мес. (₽)") { + TextField("0", text: $recurringAmount).keyboardType(.decimalPad) + .foregroundColor(.white).padding(14) + .background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.07))) + } + } + if isDeposit { + fieldLabel("Ставка (%)") { + TextField("0.0", text: $interestRate).keyboardType(.decimalPad) + .foregroundColor(.white).padding(14) + .background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.07))) + } + } + }.padding(20) + } + } + } + } + + @ViewBuilder func fieldLabel(_ label: String, @ViewBuilder content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 8) { + Label(label, systemImage: "pencil").font(.caption).foregroundColor(Color(hex: "8888aa")) + content() + } + } + + func save() { + isLoading = true + Task { + var params: [String: Any] = ["name": name, "is_deposit": isDeposit, "is_recurring": isRecurring, "is_account": isAccount] + if isRecurring, let a = Double(recurringAmount) { params["recurring_amount"] = a } + if isDeposit, let r = Double(interestRate) { params["interest_rate"] = r } + if let body = try? JSONSerialization.data(withJSONObject: params) { + try? await APIService.shared.createSavingsCategory(token: authManager.token, body: body) + } + await onAdded() + await MainActor.run { isPresented = false } + } + } +} + +struct TypeButton: View { + let label: String + let selected: Bool + let action: () -> Void + var body: some View { + Button(action: action) { + Text(label).font(.caption.bold()) + .foregroundColor(selected ? .black : .white) + .frame(maxWidth: .infinity).padding(.vertical, 10) + .background(RoundedRectangle(cornerRadius: 10).fill(selected ? Color(hex: "0D9488") : Color.white.opacity(0.07))) + } + } +} + +// MARK: - SavingsOperationsTab + +struct SavingsOperationsTab: View { + @EnvironmentObject var authManager: AuthManager + @State private var transactions: [SavingsTransaction] = [] + @State private var categories: [SavingsCategory] = [] + @State private var selectedCategoryId: Int? = nil + @State private var isLoading = true + @State private var showAdd = false + + var filtered: [SavingsTransaction] { + guard let cid = selectedCategoryId else { return transactions } + return transactions.filter { $0.categoryId == cid } + } + + var body: some View { + ZStack(alignment: .bottomTrailing) { + VStack(spacing: 0) { + // Filter pills + if !categories.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + FilterPill(label: "Все", selected: selectedCategoryId == nil) { selectedCategoryId = nil } + ForEach(categories.filter { $0.isClosed != true }) { cat in + FilterPill(label: cat.name, selected: selectedCategoryId == cat.id) { selectedCategoryId = cat.id } + } + } + .padding(.horizontal) + } + .padding(.bottom, 8) + } + + if isLoading { + ProgressView().tint(Color(hex: "0D9488")).padding(.top, 40) + Spacer() + } else if filtered.isEmpty { + VStack { EmptyState(icon: "arrow.left.arrow.right", text: "Нет операций"); Spacer() } + } else { + List { + ForEach(filtered) { tx in + SavingsTransactionRow2(transaction: tx) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } + .onDelete { idx in + let toDelete = idx.map { filtered[$0] } + Task { + for t in toDelete { try? await APIService.shared.deleteSavingsTransaction(token: authManager.token, id: t.id) } + await load(refresh: true) + } + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + } + } + .refreshable { await load(refresh: true) } + + Button(action: { showAdd = true }) { + ZStack { + Circle() + .fill(LinearGradient(colors: [Color(hex: "0D9488"), Color(hex: "14b8a6")], startPoint: .topLeading, endPoint: .bottomTrailing)) + .frame(width: 56, height: 56) + .shadow(color: Color(hex: "0D9488").opacity(0.4), radius: 8, y: 4) + Image(systemName: "plus").font(.title2.bold()).foregroundColor(.white) + } + } + .padding(.bottom, 90) + .padding(.trailing, 20) + } + .task { await load() } + .sheet(isPresented: $showAdd) { + AddSavingsTransactionView(isPresented: $showAdd, categories: categories) { await load(refresh: true) } + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + .presentationBackground(Color(hex: "0a0a1a")) + } + } + + func load(refresh: Bool = false) async { + if !refresh { isLoading = true } + async let txs = APIService.shared.getSavingsTransactions(token: authManager.token, categoryId: selectedCategoryId) + async let cats = APIService.shared.getSavingsCategories(token: authManager.token) + transactions = (try? await txs) ?? [] + categories = (try? await cats) ?? [] + isLoading = false + } +} + +struct FilterPill: View { + let label: String + let selected: Bool + let action: () -> Void + var body: some View { + Button(action: action) { + Text(label).font(.caption.bold()) + .foregroundColor(selected ? .black : .white) + .padding(.horizontal, 14).padding(.vertical, 8) + .background(RoundedRectangle(cornerRadius: 20).fill(selected ? Color(hex: "0D9488") : Color.white.opacity(0.08))) + } + } +} + +struct SavingsTransactionRow2: 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)) + .fill((transaction.isDeposit ? Color(hex: "0D9488") : 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")) + .foregroundColor(transaction.isDeposit ? Color(hex: "0D9488") : Color(hex: "ff4757")) } - - VStack(alignment: .leading, spacing: 3) { - Text(transaction.categoryName ?? "Без категории") - .font(.callout).foregroundColor(.white) + VStack(alignment: .leading, spacing: 2) { + 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")) - } + if let name = transaction.userName { Text(name).font(.caption).foregroundColor(Color(hex: "8888aa")) } + Text(transaction.dateFormatted).font(.caption2).foregroundColor(Color(hex: "8888aa")) + } + if let desc = transaction.description, !desc.isEmpty { + Text(desc).font(.caption2).foregroundColor(Color(hex: "8888aa")) } } - Spacer() - - Text("\(transaction.isDeposit ? "+" : "-")\(formatAmount(transaction.amount))") + Text("\(transaction.isDeposit ? "+" : "-")\(formatAmt(transaction.amount))") .font(.callout.bold()) - .foregroundColor(transaction.isDeposit ? Color(hex: "00d4aa") : Color(hex: "ff4757")) + .foregroundColor(transaction.isDeposit ? Color(hex: "0D9488") : 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])" - } + func formatAmt(_ v: Double) -> String { v >= 1_000_000 ? String(format: "%.2f млн ₽", v / 1_000_000) : String(format: "%.0f ₽", v) } } -// MARK: - SavingsTotalCard +// MARK: - AddSavingsTransactionView + +struct AddSavingsTransactionView: View { + @Binding var isPresented: Bool + @EnvironmentObject var authManager: AuthManager + let categories: [SavingsCategory] + let onAdded: () async -> Void + + @State private var amount = "" + @State private var description = "" + @State private var type = "deposit" + @State private var selectedCategoryId: Int? = nil + @State private var isLoading = false + + var isDeposit: Bool { type == "deposit" } -struct SavingsTotalCard: View { - let stats: SavingsStats - var body: some View { - VStack(spacing: 20) { - VStack(spacing: 6) { - Text("Общий баланс").font(.subheadline).foregroundColor(Color(hex: "8888aa")) - Text(formatAmount(stats.totalBalance ?? 0)) - .font(.system(size: 36, weight: .bold)) - .foregroundColor(.white) - } - - HStack(spacing: 0) { - VStack(spacing: 4) { - Text("Пополнения").font(.caption).foregroundColor(Color(hex: "8888aa")) - Text("+\(formatAmount(stats.totalDeposits ?? 0))").font(.callout.bold()).foregroundColor(Color(hex: "00d4aa")) + ZStack { + Color(hex: "0a0a1a").ignoresSafeArea() + VStack(spacing: 0) { + RoundedRectangle(cornerRadius: 3).fill(Color.white.opacity(0.2)).frame(width: 40, height: 4).padding(.top, 12) + HStack { + Button("Отмена") { isPresented = false }.foregroundColor(Color(hex: "8888aa")) + Spacer() + Text("Новая операция").font(.headline).foregroundColor(.white) + Spacer() + Button(action: save) { + if isLoading { ProgressView().tint(Color(hex: "0D9488")).scaleEffect(0.8) } + else { Text("Добавить").foregroundColor((amount.isEmpty || selectedCategoryId == nil) ? Color(hex: "8888aa") : Color(hex: "0D9488")).fontWeight(.semibold) } + }.disabled(amount.isEmpty || selectedCategoryId == nil || isLoading) } - Spacer() - VStack(spacing: 4) { - Text("Снятия").font(.caption).foregroundColor(Color(hex: "8888aa")) - Text("-\(formatAmount(stats.totalWithdrawals ?? 0))").font(.callout.bold()).foregroundColor(Color(hex: "ff4757")) - } - Spacer() - VStack(spacing: 4) { - Text("Категорий").font(.caption).foregroundColor(Color(hex: "8888aa")) - Text("\(stats.categoriesCount ?? 0)").font(.callout.bold()).foregroundColor(.white) - } - } - } - .padding(20) - .background( - LinearGradient(colors: [Color(hex: "1a1a3e"), Color(hex: "12122a")], startPoint: .topLeading, endPoint: .bottomTrailing) - ) - .cornerRadius(20) - .overlay(RoundedRectangle(cornerRadius: 20).stroke(Color(hex: "00d4aa").opacity(0.3), lineWidth: 1)) - .padding(.horizontal) - } - - func formatAmount(_ v: Double) -> String { - if v >= 1_000_000 { - return String(format: "%.2f млн ₽", v / 1_000_000) - } else if v >= 1000 { - return String(format: "%.0f ₽", v) - } - return String(format: "%.0f ₽", v) - } -} - -// MARK: - SavingsCategoryCard - -struct SavingsCategoryCard: View { - let category: SavingsCategory - @State private var appeared = false - - var body: some View { - VStack(spacing: 12) { - HStack(spacing: 12) { - ZStack { - Circle() - .fill(Color(hex: category.color).opacity(0.2)) - .frame(width: 46, height: 46) - Image(systemName: category.icon) - .foregroundColor(Color(hex: category.color)) - .font(.title3) - } - - VStack(alignment: .leading, spacing: 4) { - Text(category.name).font(.callout.bold()).foregroundColor(.white) - Text(category.typeLabel).font(.caption).foregroundColor(Color(hex: "8888aa")) - } - - Spacer() - - VStack(alignment: .trailing, spacing: 2) { - Text(formatAmount(category.currentAmount ?? 0)) - .font(.callout.bold()) - .foregroundColor(Color(hex: category.color)) - if let end = category.depositEndDate { - Text("до \(formatDate(end))") - .font(.caption2) - .foregroundColor(Color(hex: "8888aa")) - } - } - } - - if category.isDeposit == true, let target = category.depositAmount, target > 0 { - let progress = min((category.currentAmount ?? 0) / target, 1.0) - VStack(spacing: 4) { - GeometryReader { geo in - ZStack(alignment: .leading) { - RoundedRectangle(cornerRadius: 4).fill(Color.white.opacity(0.08)) - RoundedRectangle(cornerRadius: 4) - .fill(LinearGradient(colors: [Color(hex: "ffa502"), Color(hex: "ff6b35")], startPoint: .leading, endPoint: .trailing)) - .frame(width: geo.size.width * CGFloat(appeared ? progress : 0)) - .animation(.easeInOut(duration: 0.8), value: appeared) + .padding(.horizontal, 20).padding(.vertical, 16) + Divider().background(Color.white.opacity(0.1)) + ScrollView { + VStack(spacing: 20) { + // Type toggle + HStack(spacing: 0) { + Button(action: { type = "deposit" }) { + Text("Пополнение ↓") + .font(.callout.bold()).foregroundColor(isDeposit ? .black : Color(hex: "0D9488")) + .frame(maxWidth: .infinity).padding(.vertical, 12) + .background(isDeposit ? Color(hex: "0D9488") : Color.clear) + } + Button(action: { type = "withdrawal" }) { + Text("Снятие ↑") + .font(.callout.bold()).foregroundColor(!isDeposit ? .black : Color(hex: "ff4757")) + .frame(maxWidth: .infinity).padding(.vertical, 12) + .background(!isDeposit ? Color(hex: "ff4757") : Color.clear) + } } - } - .frame(height: 6) - HStack { - Text("\(Int(progress * 100))%") - .font(.caption2).foregroundColor(Color(hex: "ffa502")) - Spacer() - Text("цель: \(formatAmount(target))") - .font(.caption2).foregroundColor(Color(hex: "8888aa")) - } + .background(Color.white.opacity(0.07)).cornerRadius(12) + + HStack { + Text(isDeposit ? "+" : "−").font(.title.bold()).foregroundColor(isDeposit ? Color(hex: "0D9488") : Color(hex: "ff4757")) + TextField("0", text: $amount).keyboardType(.decimalPad) + .font(.system(size: 32, weight: .bold)).foregroundColor(.white).multilineTextAlignment(.center) + Text("₽").font(.title.bold()).foregroundColor(Color(hex: "8888aa")) + } + .padding(20) + .background(RoundedRectangle(cornerRadius: 16).fill(Color.white.opacity(0.07))) + + VStack(alignment: .leading, spacing: 8) { + Label("Категория", systemImage: "tag.fill").font(.caption).foregroundColor(Color(hex: "8888aa")) + ForEach(categories.filter { $0.isClosed != true }) { cat in + Button(action: { selectedCategoryId = selectedCategoryId == cat.id ? nil : cat.id }) { + HStack(spacing: 10) { + Image(systemName: cat.icon).foregroundColor(Color(hex: cat.colorHex)).font(.body) + Text(cat.name).font(.callout).foregroundColor(.white) + Spacer() + if selectedCategoryId == cat.id { + Image(systemName: "checkmark").foregroundColor(Color(hex: "0D9488")) + } + } + .padding(12) + .background(RoundedRectangle(cornerRadius: 12).fill(selectedCategoryId == cat.id ? Color(hex: "0D9488").opacity(0.15) : Color.white.opacity(0.05))) + } + } + } + + VStack(alignment: .leading, spacing: 8) { + Label("Описание", systemImage: "text.alignleft").font(.caption).foregroundColor(Color(hex: "8888aa")) + TextField("Комментарий...", text: $description) + .foregroundColor(.white).padding(14) + .background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.07))) + } + }.padding(20) } } } - .padding(16) - .background( - RoundedRectangle(cornerRadius: 16) - .fill(Color.white.opacity(0.04)) - .overlay(RoundedRectangle(cornerRadius: 16).stroke(Color(hex: category.color).opacity(0.15), lineWidth: 1)) - ) - .opacity(appeared ? 1 : 0) - .offset(y: appeared ? 0 : 20) - .onAppear { - withAnimation(.easeOut(duration: 0.4)) { appeared = true } + } + + func save() { + guard let a = Double(amount.replacingOccurrences(of: ",", with: ".")), + let cid = selectedCategoryId else { return } + isLoading = true + Task { + let req = CreateSavingsTransactionRequest(categoryId: cid, amount: a, type: type, description: description.isEmpty ? nil : description) + try? await APIService.shared.createSavingsTransaction(token: authManager.token, request: req) + await onAdded() + await MainActor.run { isPresented = false } } } - - func formatAmount(_ v: Double) -> String { - if v >= 1_000_000 { return String(format: "%.2f млн ₽", 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 s } - return "\(parts[2]).\(parts[1]).\(parts[0])" - } } diff --git a/PulseHealth/Views/Settings/SettingsView.swift b/PulseHealth/Views/Settings/SettingsView.swift new file mode 100644 index 0000000..07d90e4 --- /dev/null +++ b/PulseHealth/Views/Settings/SettingsView.swift @@ -0,0 +1,293 @@ +import SwiftUI + +// MARK: - SettingsView + +struct SettingsView: View { + @EnvironmentObject var authManager: AuthManager + @AppStorage("colorScheme") private var colorSchemeRaw: String = "dark" + @State private var profile: UserProfile? + @State private var isLoading = true + @State private var isSaving = false + @State private var showPasswordChange = false + + // Profile fields + @State private var telegramChatId = "" + @State private var morningNotification = true + @State private var eveningNotification = true + @State private var morningTime = "09:00" + @State private var eveningTime = "21:00" + @State private var timezone = "Europe/Moscow" + @State private var username = "" + + var isDark: Bool { colorSchemeRaw != "light" } + + let timezones = [ + "Europe/Moscow", "Europe/Kaliningrad", "Europe/Samara", "Asia/Yekaterinburg", + "Asia/Omsk", "Asia/Krasnoyarsk", "Asia/Irkutsk", "Asia/Yakutsk", + "Asia/Vladivostok", "Asia/Magadan", "Asia/Kamchatka", + "UTC", "Europe/London", "Europe/Berlin", "Europe/Paris", "America/New_York" + ] + + var body: some View { + ZStack { + Color(hex: "0a0a1a").ignoresSafeArea() + ScrollView { + VStack(spacing: 0) { + // Header / Avatar + VStack(spacing: 12) { + ZStack { + Circle().fill(Color(hex: "0D9488").opacity(0.2)).frame(width: 80, height: 80) + Text(String(authManager.userName.prefix(1)).uppercased()) + .font(.largeTitle.bold()).foregroundColor(Color(hex: "0D9488")) + } + Text(authManager.userName).font(.title2.bold()).foregroundColor(.white) + Text("ID: \(authManager.userId)").font(.caption).foregroundColor(Color(hex: "8888aa")) + } + .padding(.top, 24) + .padding(.bottom, 28) + + if isLoading { + ProgressView().tint(Color(hex: "0D9488")).padding(.top, 20) + } else { + VStack(spacing: 20) { + // MARK: Appearance + SettingsSection(title: "Внешний вид") { + SettingsToggle(icon: "moon.fill", title: isDark ? "Тёмная тема" : "Светлая тема", color: "6366f1", isOn: isDark) { + colorSchemeRaw = isDark ? "light" : "dark" + } + } + + // MARK: Profile + SettingsSection(title: "Профиль") { + VStack(spacing: 8) { + Label("Имя пользователя", systemImage: "person.fill") + .font(.caption).foregroundColor(Color(hex: "8888aa")) + .frame(maxWidth: .infinity, alignment: .leading) + TextField("Username", text: $username) + .foregroundColor(.white).padding(14) + .background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.07))) + } + .padding(.horizontal, 4) + + SettingsButton(icon: "lock.fill", title: "Сменить пароль", color: "7c3aed") { + showPasswordChange = true + } + } + + // MARK: Telegram + SettingsSection(title: "Telegram Бот") { + VStack(alignment: .leading, spacing: 8) { + Label("Chat ID", systemImage: "paperplane.fill") + .font(.caption).foregroundColor(Color(hex: "8888aa")) + TextField("Например: 123456789", text: $telegramChatId) + .keyboardType(.numbersAndPunctuation) + .foregroundColor(.white).padding(14) + .background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.07))) + HStack(spacing: 4) { + Image(systemName: "info.circle").font(.caption2).foregroundColor(Color(hex: "0D9488")) + Text("Напишите /start боту @pulse_tracking_bot, чтобы получить Chat ID") + .font(.caption2).foregroundColor(Color(hex: "8888aa")) + } + } + .padding(.horizontal, 4) + } + + // MARK: Notifications + SettingsSection(title: "Уведомления") { + VStack(spacing: 12) { + SettingsToggle(icon: "sunrise.fill", title: "Утренние уведомления", color: "ffa502", isOn: morningNotification) { + morningNotification.toggle() + } + if morningNotification { + HStack { + Text("Время").font(.callout).foregroundColor(Color(hex: "8888aa")) + Spacer() + TextField("09:00", text: $morningTime) + .keyboardType(.numbersAndPunctuation) + .foregroundColor(.white) + .multilineTextAlignment(.trailing) + .frame(width: 60) + } + .padding(.horizontal, 4) + } + + Divider().background(Color.white.opacity(0.08)) + + SettingsToggle(icon: "moon.stars.fill", title: "Вечерние уведомления", color: "6366f1", isOn: eveningNotification) { + eveningNotification.toggle() + } + if eveningNotification { + HStack { + Text("Время").font(.callout).foregroundColor(Color(hex: "8888aa")) + Spacer() + TextField("21:00", text: $eveningTime) + .keyboardType(.numbersAndPunctuation) + .foregroundColor(.white) + .multilineTextAlignment(.trailing) + .frame(width: 60) + } + .padding(.horizontal, 4) + } + } + } + + // MARK: Timezone + SettingsSection(title: "Часовой пояс") { + VStack(alignment: .leading, spacing: 8) { + Label("Выберите часовой пояс", systemImage: "clock.fill") + .font(.caption).foregroundColor(Color(hex: "8888aa")) + Picker("Часовой пояс", selection: $timezone) { + ForEach(timezones, id: \.self) { tz in Text(tz).tag(tz) } + } + .pickerStyle(.wheel) + .frame(height: 120) + .clipped() + .background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.04))) + } + .padding(.horizontal, 4) + } + + // MARK: Save Button + Button(action: { Task { await saveProfile() } }) { + HStack { + if isSaving { ProgressView().tint(.black).scaleEffect(0.8) } + else { Text("Сохранить изменения").font(.headline).foregroundColor(.black) } + } + .frame(maxWidth: .infinity).padding() + .background(LinearGradient(colors: [Color(hex: "0D9488"), Color(hex: "14b8a6")], startPoint: .leading, endPoint: .trailing)) + .cornerRadius(14) + } + .disabled(isSaving) + .padding(.horizontal) + + // MARK: Logout + Button(action: { authManager.logout() }) { + HStack { + Image(systemName: "rectangle.portrait.and.arrow.right") + Text("Выйти из аккаунта") + } + .font(.callout.bold()) + .foregroundColor(Color(hex: "ff4757")) + .frame(maxWidth: .infinity).padding() + .background(RoundedRectangle(cornerRadius: 14).fill(Color(hex: "ff4757").opacity(0.1))) + .overlay(RoundedRectangle(cornerRadius: 14).stroke(Color(hex: "ff4757").opacity(0.3), lineWidth: 1)) + } + .padding(.horizontal) + + Text("Pulse v1.1 • Made with ❤️").font(.caption).foregroundColor(Color(hex: "8888aa")) + .padding(.bottom, 20) + } + } + } + } + } + .task { await loadProfile() } + .sheet(isPresented: $showPasswordChange) { + ChangePasswordView(isPresented: $showPasswordChange) + .presentationDetents([.medium]) + .presentationDragIndicator(.visible) + .presentationBackground(Color(hex: "0a0a1a")) + } + } + + func loadProfile() async { + isLoading = true + username = authManager.userName + if let p = try? await APIService.shared.getProfile(token: authManager.token) { + profile = p + telegramChatId = p.telegramChatId ?? "" + morningNotification = p.morningNotification ?? true + eveningNotification = p.eveningNotification ?? true + morningTime = p.morningTime ?? "09:00" + eveningTime = p.eveningTime ?? "21:00" + timezone = p.timezone ?? "Europe/Moscow" + } + isLoading = false + } + + func saveProfile() async { + isSaving = true + let req = UpdateProfileRequest( + telegramChatId: telegramChatId.isEmpty ? nil : telegramChatId, + morningNotification: morningNotification, + eveningNotification: eveningNotification, + morningTime: morningTime, + eveningTime: eveningTime, + timezone: timezone + ) + _ = try? await APIService.shared.updateProfile(token: authManager.token, request: req) + // Update username if changed + if username != authManager.userName { + var req2 = URLRequest(url: URL(string: "https://api.digital-home.site/auth/me")!) + req2.httpMethod = "PUT" + req2.setValue("application/json", forHTTPHeaderField: "Content-Type") + req2.setValue("Bearer \(authManager.token)", forHTTPHeaderField: "Authorization") + req2.httpBody = try? JSONEncoder().encode(["username": username]) + _ = try? await URLSession.shared.data(for: req2) + await MainActor.run { authManager.userName = username; UserDefaults.standard.set(username, forKey: "userName") } + } + isSaving = false + } +} + +// MARK: - SettingsSection + +struct SettingsSection: View { + let title: String + @ViewBuilder let content: () -> Content + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(title).font(.subheadline.bold()).foregroundColor(Color(hex: "8888aa")).padding(.horizontal) + VStack(spacing: 8) { + content() + } + .padding(16) + .background(RoundedRectangle(cornerRadius: 16).fill(Color.white.opacity(0.04))) + .padding(.horizontal) + } + } +} + +// MARK: - SettingsToggle + +struct SettingsToggle: View { + let icon: String + let title: String + let color: String + let isOn: Bool + let onToggle: () -> Void + var body: some View { + HStack(spacing: 14) { + ZStack { + RoundedRectangle(cornerRadius: 8).fill(Color(hex: color).opacity(0.2)).frame(width: 36, height: 36) + Image(systemName: icon).foregroundColor(Color(hex: color)).font(.subheadline) + } + Text(title).font(.callout).foregroundColor(.white) + Spacer() + Toggle("", isOn: Binding(get: { isOn }, set: { _ in onToggle() })) + .tint(Color(hex: "0D9488")) + } + } +} + +// MARK: - SettingsButton + +struct SettingsButton: View { + let icon: String + let title: String + let color: String + let action: () -> Void + var body: some View { + Button(action: action) { + HStack(spacing: 14) { + ZStack { + RoundedRectangle(cornerRadius: 8).fill(Color(hex: color).opacity(0.2)).frame(width: 36, height: 36) + Image(systemName: icon).foregroundColor(Color(hex: color)).font(.subheadline) + } + Text(title).font(.callout).foregroundColor(.white) + Spacer() + Image(systemName: "chevron.right").foregroundColor(Color(hex: "8888aa")).font(.caption) + } + } + } +} diff --git a/PulseHealth/Views/Tasks/AddTaskView.swift b/PulseHealth/Views/Tasks/AddTaskView.swift index 28d41a4..1e992f8 100644 --- a/PulseHealth/Views/Tasks/AddTaskView.swift +++ b/PulseHealth/Views/Tasks/AddTaskView.swift @@ -4,103 +4,136 @@ struct AddTaskView: View { @Binding var isPresented: Bool @EnvironmentObject var authManager: AuthManager let onAdded: () async -> Void - + @State private var title = "" @State private var description = "" @State private var priority: Int = 2 + @State private var selectedIcon = "✅" + @State private var selectedColor = "#0D9488" + @State private var hasDueDate = false + @State private var dueDate = Date() @State private var isLoading = false - + let priorities: [(Int, String, String)] = [ (1, "Низкий", "8888aa"), (2, "Средний", "ffa502"), (3, "Высокий", "ff4757"), (4, "Срочный", "ff0000") ] - + + let icons = ["✅","📌","🎯","💼","🏠","🛒","📞","🎓","💊","🚗", + "📅","⚡","🔧","📬","💡","🏋️","🌿","🎵","✍️","🌏"] + + let colors = ["#0D9488","#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 + .fill(Color.white.opacity(0.2)).frame(width: 40, height: 4).padding(.top, 12) HStack { - Button("Отмена") { isPresented = false } - .foregroundColor(Color(hex: "8888aa")) + 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(title.isEmpty ? Color(hex: "8888aa") : Color(hex: "00d4aa")).fontWeight(.semibold) } - } - .disabled(title.isEmpty || isLoading) + if isLoading { ProgressView().tint(Color(hex: "0D9488")).scaleEffect(0.8) } + else { Text("Добавить").foregroundColor(title.isEmpty ? Color(hex: "8888aa") : Color(hex: "0D9488")).fontWeight(.semibold) } + }.disabled(title.isEmpty || isLoading) } - .padding(.horizontal, 20) - .padding(.vertical, 16) - + .padding(.horizontal, 20).padding(.vertical, 16) Divider().background(Color.white.opacity(0.1)) - ScrollView { VStack(spacing: 16) { - // Title field + // Title VStack(alignment: .leading, spacing: 8) { Label("Название", systemImage: "pencil").font(.caption).foregroundColor(Color(hex: "8888aa")) TextField("Что нужно сделать?", text: $title, axis: .vertical) - .lineLimit(1...3) - .foregroundColor(.white) - .padding(14) + .lineLimit(1...3).foregroundColor(.white).padding(14) .background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.07))) } - - // Description field + // Description VStack(alignment: .leading, spacing: 8) { Label("Описание", systemImage: "text.alignleft").font(.caption).foregroundColor(Color(hex: "8888aa")) - TextField("Дополнительные детали...", text: $description, axis: .vertical) - .lineLimit(2...5) - .foregroundColor(.white) - .padding(14) + TextField("Детали...", text: $description, axis: .vertical) + .lineLimit(2...4).foregroundColor(.white).padding(14) .background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.07))) } - - // Priority selector + // Priority VStack(alignment: .leading, spacing: 8) { Label("Приоритет", systemImage: "flag.fill").font(.caption).foregroundColor(Color(hex: "8888aa")) HStack(spacing: 8) { ForEach(priorities, id: \.0) { p in Button(action: { priority = p.0 }) { - Text(p.1) - .font(.caption.bold()) + Text(p.1).font(.caption.bold()) .foregroundColor(priority == p.0 ? .black : Color(hex: p.2)) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background( - RoundedRectangle(cornerRadius: 20) - .fill(priority == p.0 ? Color(hex: p.2) : Color(hex: p.2).opacity(0.15)) - ) + .padding(.horizontal, 12).padding(.vertical, 8) + .background(RoundedRectangle(cornerRadius: 20).fill(priority == p.0 ? Color(hex: p.2) : Color(hex: p.2).opacity(0.15))) } } } } - } - .padding(20) + // Due Date + VStack(alignment: .leading, spacing: 8) { + HStack { + Label("Срок выполнения", systemImage: "calendar").font(.caption).foregroundColor(Color(hex: "8888aa")) + Spacer() + Toggle("", isOn: $hasDueDate).tint(Color(hex: "0D9488")).labelsHidden() + } + if hasDueDate { + DatePicker("", selection: $dueDate, in: Date()..., displayedComponents: .date) + .datePickerStyle(.compact) + .colorInvert() + .colorMultiply(Color(hex: "0D9488")) + } + } + // Icon + 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(.title3) + .frame(width: 44, height: 44) + .background(Circle().fill(selectedIcon == icon ? Color(hex: "0D9488").opacity(0.25) : Color.white.opacity(0.05))) + .overlay(Circle().stroke(selectedIcon == icon ? Color(hex: "0D9488") : Color.clear, lineWidth: 2)) + } + } + } + } + // Color + VStack(alignment: .leading, spacing: 8) { + Label("Цвет", systemImage: "paintpalette").font(.caption).foregroundColor(Color(hex: "8888aa")) + HStack(spacing: 10) { + ForEach(colors, id: \.self) { c in + Button(action: { selectedColor = c }) { + Circle().fill(Color(hex: String(c.dropFirst()))).frame(width: 32, height: 32) + .overlay(Circle().stroke(.white, lineWidth: selectedColor == c ? 2 : 0)) + .scaleEffect(selectedColor == c ? 1.15 : 1.0) + } + } + } + } + }.padding(20) } } } } - + func save() { isLoading = true + let df = DateFormatter(); df.dateFormat = "yyyy-MM-dd" + let dueDateStr = hasDueDate ? df.string(from: dueDate) : nil Task { let req = CreateTaskRequest( title: title, description: description.isEmpty ? nil : description, - priority: priority + priority: priority, + dueDate: dueDateStr, + icon: selectedIcon, + color: selectedColor ) try? await APIService.shared.createTask(token: authManager.token, request: req) await onAdded() diff --git a/PulseHealth/Views/Tracker/TrackerView.swift b/PulseHealth/Views/Tracker/TrackerView.swift new file mode 100644 index 0000000..6229b0a --- /dev/null +++ b/PulseHealth/Views/Tracker/TrackerView.swift @@ -0,0 +1,701 @@ +import SwiftUI +import Charts + +// MARK: - TrackerView + +struct TrackerView: View { + @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(.horizontal) + .padding(.top, 16) + .padding(.bottom, 12) + + Picker("", selection: $selectedTab) { + Text("Привычки").tag(0) + Text("Задачи").tag(1) + Text("Статистика").tag(2) + } + .pickerStyle(.segmented) + .padding(.horizontal) + .padding(.bottom, 12) + + switch selectedTab { + case 0: HabitListView() + case 1: TaskListView() + default: StatisticsView() + } + } + } + } +} + +// MARK: - HabitListView + +struct HabitListView: View { + @EnvironmentObject var authManager: AuthManager + @State private var habits: [Habit] = [] + @State private var isLoading = true + @State private var showAddHabit = false + @State private var showArchived = false + @State private var errorMsg: String? + @State private var showError = false + + var activeHabits: [Habit] { habits.filter { $0.isArchived != true } } + var archivedHabits: [Habit] { habits.filter { $0.isArchived == true } } + + var body: some View { + ZStack(alignment: .bottomTrailing) { + Group { + if isLoading { + ProgressView().tint(Color(hex: "0D9488")).padding(.top, 40) + Spacer() + } else if activeHabits.isEmpty { + VStack { EmptyState(icon: "flame", text: "Нет активных привычек"); Spacer() } + } else { + List { + ForEach(activeHabits) { habit in + HabitTrackerRow(habit: habit) { await toggleHabit(habit) } + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 3, leading: 16, bottom: 3, trailing: 16)) + } + .onDelete { idx in + let toDelete = idx.map { activeHabits[$0] } + Task { + for h in toDelete { + try? await APIService.shared.deleteHabit(token: authManager.token, id: h.id) + } + await loadHabits(refresh: true) + } + } + + if !archivedHabits.isEmpty { + Section(header: Text("Архив").foregroundColor(Color(hex: "8888aa"))) { + ForEach(archivedHabits) { habit in + HabitTrackerRow(habit: habit, isArchived: true) {} + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } + } + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + } + } + .refreshable { await loadHabits(refresh: true) } + + Button(action: { showAddHabit = true }) { + ZStack { + Circle() + .fill(LinearGradient(colors: [Color(hex: "0D9488"), Color(hex: "14b8a6")], startPoint: .topLeading, endPoint: .bottomTrailing)) + .frame(width: 56, height: 56) + .shadow(color: Color(hex: "0D9488").opacity(0.4), radius: 8, y: 4) + Image(systemName: "plus").font(.title2.bold()).foregroundColor(.white) + } + } + .padding(.bottom, 90) + .padding(.trailing, 20) + } + .task { await loadHabits() } + .sheet(isPresented: $showAddHabit) { + AddHabitView(isPresented: $showAddHabit) { await loadHabits(refresh: true) } + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + .presentationBackground(Color(hex: "0a0a1a")) + } + .alert("Ошибка", isPresented: $showError) { Button("OK", role: .cancel) {} } + message: { Text(errorMsg ?? "") } + } + + func loadHabits(refresh: Bool = false) async { + if !refresh { isLoading = true } + habits = (try? await APIService.shared.getHabits(token: authManager.token, includeArchived: true)) ?? [] + isLoading = false + } + + func toggleHabit(_ habit: Habit) async { + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + do { + if habit.completedToday == true { + let logs = try await APIService.shared.getHabitLogs(token: authManager.token, habitId: habit.id, days: 1) + let today = todayStr() + if let log = logs.first(where: { $0.dateOnly == today }) { + try await APIService.shared.unlogHabit(token: authManager.token, habitId: habit.id, logId: log.id) + } + } else { + try await APIService.shared.logHabit(token: authManager.token, id: habit.id) + } + await loadHabits(refresh: true) + } catch { errorMsg = error.localizedDescription; showError = true } + } + + func archiveHabit(_ habit: Habit) async { + var params: [String: Any] = ["is_archived": true] + if let body = try? JSONSerialization.data(withJSONObject: params) { + try? await APIService.shared.updateHabit(token: authManager.token, id: habit.id, body: body) + } + await loadHabits(refresh: true) + } + + func todayStr() -> String { + let df = DateFormatter(); df.dateFormat = "yyyy-MM-dd"; return df.string(from: Date()) + } +} + +// MARK: - HabitTrackerRow + +struct HabitTrackerRow: View { + let habit: Habit + var isArchived: Bool = false + let onToggle: () async -> Void + + var accentColor: Color { Color(hex: habit.accentColorHex.replacingOccurrences(of: "#", with: "")) } + var isDone: Bool { habit.completedToday == true } + + var body: some View { + HStack(spacing: 14) { + ZStack { + Circle().fill(accentColor.opacity(isArchived ? 0.05 : isDone ? 0.3 : 0.15)).frame(width: 44, height: 44) + Text(habit.displayIcon).font(.title3).opacity(isArchived ? 0.4 : 1) + } + VStack(alignment: .leading, spacing: 3) { + Text(habit.name) + .font(.callout.weight(.medium)) + .foregroundColor(isArchived ? Color(hex: "8888aa") : .white) + HStack(spacing: 8) { + Text(habit.frequencyLabel).font(.caption).foregroundColor(Color(hex: "8888aa")) + if let streak = habit.currentStreak, streak > 0 { + HStack(spacing: 2) { + Text("🔥").font(.caption2) + Text("\(streak) дн.").font(.caption).foregroundColor(Color(hex: "ffa502")) + } + } + } + } + Spacer() + if !isArchived { + Button(action: { Task { await onToggle() } }) { + Image(systemName: isDone ? "checkmark.circle.fill" : "circle") + .font(.title2).foregroundColor(isDone ? accentColor : Color(hex: "8888aa")) + } + } else { + Text("Архив").font(.caption).foregroundColor(Color(hex: "8888aa")) + .padding(.horizontal, 8).padding(.vertical, 4) + .background(RoundedRectangle(cornerRadius: 6).fill(Color.white.opacity(0.06))) + } + } + .padding(14) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(isDone && !isArchived ? accentColor.opacity(0.08) : Color.white.opacity(0.04)) + .overlay(RoundedRectangle(cornerRadius: 16).stroke(isDone && !isArchived ? accentColor.opacity(0.3) : Color.clear, lineWidth: 1)) + ) + } +} + +// MARK: - TaskListView + +struct TaskListView: View { + @EnvironmentObject var authManager: AuthManager + @State private var tasks: [PulseTask] = [] + @State private var isLoading = true + @State private var filter: TaskFilter = .active + @State private var showAddTask = false + @State private var errorMsg: String? + @State private var showError = false + + enum TaskFilter: String, CaseIterable { + case active = "Активные" + case completed = "Выполненные" + } + + var filtered: [PulseTask] { + switch filter { + case .active: return tasks.filter { !$0.completed }.sorted { ($0.priority ?? 0) > ($1.priority ?? 0) } + case .completed: return tasks.filter { $0.completed } + } + } + + var body: some View { + ZStack(alignment: .bottomTrailing) { + VStack(spacing: 0) { + Picker("", selection: $filter) { + ForEach(TaskFilter.allCases, id: \.self) { Text($0.rawValue).tag($0) } + } + .pickerStyle(.segmented) + .padding(.horizontal) + .padding(.bottom, 8) + + if isLoading { + ProgressView().tint(Color(hex: "0D9488")).padding(.top, 40) + Spacer() + } else if filtered.isEmpty { + VStack { EmptyState(icon: "checkmark.circle", text: filter == .active ? "Нет активных задач" : "Нет выполненных задач"); Spacer() } + } else { + List { + ForEach(filtered) { task in + TrackerTaskRow(task: task, onToggle: { await toggleTask(task) }) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 2, leading: 16, bottom: 2, trailing: 16)) + } + .onDelete { idx in + let toDelete = idx.map { filtered[$0] } + Task { + for t in toDelete { try? await APIService.shared.deleteTask(token: authManager.token, id: t.id) } + await loadTasks(refresh: true) + } + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + } + } + .refreshable { await loadTasks(refresh: true) } + + Button(action: { showAddTask = true }) { + ZStack { + Circle() + .fill(LinearGradient(colors: [Color(hex: "0D9488"), Color(hex: "14b8a6")], startPoint: .topLeading, endPoint: .bottomTrailing)) + .frame(width: 56, height: 56) + .shadow(color: Color(hex: "0D9488").opacity(0.4), radius: 8, y: 4) + Image(systemName: "plus").font(.title2.bold()).foregroundColor(.white) + } + } + .padding(.bottom, 90) + .padding(.trailing, 20) + } + .task { await loadTasks() } + .sheet(isPresented: $showAddTask) { + AddTaskView(isPresented: $showAddTask) { await loadTasks(refresh: true) } + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + .presentationBackground(Color(hex: "0a0a1a")) + } + .alert("Ошибка", isPresented: $showError) { Button("OK", role: .cancel) {} } + message: { Text(errorMsg ?? "") } + } + + func loadTasks(refresh: Bool = false) async { + if !refresh { isLoading = true } + tasks = (try? await APIService.shared.getTasks(token: authManager.token)) ?? [] + isLoading = false + } + + func toggleTask(_ task: PulseTask) async { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + do { + if task.completed { + try await APIService.shared.uncompleteTask(token: authManager.token, id: task.id) + } else { + try await APIService.shared.completeTask(token: authManager.token, id: task.id) + } + await loadTasks(refresh: true) + } catch { errorMsg = error.localizedDescription; showError = true } + } +} + +// MARK: - TrackerTaskRow + +struct TrackerTaskRow: View { + let task: PulseTask + let onToggle: () async -> Void + + var body: some View { + HStack(spacing: 12) { + Button(action: { Task { await onToggle() } }) { + Image(systemName: task.completed ? "checkmark.circle.fill" : "circle") + .font(.title3) + .foregroundColor(task.completed ? Color(hex: "0D9488") : Color(hex: "8888aa")) + } + VStack(alignment: .leading, spacing: 3) { + Text(task.title) + .strikethrough(task.completed) + .foregroundColor(task.completed ? Color(hex: "8888aa") : .white) + .font(.callout) + HStack(spacing: 6) { + if let p = task.priority, p > 0 { + Text(task.priorityDisplayName) + .font(.caption2) + .foregroundColor(Color(hex: task.priorityColor)) + .padding(.horizontal, 6).padding(.vertical, 2) + .background(RoundedRectangle(cornerRadius: 4).fill(Color(hex: task.priorityColor).opacity(0.15))) + } + if let due = task.dueDateFormatted { + Text(due).font(.caption2).foregroundColor(task.isOverdue ? Color(hex: "ff4757") : Color(hex: "8888aa")) + } + if task.isRecurring == true { + Image(systemName: "arrow.clockwise").font(.caption2).foregroundColor(Color(hex: "8888aa")) + } + } + } + Spacer() + } + .padding(12) + .background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.05))) + } +} + +// MARK: - StatisticsView + +struct StatisticsView: View { + @EnvironmentObject var authManager: AuthManager + @State private var habits: [Habit] = [] + @State private var selectedHabitId: Int? = nil + @State private var habitStats: HabitStats? + @State private var habitLogs: [HabitLog] = [] + @State private var isLoading = true + + var selectedHabit: Habit? { habits.first { $0.id == selectedHabitId } } + + var heatmapData: [String: Int] { + var counts: [String: Int] = [:] + for log in habitLogs { counts[log.dateOnly, default: 0] += 1 } + return counts + } + + var completionPoints: [CompletionDataPoint] { + let df = DateFormatter(); df.dateFormat = "yyyy-MM-dd" + let cal = Calendar.current + return (0..<30).reversed().compactMap { i -> CompletionDataPoint? in + guard let date = cal.date(byAdding: .day, value: -i, to: Date()) else { return nil } + let key = df.string(from: date) + let count = heatmapData[key] ?? 0 + let total = habits.filter { $0.isArchived != true }.count + let rate = total > 0 ? Double(min(count, total)) / Double(total) : 0 + let label = cal.component(.day, from: date) == 1 || i == 0 ? df.string(from: date).prefix(7).description : "\(cal.component(.day, from: date))" + return CompletionDataPoint(date: date, rate: rate, label: label) + } + } + + var body: some View { + ScrollView { + VStack(spacing: 20) { + // Habit Picker + VStack(alignment: .leading, spacing: 8) { + Text("Привычка").font(.caption).foregroundColor(Color(hex: "8888aa")).padding(.horizontal) + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + Button(action: { selectedHabitId = nil; Task { await loadLogs() } }) { + Text("Все") + .font(.caption.bold()) + .foregroundColor(selectedHabitId == nil ? .black : .white) + .padding(.horizontal, 14).padding(.vertical, 8) + .background(RoundedRectangle(cornerRadius: 20).fill(selectedHabitId == nil ? Color(hex: "0D9488") : Color.white.opacity(0.08))) + } + ForEach(habits.filter { $0.isArchived != true }) { habit in + Button(action: { selectedHabitId = habit.id; Task { await loadLogs() } }) { + HStack(spacing: 4) { + Text(habit.displayIcon).font(.caption) + Text(habit.name).font(.caption.bold()) + } + .foregroundColor(selectedHabitId == habit.id ? .black : .white) + .padding(.horizontal, 12).padding(.vertical, 8) + .background(RoundedRectangle(cornerRadius: 20).fill(selectedHabitId == habit.id ? Color(hex: "0D9488") : Color.white.opacity(0.08))) + } + } + } + .padding(.horizontal) + } + } + + if isLoading { + ProgressView().tint(Color(hex: "0D9488")) + } else { + // Stat Cards + if let stats = habitStats { + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) { + StatCardSmall(icon: "flame.fill", value: "\(stats.currentStreak)", label: "Текущий streak", color: "ffa502") + StatCardSmall(icon: "trophy.fill", value: "\(stats.longestStreak)", label: "Лучший streak", color: "f59e0b") + StatCardSmall(icon: "checkmark.circle.fill", value: "\(stats.thisMonth)", label: "В этом месяце", color: "0D9488") + StatCardSmall(icon: "chart.line.uptrend.xyaxis", value: "\(stats.completionPercent)%", label: "Completion rate", color: "6366f1") + } + .padding(.horizontal) + } + + // Heatmap + VStack(alignment: .leading, spacing: 8) { + Text("Активность (84 дня)") + .font(.subheadline.bold()).foregroundColor(.white).padding(.horizontal) + HeatmapView(data: heatmapData) + .padding(.horizontal) + } + + // Line Chart — Completion Rate 30 days + if !completionPoints.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("Completion Rate (30 дней)") + .font(.subheadline.bold()).foregroundColor(.white).padding(.horizontal) + Chart(completionPoints) { point in + AreaMark( + x: .value("Дата", point.date), + y: .value("Rate", point.rate) + ) + .foregroundStyle(LinearGradient(colors: [Color(hex: "0D9488").opacity(0.4), Color.clear], startPoint: .top, endPoint: .bottom)) + LineMark( + x: .value("Дата", point.date), + y: .value("Rate", point.rate) + ) + .foregroundStyle(Color(hex: "0D9488")) + .lineStyle(StrokeStyle(lineWidth: 2)) + } + .chartYScale(domain: 0...1) + .chartYAxis { + AxisMarks(values: [0, 0.25, 0.5, 0.75, 1.0]) { val in + AxisGridLine().foregroundStyle(Color.white.opacity(0.08)) + AxisValueLabel { + if let v = val.as(Double.self) { + Text("\(Int(v * 100))%").font(.caption2).foregroundStyle(Color(hex: "8888aa")) + } + } + } + } + .chartXAxis { + AxisMarks(values: .stride(by: .day, count: 7)) { _ in + AxisGridLine().foregroundStyle(Color.white.opacity(0.05)) + AxisValueLabel(format: .dateTime.day().month()).foregroundStyle(Color(hex: "8888aa")) + } + } + .frame(height: 160) + .padding(.horizontal) + } + .padding(.vertical, 8) + .background(RoundedRectangle(cornerRadius: 16).fill(Color.white.opacity(0.04))) + .padding(.horizontal) + } + + // Bar Chart — Top Habits + if habits.filter({ $0.isArchived != true }).count > 1 { + TopHabitsChart(habits: habits.filter { $0.isArchived != true }) + } + } + Spacer(minLength: 80) + } + .padding(.top, 8) + } + .task { await loadAll() } + } + + func loadAll() async { + isLoading = true + habits = (try? await APIService.shared.getHabits(token: authManager.token)) ?? [] + await loadLogs() + isLoading = false + } + + func loadLogs() async { + if let id = selectedHabitId { + habitStats = try? await APIService.shared.getHabitStats(token: authManager.token, habitId: id) + habitLogs = (try? await APIService.shared.getHabitLogs(token: authManager.token, habitId: id, days: 90)) ?? [] + } else { + habitStats = nil + // Aggregate logs from all habits + var allLogs: [HabitLog] = [] + for habit in habits.filter({ $0.isArchived != true }) { + let logs = (try? await APIService.shared.getHabitLogs(token: authManager.token, habitId: habit.id, days: 90)) ?? [] + allLogs.append(contentsOf: logs) + } + habitLogs = allLogs + // Build aggregate stats + let total = allLogs.count + let month = allLogs.filter { $0.dateOnly >= monthStart() }.count + habitStats = HabitStats(currentStreak: 0, longestStreak: 0, thisMonth: month, totalCompleted: total, completionRate: nil) + } + } + + func monthStart() -> String { + let df = DateFormatter(); df.dateFormat = "yyyy-MM" + return df.string(from: Date()) + "-01" + } +} + +// MARK: - StatCardSmall + +struct StatCardSmall: View { + let icon: String + let value: String + let label: String + let color: String + var body: some View { + HStack(spacing: 12) { + Image(systemName: icon).foregroundColor(Color(hex: color)).font(.title3) + VStack(alignment: .leading, spacing: 2) { + Text(value).font(.headline.bold()).foregroundColor(.white) + Text(label).font(.caption2).foregroundColor(Color(hex: "8888aa")) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + .background(RoundedRectangle(cornerRadius: 14).fill(Color.white.opacity(0.05))) + } +} + +// MARK: - HeatmapView + +struct HeatmapView: View { + let data: [String: Int] + let weeks = 12 + let daysPerWeek = 7 + @State private var selectedDay: String? + + var maxCount: Int { max(data.values.max() ?? 1, 1) } + + var cells: [[String]] { + let cal = Calendar.current + let df = DateFormatter(); df.dateFormat = "yyyy-MM-dd" + let today = Date() + // Build 84 days back + let totalDays = weeks * daysPerWeek + var days: [String] = (0..() + for (ci, col) in cells.enumerated() { + for dayStr in col { + guard !dayStr.isEmpty, let date = df.date(from: dayStr) else { continue } + let cal = Calendar.current + if cal.component(.day, from: date) <= 7 { + let label = mf.string(from: date) + if !seenMonths.contains(label) { seenMonths.insert(label); labels.append((label, ci)) } + } + } + } + return labels + } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + // Month labels + HStack(spacing: 0) { + ForEach(0.. String { + let parts = s.split(separator: "-") + guard parts.count == 3 else { return s } + return "\(parts[2]).\(parts[1]).\(parts[0])" + } +} + +// MARK: - TopHabitsChart + +struct TopHabitsChart: View { + let habits: [Habit] + var sorted: [Habit] { habits.sorted { ($0.currentStreak ?? 0) > ($1.currentStreak ?? 0) }.prefix(5).map { $0 } } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Топ привычек по streak") + .font(.subheadline.bold()).foregroundColor(.white) + Chart(sorted) { habit in + BarMark( + x: .value("Streak", habit.currentStreak ?? 0), + y: .value("Привычка", habit.name) + ) + .foregroundStyle(Color(hex: "0D9488")) + .cornerRadius(4) + } + .chartXAxis { + AxisMarks { _ in + AxisGridLine().foregroundStyle(Color.white.opacity(0.08)) + AxisValueLabel().foregroundStyle(Color(hex: "8888aa")) + } + } + .chartYAxis { + AxisMarks { v in + AxisValueLabel().foregroundStyle(Color(hex: "8888aa")) + } + } + .frame(height: 180) + } + .padding(16) + .background(RoundedRectangle(cornerRadius: 16).fill(Color.white.opacity(0.04))) + .padding(.horizontal) + } +} diff --git a/project.yml b/project.yml index 67f4bb7..90c2aab 100644 --- a/project.yml +++ b/project.yml @@ -14,6 +14,7 @@ targets: SWIFT_VERSION: 5.9 INFOPLIST_FILE: PulseHealth/Info.plist CODE_SIGN_STYLE: Automatic + CODE_SIGN_ENTITLEMENTS: PulseHealth/PulseHealth.entitlements entitlements: path: PulseHealth/PulseHealth.entitlements capabilities: