login page

This commit is contained in:
Ricardo Araújo 2026-09-15 14:49:39 +01:00
parent 3c3d3e547e
commit e095e0d0bb
5 changed files with 298 additions and 11 deletions

View File

@ -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
}

View File

@ -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)")
}
}
}

View File

@ -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<String>,
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()
}

View File

@ -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)
}
}
}

View File

@ -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)
}
}
})