From e095e0d0bb095fc823c9cbb4b66cc6109a7223dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Ara=C3=BAjo?= Date: Tue, 15 Sep 2026 14:49:39 +0100 Subject: [PATCH] login page --- medmap/AppState.swift | 9 +- medmap/ViewModels/LoginViewModel.swift | 51 ++++++ medmap/Views/LoginView.swift | 229 +++++++++++++++++++++++++ medmap/Views/SplashScreenView.swift | 12 +- medmap/medmapApp.swift | 8 +- 5 files changed, 298 insertions(+), 11 deletions(-) create mode 100644 medmap/ViewModels/LoginViewModel.swift create mode 100644 medmap/Views/LoginView.swift diff --git a/medmap/AppState.swift b/medmap/AppState.swift index b37bb60..53f9ec3 100755 --- a/medmap/AppState.swift +++ b/medmap/AppState.swift @@ -7,11 +7,9 @@ enum AppState: Equatable, Hashable { static func == (lhs: AppState, rhs: AppState) -> Bool { switch (lhs, rhs) { case (.splashScreen, .splashScreen): - return true - - - + case (.login, .login): + return true default: return false @@ -19,6 +17,5 @@ enum AppState: Equatable, Hashable { } case splashScreen - - + case login } diff --git a/medmap/ViewModels/LoginViewModel.swift b/medmap/ViewModels/LoginViewModel.swift new file mode 100644 index 0000000..fe69d56 --- /dev/null +++ b/medmap/ViewModels/LoginViewModel.swift @@ -0,0 +1,51 @@ +// +// LoginViewModel.swift +// medmap +// + +import Foundation + +@MainActor +final class LoginViewModel: ViewModel { + + @Published var email = "" + @Published var password = "" + @Published private(set) var errorMessage: String? + @Published private(set) var authenticatedPatient: LoginResponse? + + private let api: MedMapAPI + + init(api: MedMapAPI = MedMapAPI()) { + self.api = api + super.init() + } + + var canSubmit: Bool { + !email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !password.isEmpty && !isLoading + } + + func login() async { + guard canSubmit else { return } + + errorMessage = nil + isLoading = true + defer { isLoading = false } + + do { + let response = try await api.login( + email: email.trimmingCharacters(in: .whitespacesAndNewlines), + password: password + ) + + guard response.role == .patient else { + errorMessage = "Esta aplicacao esta disponivel apenas para doentes." + return + } + + authenticatedPatient = response + } catch { + errorMessage = "Nao foi possivel iniciar sessao. Confirme o email e a palavra-passe." + fluxlog("Patient login failed: \(error.localizedDescription)") + } + } +} \ No newline at end of file diff --git a/medmap/Views/LoginView.swift b/medmap/Views/LoginView.swift new file mode 100644 index 0000000..927c07b --- /dev/null +++ b/medmap/Views/LoginView.swift @@ -0,0 +1,229 @@ +// +// LoginView.swift +// medmap +// + +import SwiftUI + +struct LoginView: View { + + @StateObject private var viewModel = LoginViewModel() + @FocusState private var focusedField: Field? + + private enum Field { + case email + case password + } + + var body: some View { + ZStack { + Color.appBackground.ignoresSafeArea() + + GeometryReader { geometry in + let usesTwoColumns = geometry.size.width > 700 + + Group { + if usesTwoColumns { + HStack(spacing: 0) { + brandPanel + .frame(width: geometry.size.width * 0.44) + formPanel + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } else { + formPanel + } + } + } + } + .preferredColorScheme(.light) + } + + private var brandPanel: some View { + ZStack(alignment: .bottomLeading) { + Color(hex: "#315F98") + + Image("splashImage") + .resizable() + .scaledToFit() + .frame(maxWidth: 360) + .padding(40) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 10) { + Image("splashLogo") + .resizable() + .scaledToFit() + .frame(width: 146, height: 96) + .accessibilityHidden(true) + Text("MedMap") + .font(.jakartaBold24) + .foregroundStyle(.white) + Text("Acompanhamento simples da sua medicacao.") + .font(.jakartaRegular16) + .foregroundStyle(.white.opacity(0.84)) + .fixedSize(horizontal: false, vertical: true) + } + .padding(44) + } + .ignoresSafeArea() + } + + private var formPanel: some View { + VStack(spacing: 0) { + Spacer(minLength: 32) + + VStack(alignment: .leading, spacing: 28) { + VStack(alignment: .leading, spacing: 10) { + if UIDevice.current.userInterfaceIdiom == .phone { + Image("splashLogo") + .resizable() + .scaledToFit() + .frame(width: 120, height: 76) + .accessibilityHidden(true) + } + + Text("Bem-vindo de volta") + .font(.jakartaBold24) + .foregroundStyle(Color.appTextPrimary) + Text("Entre na sua conta de doente para consultar o seu plano de medicacao.") + .font(.jakartaRegular16) + .foregroundStyle(Color.appTextPrimary.opacity(0.68)) + .fixedSize(horizontal: false, vertical: true) + } + + VStack(spacing: 18) { + loginField( + title: "Email", + placeholder: "Email", + icon: "envelope", + text: $viewModel.email, + field: .email, + contentType: .emailAddress, + keyboardType: .emailAddress + ) + + passwordField + } + + if let errorMessage = viewModel.errorMessage { + Text(errorMessage) + .font(.jakartaRegular14) + .foregroundStyle(.red) + .fixedSize(horizontal: false, vertical: true) + .accessibilityLabel("Erro de autenticacao: \(errorMessage)") + } + + Button { + focusedField = nil + Task { + await viewModel.login() + } + } label: { + Group { + if viewModel.isLoading { + ProgressView() + .tint(.white) + } else { + Text("Entrar") + .font(.jakartaBold16) + } + } + .frame(maxWidth: .infinity) + .frame(height: 52) + } + .foregroundStyle(.white) + .background(Color.appBackground) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .disabled(!viewModel.canSubmit) + .opacity(viewModel.canSubmit ? 1 : 0.55) + .accessibilityHint("Inicia sessao na conta de doente") + } + .frame(maxWidth: 430) + .padding(.horizontal, 32) + + Spacer() + + Text("MedMap") + .font(.jakartaRegular12) + .foregroundStyle(Color.appTextPrimary.opacity(0.45)) + .padding(.bottom, 24) + } + .background(Color.white) + } + + private var passwordField: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Palavra-passe") + .font(.jakartaMedium14) + .foregroundStyle(Color.appTextPrimary) + + HStack(spacing: 12) { + Image(systemName: "lock") + .foregroundStyle(Color.appBackground) + .frame(width: 20) + SecureField("Palavra-passe", text: $viewModel.password) + .textContentType(.password) + .focused($focusedField, equals: .password) + .submitLabel(.go) + .onSubmit { + Task { + await viewModel.login() + } + } + } + .padding(.horizontal, 16) + .frame(height: 52) + .background(Color.appSecondary) + .overlay { + RoundedRectangle(cornerRadius: 8) + .stroke(Color.appBorder, lineWidth: 1) + } + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + } + + private func loginField( + title: String, + placeholder: String, + icon: String, + text: Binding, + field: Field, + contentType: UITextContentType, + keyboardType: UIKeyboardType + ) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.jakartaMedium14) + .foregroundStyle(Color.appTextPrimary) + + HStack(spacing: 12) { + Image(systemName: icon) + .foregroundStyle(Color.appBackground) + .frame(width: 20) + TextField(placeholder, text: text) + .textContentType(contentType) + .keyboardType(keyboardType) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .focused($focusedField, equals: field) + .submitLabel(.next) + .onSubmit { + focusedField = .password + } + } + .padding(.horizontal, 16) + .frame(height: 52) + .background(Color.appSecondary) + .overlay { + RoundedRectangle(cornerRadius: 8) + .stroke(Color.appBorder, lineWidth: 1) + } + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + } +} + +#Preview { + LoginView() +} \ No newline at end of file diff --git a/medmap/Views/SplashScreenView.swift b/medmap/Views/SplashScreenView.swift index cbce4ed..f13f2c8 100644 --- a/medmap/Views/SplashScreenView.swift +++ b/medmap/Views/SplashScreenView.swift @@ -42,9 +42,15 @@ struct SplashScreenView: View { } .ignoresSafeArea() - .onAppear { - - + .task { + do { + try await Task.sleep(for: .seconds(3)) + } catch { + return + } + + guard !Task.isCancelled else { return } + viewModel.appStateManager.navigateTo(state: .login) } } } diff --git a/medmap/medmapApp.swift b/medmap/medmapApp.swift index f5c46c9..ac491bc 100644 --- a/medmap/medmapApp.swift +++ b/medmap/medmapApp.swift @@ -86,8 +86,12 @@ struct medmapApp: App { .transition(.move(edge: .bottom)) .animation(.easeInOut, value: 10) - - + case .login: + + LoginView() + .navigationBarHidden(true) + .transition(.opacity) + .animation(.easeInOut, value: 10) } } })