login page
This commit is contained in:
parent
3c3d3e547e
commit
e095e0d0bb
@ -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
|
||||
}
|
||||
|
||||
51
medmap/ViewModels/LoginViewModel.swift
Normal file
51
medmap/ViewModels/LoginViewModel.swift
Normal 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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
229
medmap/Views/LoginView.swift
Normal file
229
medmap/Views/LoginView.swift
Normal 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()
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user