diff --git a/PulseHealth/Services/APIService.swift b/PulseHealth/Services/APIService.swift index 9a78bb9..5872c99 100644 --- a/PulseHealth/Services/APIService.swift +++ b/PulseHealth/Services/APIService.swift @@ -58,6 +58,27 @@ class APIService { } } + func register(email: String, password: String, name: String) async throws -> LoginResponse { + let url = URL(string: "\(baseURL)/api/auth/register")! + let body = try JSONEncoder().encode(["email": email, "password": password, "name": name]) + let req = makeRequest(url: url, method: "POST", body: body) + let (data, response) = try await URLSession.shared.data(for: req) + guard let httpResponse = response as? HTTPURLResponse else { throw APIError.networkError("Нет ответа") } + if httpResponse.statusCode == 409 { throw APIError.serverError(409, "Email уже занят") } + if httpResponse.statusCode != 200 && httpResponse.statusCode != 201 { + let msg = String(data: data, encoding: .utf8) ?? "Unknown" + throw APIError.serverError(httpResponse.statusCode, msg) + } + return try JSONDecoder().decode(LoginResponse.self, from: data) + } + + func forgotPassword(email: String) async throws { + let url = URL(string: "\(baseURL)/api/auth/forgot-password")! + let body = try JSONEncoder().encode(["email": email]) + let req = makeRequest(url: url, method: "POST", body: body) + _ = try await URLSession.shared.data(for: req) + } + func getProfile(token: String) async throws -> ProfileResponse { let url = URL(string: "\(baseURL)/api/profile")! let req = makeRequest(url: url, token: token) @@ -84,4 +105,19 @@ class APIService { } return try JSONDecoder().decode(LatestHealthResponse.self, from: data) } + + func getHeatmap(token: String, days: Int = 7) async throws -> [HeatmapEntry] { + let url = URL(string: "\(baseURL)/api/health/heatmap?days=\(days)")! + let req = makeRequest(url: url, token: token) + let (data, response) = try await URLSession.shared.data(for: req) + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + throw APIError.networkError("Heatmap недоступен") + } + // Try array first, then wrapped response + if let entries = try? JSONDecoder().decode([HeatmapEntry].self, from: data) { + return entries + } + let wrapped = try JSONDecoder().decode(HeatmapResponse.self, from: data) + return wrapped.data + } } diff --git a/PulseHealth/Views/LoginView.swift b/PulseHealth/Views/LoginView.swift index 496dcf7..f24c866 100644 --- a/PulseHealth/Views/LoginView.swift +++ b/PulseHealth/Views/LoginView.swift @@ -2,11 +2,16 @@ import SwiftUI struct LoginView: View { @EnvironmentObject var authManager: AuthManager - @State private var email = "daniilklimov25@gmail.com" + @State private var email = "" @State private var password = "" + @State private var name = "" @State private var isLoading = false @State private var errorMessage = "" @State private var showPassword = false + @State private var isRegistering = false + @State private var forgotEmail = "" + @State private var showForgotSheet = false + @State private var forgotSent = false var body: some View { ZStack { @@ -18,11 +23,20 @@ struct LoginView: View { VStack(spacing: 8) { Text("🫀").font(.system(size: 60)) Text("Pulse Health").font(.largeTitle.bold()).foregroundColor(.white) - Text("Персональный дашборд здоровья") + Text(isRegistering ? "Создать аккаунт" : "Персональный дашборд здоровья") .font(.subheadline).foregroundColor(.white.opacity(0.6)) }.padding(.top, 60) VStack(spacing: 16) { + if isRegistering { + TextField("Имя", text: $name) + .autocorrectionDisabled() + .padding() + .background(Color.white.opacity(0.1)) + .cornerRadius(12) + .foregroundColor(.white) + } + TextField("Email", text: $email) .keyboardType(.emailAddress) .autocapitalization(.none) @@ -32,7 +46,6 @@ struct LoginView: View { .cornerRadius(12) .foregroundColor(.white) - // Password field with show/hide toggle HStack { Group { if showPassword { @@ -44,7 +57,6 @@ struct LoginView: View { } } .foregroundColor(.white) - Button(action: { showPassword.toggle() }) { Image(systemName: showPassword ? "eye.slash.fill" : "eye.fill") .foregroundColor(.white.opacity(0.6)) @@ -59,14 +71,14 @@ struct LoginView: View { .foregroundColor(.red) .font(.caption) .multilineTextAlignment(.center) - .padding(.horizontal) } - Button(action: login) { + Button(action: isRegistering ? register : login) { if isLoading { ProgressView().tint(.black) } else { - Text("Войти").font(.headline).foregroundColor(.black) + Text(isRegistering ? "Зарегистрироваться" : "Войти") + .font(.headline).foregroundColor(.black) } } .frame(maxWidth: .infinity) @@ -74,39 +86,118 @@ struct LoginView: View { .background(Color(hex: "00d4aa")) .cornerRadius(12) .disabled(isLoading || email.isEmpty || password.isEmpty) + + // Forgot password (only on login) + if !isRegistering { + Button(action: { showForgotSheet = true }) { + Text("Забыли пароль?") + .font(.footnote) + .foregroundColor(Color(hex: "00d4aa")) + } + } + + // Toggle login/register + HStack { + Text(isRegistering ? "Уже есть аккаунт?" : "Нет аккаунта?") + .foregroundColor(.white.opacity(0.6)) + .font(.footnote) + Button(action: { + isRegistering.toggle() + errorMessage = "" + }) { + Text(isRegistering ? "Войти" : "Зарегистрироваться") + .font(.footnote.bold()) + .foregroundColor(Color(hex: "00d4aa")) + } + } } .padding(.horizontal, 24) Spacer() } + .sheet(isPresented: $showForgotSheet) { + ForgotPasswordView(isPresented: $showForgotSheet) + } } } func login() { - isLoading = true - errorMessage = "" + isLoading = true; errorMessage = "" Task { do { let response = try await APIService.shared.login(email: email.trimmingCharacters(in: .whitespaces), password: password) let profile = try? await APIService.shared.getProfile(token: response.token) await MainActor.run { - authManager.login( - token: response.token, - name: response.user.name, - apiKey: profile?.apiKey ?? "" - ) + authManager.login(token: response.token, name: response.user.name, apiKey: profile?.apiKey ?? "") } } catch let error as APIError { - await MainActor.run { - errorMessage = error.errorDescription ?? "Ошибка" - isLoading = false - } + await MainActor.run { errorMessage = error.errorDescription ?? "Ошибка"; isLoading = false } } catch { + await MainActor.run { errorMessage = "Ошибка: \(error.localizedDescription)"; isLoading = false } + } + } + } + + func register() { + isLoading = true; errorMessage = "" + Task { + do { + let response = try await APIService.shared.register(email: email.trimmingCharacters(in: .whitespaces), password: password, name: name) + let profile = try? await APIService.shared.getProfile(token: response.token) await MainActor.run { - errorMessage = "Ошибка: \(error.localizedDescription)" - isLoading = false + authManager.login(token: response.token, name: response.user.name, apiKey: profile?.apiKey ?? "") } + } catch let error as APIError { + await MainActor.run { errorMessage = error.errorDescription ?? "Ошибка"; isLoading = false } + } catch { + await MainActor.run { errorMessage = "Ошибка: \(error.localizedDescription)"; isLoading = false } } } } } + +struct ForgotPasswordView: View { + @Binding var isPresented: Bool + @State private var email = "" + @State private var isSent = false + @State private var isLoading = false + + var body: some View { + ZStack { + Color(hex: "0a0a1a").ignoresSafeArea() + VStack(spacing: 24) { + Text("Сброс пароля").font(.title2.bold()).foregroundColor(.white) + if isSent { + VStack(spacing: 12) { + Text("✅").font(.system(size: 50)) + Text("Письмо отправлено!\nПроверьте \(email)") + .foregroundColor(.white).multilineTextAlignment(.center) + } + Button("Закрыть") { isPresented = false } + .padding().foregroundColor(Color(hex: "00d4aa")) + } else { + Text("Введите email и мы отправим ссылку для сброса пароля") + .foregroundColor(.white.opacity(0.6)).multilineTextAlignment(.center) + TextField("Email", text: $email) + .keyboardType(.emailAddress).autocapitalization(.none) + .padding().background(Color.white.opacity(0.1)).cornerRadius(12).foregroundColor(.white) + Button(action: send) { + if isLoading { ProgressView().tint(.black) } + else { Text("Отправить").font(.headline).foregroundColor(.black) } + } + .frame(maxWidth: .infinity).padding() + .background(Color(hex: "00d4aa")).cornerRadius(12).disabled(email.isEmpty || isLoading) + Button("Отмена") { isPresented = false }.foregroundColor(.white.opacity(0.5)) + } + }.padding(32) + } + } + + func send() { + isLoading = true + Task { + try? await APIService.shared.forgotPassword(email: email) + await MainActor.run { isSent = true; isLoading = false } + } + } +}