import Foundation // MARK: - APIError enum APIError: Error, LocalizedError { case unauthorized case networkError(String) case decodingError(String) case serverError(Int, String) var errorDescription: String? { switch self { case .unauthorized: return "Сессия истекла. Войдите снова." case .networkError(let m): return "Ошибка сети: \(m)" case .decodingError(let m): return "Ошибка данных: \(m)" case .serverError(let c, let m): return "Ошибка \(c): \(m)" } } } // MARK: - APIService class APIService { static let shared = APIService() let baseURL = "https://api.digital-home.site" weak var authManager: AuthManager? private func makeRequest(_ path: String, method: String = "GET", token: String? = nil, body: Data? = nil) -> URLRequest { var req = URLRequest(url: URL(string: "\(baseURL)\(path)")!) req.httpMethod = method req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.timeoutInterval = 15 if let t = token { req.setValue("Bearer \(t)", forHTTPHeaderField: "Authorization") } req.httpBody = body return req } private func fetch(_ path: String, method: String = "GET", token: String? = nil, body: Data? = nil) async throws -> T { let req = makeRequest(path, method: method, token: token, body: body) let (data, response) = try await URLSession.shared.data(for: req) guard let http = response as? HTTPURLResponse else { throw APIError.networkError("Нет ответа") } if http.statusCode == 401, let auth = authManager, !auth.refreshToken.isEmpty, !path.contains("/auth/refresh") { // Try to refresh the token do { let refreshResp = try await refreshToken(refreshToken: auth.refreshToken) let newToken = refreshResp.authToken guard !newToken.isEmpty else { throw APIError.unauthorized } await MainActor.run { auth.updateTokens(accessToken: newToken, refreshToken: refreshResp.refreshToken) } // Retry original request with new token let retryReq = makeRequest(path, method: method, token: newToken, body: body) let (retryData, retryResp) = try await URLSession.shared.data(for: retryReq) guard let retryHttp = retryResp as? HTTPURLResponse else { throw APIError.networkError("Нет ответа") } if retryHttp.statusCode == 401 { throw APIError.unauthorized } if retryHttp.statusCode >= 400 { let msg = String(data: retryData, encoding: .utf8) ?? "Unknown" throw APIError.serverError(retryHttp.statusCode, msg) } return try JSONDecoder().decode(T.self, from: retryData) } catch { throw APIError.unauthorized } } if http.statusCode == 401 { throw APIError.unauthorized } if http.statusCode >= 400 { let msg = String(data: data, encoding: .utf8) ?? "Unknown" throw APIError.serverError(http.statusCode, msg) } let decoder = JSONDecoder() do { return try decoder.decode(T.self, from: data) } catch { let snippet = String(data: data, encoding: .utf8)?.prefix(200) ?? "" throw APIError.decodingError("\(error.localizedDescription) | Response: \(snippet)") } } // MARK: - Auth func login(email: String, password: String) async throws -> AuthResponse { let body = try JSONEncoder().encode(LoginRequest(email: email, password: password)) return try await fetch("/auth/login", method: "POST", body: body) } func register(email: String, password: String, name: String) async throws -> AuthResponse { let body = try JSONEncoder().encode(RegisterRequest(email: email, password: password, name: name)) return try await fetch("/auth/register", method: "POST", body: body) } func me(token: String) async throws -> UserInfo { return try await fetch("/auth/me", token: token) } func refreshToken(refreshToken: String) async throws -> RefreshResponse { let body = try JSONEncoder().encode(RefreshRequest(refreshToken: refreshToken)) return try await fetch("/auth/refresh", method: "POST", body: body) } // 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] { return try await fetch("/tasks", token: token) } func getTodayTasks(token: String) async throws -> [PulseTask] { return try await fetch("/tasks/today", token: token) } @discardableResult func createTask(token: String, request: CreateTaskRequest) async throws -> PulseTask { let body = try JSONEncoder().encode(request) 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, includeArchived: Bool = false) async throws -> [Habit] { let query = includeArchived ? "?archived=true" : "" return try await fetch("/habits\(query)", token: token) } @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["date"] = 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, 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, 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 func createTransaction(token: String, request: CreateTransactionRequest) async throws -> FinanceTransaction { let body = try JSONEncoder().encode(request) 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, 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) } @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) } @discardableResult func updateSavingsTransaction(token: String, id: Int, request: CreateSavingsTransactionRequest) async throws -> SavingsTransaction { let body = try JSONEncoder().encode(request) return try await fetch("/savings/transactions/\(id)", method: "PUT", token: token, body: body) } // MARK: - Savings Recurring Plans func getRecurringPlans(token: String, categoryId: Int) async throws -> [SavingsRecurringPlan] { return try await fetch("/savings/categories/\(categoryId)/recurring-plans", token: token) } @discardableResult func createRecurringPlan(token: String, categoryId: Int, request: CreateRecurringPlanRequest) async throws -> SavingsRecurringPlan { let body = try JSONEncoder().encode(request) return try await fetch("/savings/categories/\(categoryId)/recurring-plans", method: "POST", token: token, body: body) } @discardableResult func updateRecurringPlan(token: String, planId: Int, request: UpdateRecurringPlanRequest) async throws -> SavingsRecurringPlan { let body = try JSONEncoder().encode(request) return try await fetch("/savings/recurring-plans/\(planId)", method: "PUT", token: token, body: body) } func deleteRecurringPlan(token: String, planId: Int) async throws { let _: EmptyResponse = try await fetch("/savings/recurring-plans/\(planId)", method: "DELETE", token: token) } // MARK: - Habit Freezes func getHabitFreezes(token: String, habitId: Int) async throws -> [HabitFreeze] { return try await fetch("/habits/\(habitId)/freezes", token: token) } @discardableResult func createHabitFreeze(token: String, habitId: Int, startDate: String, endDate: String, reason: String? = nil) async throws -> HabitFreeze { var params: [String: Any] = ["start_date": startDate, "end_date": endDate] if let r = reason { params["reason"] = r } let body = try JSONSerialization.data(withJSONObject: params) return try await fetch("/habits/\(habitId)/freezes", method: "POST", token: token, body: body) } func deleteHabitFreeze(token: String, habitId: Int, freezeId: Int) async throws { let _: EmptyResponse = try await fetch("/habits/\(habitId)/freezes/\(freezeId)", method: "DELETE", token: token) } // MARK: - Finance Categories CRUD @discardableResult func createFinanceCategory(token: String, request: CreateFinanceCategoryRequest) async throws -> FinanceCategory { let body = try JSONEncoder().encode(request) return try await fetch("/finance/categories", method: "POST", token: token, body: body) } @discardableResult func updateFinanceCategory(token: String, id: Int, request: CreateFinanceCategoryRequest) async throws -> FinanceCategory { let body = try JSONEncoder().encode(request) return try await fetch("/finance/categories/\(id)", method: "PUT", token: token, body: body) } func deleteFinanceCategory(token: String, id: Int) async throws { let _: EmptyResponse = try await fetch("/finance/categories/\(id)", method: "DELETE", token: token) } // MARK: - Finance Transaction Update @discardableResult func updateTransaction(token: String, id: Int, request: CreateTransactionRequest) async throws -> FinanceTransaction { let body = try JSONEncoder().encode(request) return try await fetch("/finance/transactions/\(id)", method: "PUT", token: token, body: body) } } struct EmptyResponse: Codable {}