diff --git a/medmap/AppState.swift b/medmap/AppState.swift index 836056e..20d653e 100755 --- a/medmap/AppState.swift +++ b/medmap/AppState.swift @@ -10,6 +10,8 @@ enum AppState: Equatable, Hashable { return true case (.login, .login): return true + case (.signup, .signup): + return true case (.dashboard, .dashboard): return true @@ -20,5 +22,6 @@ enum AppState: Equatable, Hashable { case splashScreen case login + case signup case dashboard } diff --git a/medmap/ViewModels/LoginViewModel.swift b/medmap/ViewModels/LoginViewModel.swift index a79a10e..5b6a334 100644 --- a/medmap/ViewModels/LoginViewModel.swift +++ b/medmap/ViewModels/LoginViewModel.swift @@ -25,6 +25,10 @@ final class LoginViewModel: ViewModel { !email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !password.isEmpty && !isLoading } + func showSignup() { + appStateManager.navigateTo(state: .signup) + } + func login() async { guard canSubmit else { return } diff --git a/medmap/ViewModels/SignupViewModel.swift b/medmap/ViewModels/SignupViewModel.swift new file mode 100644 index 0000000..86f53cb --- /dev/null +++ b/medmap/ViewModels/SignupViewModel.swift @@ -0,0 +1,59 @@ +// +// SignupViewModel.swift +// medmap +// + +import Foundation + +@MainActor +final class SignupViewModel: ViewModel { + + @Published var email = "" + @Published var password = "" + @Published var confirmationPassword = "" + @Published private(set) var errorMessage: String? + + private let api: MedMapAPI + + init(api: MedMapAPI = MedMapAPI()) { + self.api = api + super.init() + } + + var canSubmit: Bool { + !email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + password.count >= 8 && + password == confirmationPassword && + !isLoading + } + + func signup() async { + guard canSubmit else { return } + + errorMessage = nil + isLoading = true + defer { isLoading = false } + + do { + let response = try await api.signup( + email: email.trimmingCharacters(in: .whitespacesAndNewlines), + password: password, + role: .patient + ) + + guard response.role == .patient else { + errorMessage = "Nao foi possivel criar uma conta de doente." + return + } + + appStateManager.navigateTo(state: .dashboard) + } catch { + errorMessage = "Nao foi possivel criar a conta. Confirme os dados ou tente outro email." + fluxlog("Patient signup failed: \(error.localizedDescription)") + } + } + + func showLogin() { + appStateManager.navigateBack() + } +} \ No newline at end of file diff --git a/medmap/Views/LoginView.swift b/medmap/Views/LoginView.swift index cc32f98..64a72ad 100644 --- a/medmap/Views/LoginView.swift +++ b/medmap/Views/LoginView.swift @@ -26,12 +26,17 @@ struct LoginView: View { private var formPanel: some View { VStack(spacing: 0) { - Image("splashLogo") - .resizable() - .scaledToFit() - .frame(width: 120, height: 76) - .accessibilityHidden(true) - .padding(.top, 32) + HStack { + Image("splashLogo") + .resizable() + .scaledToFit() + .frame(width: 120, height: 76) + .accessibilityHidden(true) + + Spacer() + } + .padding(.top, 32) + .padding(.horizontal, 32) Spacer() @@ -93,7 +98,9 @@ struct LoginView: View { Spacer() VStack(spacing: 8) { - Text("Não tem conta? Criar conta") + Button("Não tem conta? Criar conta") { + viewModel.showSignup() + } .font(.jakartaRegular14) .foregroundStyle(.white.opacity(0.88)) diff --git a/medmap/Views/SignupView.swift b/medmap/Views/SignupView.swift new file mode 100644 index 0000000..bded703 --- /dev/null +++ b/medmap/Views/SignupView.swift @@ -0,0 +1,171 @@ +// +// SignupView.swift +// medmap +// + +import SwiftUI + +struct SignupView: View { + + @StateObject private var viewModel = SignupViewModel() + @FocusState private var focusedField: Field? + + private enum Field { + case email + case password + case confirmationPassword + } + + var body: some View { + ZStack { + Color.appBackground.ignoresSafeArea() + + VStack(spacing: 0) { + HStack { + Image("splashLogo") + .resizable() + .scaledToFit() + .frame(width: 120, height: 76) + .accessibilityHidden(true) + Spacer() + } + .padding(.top, 32) + .padding(.horizontal, 32) + + Spacer() + + VStack(alignment: .leading, spacing: 24) { + Text("Criar nova conta") + .font(.jakartaBold24) + .foregroundStyle(.white) + + VStack(spacing: 14) { + field( + placeholder: "Email", + icon: "envelope", + text: $viewModel.email, + focusedField: .email, + contentType: .emailAddress, + keyboardType: .emailAddress + ) + field( + placeholder: "Palavra-passe (min. 8 caracteres)", + icon: "lock", + text: $viewModel.password, + focusedField: .password, + contentType: .newPassword, + isSecure: true + ) + field( + placeholder: "Confirmar palavra-passe", + icon: "lock", + text: $viewModel.confirmationPassword, + focusedField: .confirmationPassword, + contentType: .newPassword, + isSecure: true, + submitLabel: .go + ) { + Task { await viewModel.signup() } + } + } + + if let errorMessage = viewModel.errorMessage { + Text(errorMessage) + .font(.jakartaRegular14) + .foregroundStyle(Color.loginErrorText) + .frame(maxWidth: .infinity) + .multilineTextAlignment(.center) + } + + Button { + focusedField = nil + Task { await viewModel.signup() } + } label: { + Group { + if viewModel.isLoading { + ProgressView().tint(Color.loginButtonText) + } else { + Text("Registar").font(.jakartaBold16) + } + } + .frame(maxWidth: .infinity) + .frame(height: 46) + } + .foregroundStyle(Color.loginButtonText) + .background(Color.loginButtonBackground) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .disabled(!viewModel.canSubmit) + .opacity(viewModel.canSubmit ? 1 : 0.55) + } + .padding(.horizontal, 32) + + Spacer() + + VStack(spacing: 8) { + Button("Ja tem conta? Entrar") { + viewModel.showLogin() + } + .font(.jakartaRegular14) + .foregroundStyle(.white.opacity(0.88)) + + Text("MedMap") + .font(.jakartaRegular12) + .foregroundStyle(.white.opacity(0.6)) + } + .padding(.bottom, 24) + } + } + .navigationBarHidden(true) + } + + private func field( + placeholder: String, + icon: String, + text: Binding, + focusedField: Field, + contentType: UITextContentType, + keyboardType: UIKeyboardType = .default, + isSecure: Bool = false, + submitLabel: SubmitLabel = .next, + onSubmit: @escaping () -> Void = {} + ) -> some View { + HStack(spacing: 10) { + Image(systemName: icon) + .foregroundStyle(.white) + .frame(width: 18) + ZStack(alignment: .leading) { + if text.wrappedValue.isEmpty { + Text(placeholder).foregroundStyle(Color.loginPlaceholder) + } + if isSecure { + SecureField("", text: text) + } else { + TextField("", text: text) + .keyboardType(keyboardType) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + } + .foregroundStyle(.white) + .tint(.white) + .font(.jakartaRegular16) + .textContentType(contentType) + .focused($focusedField, equals: focusedField) + .submitLabel(submitLabel) + .onSubmit(onSubmit) + } + .padding(.horizontal, 14) + .frame(height: 46) + .background(Color.loginFieldBackground) + .overlay { + RoundedRectangle(cornerRadius: 8) + .stroke(Color.loginFieldBorder, lineWidth: 1) + } + .clipShape(RoundedRectangle(cornerRadius: 8)) + .accessibilityLabel(placeholder) + } +} + +#Preview { + SignupView() +} \ No newline at end of file diff --git a/medmap/medmapApp.swift b/medmap/medmapApp.swift index c1e9f9f..46f7332 100644 --- a/medmap/medmapApp.swift +++ b/medmap/medmapApp.swift @@ -93,6 +93,13 @@ struct medmapApp: App { .transition(.opacity) .animation(.easeInOut, value: 10) + case .signup: + + SignupView() + .navigationBarHidden(true) + .transition(.opacity) + .animation(.easeInOut, value: 10) + case .dashboard: DashboardView() diff --git a/src/main/java/com/voidsoftware/map02/controllers/RestAuthController.java b/src/main/java/com/voidsoftware/map02/controllers/RestAuthController.java index d76d81e..ded703d 100644 --- a/src/main/java/com/voidsoftware/map02/controllers/RestAuthController.java +++ b/src/main/java/com/voidsoftware/map02/controllers/RestAuthController.java @@ -77,9 +77,20 @@ public class RestAuthController { }) @SecurityRequirements @PostMapping("/signup") - public ResponseEntity signup(@Valid @RequestBody SignupRequest request) { + public ResponseEntity signup(@Valid @RequestBody SignupRequest request, + HttpServletRequest httpRequest) { AppUser created = appUserService.register(request); + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(new UsernamePasswordAuthenticationToken( + created, + null, + created.getAuthorities() + )); + SecurityContextHolder.setContext(context); + HttpSession session = httpRequest.getSession(true); + session.setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, context); + HttpHeaders headers = new HttpHeaders(); headers.set("X-User-Id", created.getId().toString()); headers.set("X-User-Role", created.getRole().name());