Not has address
This commit is contained in:
@@ -3,6 +3,7 @@ import SwiftUI
|
||||
enum Route: Hashable {
|
||||
case terms, policy
|
||||
case registration, loginEmail
|
||||
case otp(email: String, phoneNumber: String)
|
||||
}
|
||||
|
||||
struct AuthFlowView: View {
|
||||
@@ -10,7 +11,7 @@ struct AuthFlowView: View {
|
||||
@Binding var selectedTab: MainTab
|
||||
let tokenStore: TokenStore
|
||||
@Binding var appState: AppState
|
||||
|
||||
|
||||
@State var path: [Route] = []
|
||||
|
||||
var body: some View {
|
||||
@@ -27,385 +28,11 @@ struct AuthFlowView: View {
|
||||
case .registration:
|
||||
RegistrationView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState, navigate: { route in path.append(route) })
|
||||
case .loginEmail:
|
||||
LoginEmailView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState)
|
||||
LoginEmailView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState, navigate: { route in path.append(route) })
|
||||
case .otp(let email, let phoneNumber):
|
||||
OtpView(email: email, phoneNumber: phoneNumber, root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LoginView: View {
|
||||
@Binding var root: RootFlow
|
||||
@Binding var selectedTab: MainTab
|
||||
let tokenStore: TokenStore
|
||||
@Binding var appState: AppState
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
let navigate: (Route) -> Void
|
||||
|
||||
@ViewBuilder private var logoImage: some View {
|
||||
#if os(Android)
|
||||
Image(colorScheme == .dark ? "pedifoods_dark" : "pedifoods", bundle: .module)
|
||||
.resizable()
|
||||
#else
|
||||
SwiftUI.Image("pedifoods")
|
||||
.resizable()
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder private var pinHeroImage: some View {
|
||||
#if os(Android)
|
||||
Image(colorScheme == .dark ? "pin_image_app_dark" : "pin_image_app", bundle: .module)
|
||||
.resizable()
|
||||
#else
|
||||
SwiftUI.Image("pin_image_app")
|
||||
.resizable()
|
||||
#endif
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
let heroHeight = max(360, geo.size.height * 0.44)
|
||||
|
||||
ZStack(alignment: .top) {
|
||||
(colorScheme == .dark ? Color.black : AppColors.backgroundLight)
|
||||
.ignoresSafeArea()
|
||||
|
||||
pinHeroImage
|
||||
.scaledToFill()
|
||||
.frame(height: heroHeight + 80)
|
||||
.offset(y: -60)
|
||||
.mask(
|
||||
LinearGradient(
|
||||
colors: [.black, .black, .black.opacity(0.0)],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
|
||||
VStack(spacing: 18) {
|
||||
Spacer().frame(height: heroHeight - 100)
|
||||
|
||||
logoImage
|
||||
.scaledToFit()
|
||||
.frame(height: 180)
|
||||
|
||||
Text("Descubra lorem ipsum ba bla")
|
||||
.font(AppTypography.heading1)
|
||||
.foregroundStyle(colorScheme == .dark ? AppColors.textInverse : AppColors.textPrimary)
|
||||
.multilineTextAlignment(.leading)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 28)
|
||||
|
||||
Spacer().frame(height: 40)
|
||||
|
||||
Button {
|
||||
navigate(.loginEmail)
|
||||
} label: {
|
||||
PrimaryButtonLabel(title: "ENTRAR")
|
||||
}
|
||||
.padding(.horizontal, 28)
|
||||
.tint(AppColors.tertiary)
|
||||
|
||||
HStack(spacing: 6) {
|
||||
Text("Não tem conta ainda?")
|
||||
.foregroundStyle(colorScheme == .dark ? AppColors.textInverse.opacity(0.9) : AppColors.textPrimary)
|
||||
Button("Criar conta") {
|
||||
navigate(.registration)
|
||||
}
|
||||
.foregroundStyle(AppColors.primary)
|
||||
}
|
||||
.font(AppTypography.body)
|
||||
|
||||
Spacer().frame(height: 12)
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct RegistrationView: View {
|
||||
@Binding var root: RootFlow
|
||||
@Binding var selectedTab: MainTab
|
||||
let tokenStore: TokenStore
|
||||
@Binding var appState: AppState
|
||||
@State var name = ""
|
||||
@State var email = ""
|
||||
@State var phone = ""
|
||||
@State var acceptedTerms = false
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
let navigate: (Route) -> Void
|
||||
|
||||
private var isFormValid: Bool { !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !phone.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && acceptedTerms }
|
||||
|
||||
@ViewBuilder private var logoImage: some View {
|
||||
#if os(Android)
|
||||
Image(colorScheme == .dark ? "pedifoods_dark" : "pedifoods", bundle: .module)
|
||||
.resizable()
|
||||
#else
|
||||
SwiftUI.Image("pedifoods")
|
||||
.resizable()
|
||||
#endif
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
(colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea()
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
logoImage
|
||||
.scaledToFit()
|
||||
.frame(width: 120, height: 120)
|
||||
|
||||
Text("Crie sua conta")
|
||||
.font(AppTypography.heading1)
|
||||
.foregroundStyle(colorScheme == .dark ? Color.white : AppColors.textPrimary)
|
||||
|
||||
Text("Preencha os dados abaixo para começar.")
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(colorScheme == .dark ? Color.white : AppColors.textPrimary)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 20)
|
||||
|
||||
VStack(spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
LoginField(icon: "person", placeholder: "Ex: Maria Silva", text: $name)
|
||||
LoginField(icon: "envelope", placeholder: "seu@email.com", text: $email)
|
||||
LoginField(icon: "phone", placeholder: "(00) 00000-0000", text: $phone)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
|
||||
Toggle("", isOn: $acceptedTerms)
|
||||
.labelsHidden()
|
||||
.toggleStyle(SwitchToggleStyle(tint: AppColors.primary))
|
||||
|
||||
Group {
|
||||
Text("Ao se cadastrar, você concorda com nossos [Termos de Uso](app://terms) e [Política de Privacidade](app://policy)")
|
||||
.foregroundStyle(colorScheme == .dark ? Color.white : AppColors.textPrimary)
|
||||
.tint(AppColors.primary)
|
||||
.environment(\.openURL, OpenURLAction { url in
|
||||
guard url.scheme == "app" else { return .handled }
|
||||
|
||||
switch url.host {
|
||||
case "terms":
|
||||
navigate(.terms)
|
||||
return .handled
|
||||
case "policy":
|
||||
navigate(.policy)
|
||||
return .handled
|
||||
default:
|
||||
return .handled
|
||||
}
|
||||
})
|
||||
}
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, 16)
|
||||
.padding(.bottom, 16)
|
||||
|
||||
PrimaryButton(title: "Cadastrar e Receber Código", image: Image(systemName: "arrow.right")) {
|
||||
// TODO: integrar com ApiService e OTP
|
||||
tokenStore.jwt = "demo-token"
|
||||
appState.session.isAuthenticated = true
|
||||
appState.session.jwt = "demo-token"
|
||||
appState.profile.email = email
|
||||
selectedTab = .home
|
||||
root = .main
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, 6)
|
||||
.disabled(!isFormValid)
|
||||
.opacity(isFormValid ? 1.0 : 0.5)
|
||||
.tint(AppColors.tertiary)
|
||||
|
||||
HStack(spacing: 6) {
|
||||
Text("Já tem uma conta?")
|
||||
.foregroundStyle(colorScheme == .dark ? Color.white.opacity(0.8) : AppColors.textPrimary)
|
||||
NavigationLink("Entrar") {
|
||||
LoginEmailView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState)
|
||||
}
|
||||
.foregroundStyle(AppColors.primary)
|
||||
}
|
||||
.font(AppTypography.body)
|
||||
.padding(.top, 16)
|
||||
|
||||
Spacer().frame(height: 12)
|
||||
}
|
||||
}
|
||||
.padding(.top, -40)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LoginEmailView: View {
|
||||
@Binding var root: RootFlow
|
||||
@Binding var selectedTab: MainTab
|
||||
let tokenStore: TokenStore
|
||||
@Binding var appState: AppState
|
||||
@State var email = ""
|
||||
@State var phone = ""
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
@ViewBuilder private var logoImage: some View {
|
||||
#if os(Android)
|
||||
Image(colorScheme == .dark ? "pedifoods_dark" : "pedifoods", bundle: .module)
|
||||
.resizable()
|
||||
#else
|
||||
SwiftUI.Image("pedifoods")
|
||||
.resizable()
|
||||
#endif
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
(colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
|
||||
logoImage
|
||||
.scaledToFit()
|
||||
.frame(width: 120, height: 120)
|
||||
|
||||
Text("Boas-vindas!")
|
||||
.font(AppTypography.heading1)
|
||||
.foregroundStyle(colorScheme == .dark ? Color.white : AppColors.textPrimary)
|
||||
.padding(Edge.Set.top, 8)
|
||||
.padding(.bottom, 16)
|
||||
|
||||
VStack(spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
|
||||
LoginField(icon: "envelope", placeholder: "seu@email.com", text: $email)
|
||||
|
||||
LoginField(icon: "phone", placeholder: "(00) 00000-0000", text: $phone)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 16)
|
||||
|
||||
PrimaryButton(title: "Receber Código", image: Image(systemName: "arrow.right")) {
|
||||
// TODO: integrar com ApiService e OTP
|
||||
tokenStore.jwt = "demo-token"
|
||||
appState.session.isAuthenticated = true
|
||||
appState.session.jwt = "demo-token"
|
||||
appState.profile.email = email
|
||||
selectedTab = .home
|
||||
root = .main
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.tint(AppColors.tertiary)
|
||||
|
||||
Text("Enviaremos um código de verificação por SMS \nou E-mail para confirmar seu acesso.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.gray)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 40)
|
||||
.padding([.top, .bottom], 16)
|
||||
|
||||
HStack(spacing: 6) {
|
||||
Text("Novo por aqui?")
|
||||
.foregroundStyle(colorScheme == .dark ? Color.white.opacity(0.8) : Color.gray)
|
||||
Text("Crie sua conta")
|
||||
.foregroundStyle(AppColors.primary)
|
||||
.onTapGesture {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
.font(AppTypography.body)
|
||||
.padding(.top, 8)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LoginField: View {
|
||||
let icon: String
|
||||
let placeholder: String
|
||||
@Binding var text: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: icon)
|
||||
.foregroundStyle(Color.gray)
|
||||
.frame(width: 28)
|
||||
TextField(text: $text, prompt: Text(placeholder).foregroundColor(Color.gray)) { }
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.foregroundColor(.black)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.frame(height: 52)
|
||||
.background(Color.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.stroke(Color.black.opacity(0.06), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct OtpView: View {
|
||||
let email: String
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Text("OTP")
|
||||
.font(AppTypography.heading1)
|
||||
.foregroundStyle(Color.white)
|
||||
|
||||
Text("Enviado para \(email)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.white.opacity(0.8))
|
||||
}
|
||||
.padding(24)
|
||||
.background(AppColors.backgroundLight)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
struct TermsOfUseView: View {
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Termos de Uso")
|
||||
.font(AppTypography.heading1)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
Text("Conteúdo dos termos de uso...")
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
.background((Color(UIColor { trait in trait.userInterfaceStyle == .dark ? .black : UIColor(AppColors.backgroundLight) })).ignoresSafeArea())
|
||||
.navigationTitle("Termos de Uso")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
|
||||
struct PrivacyPolicyView: View {
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Política de Privacidade")
|
||||
.font(AppTypography.heading1)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
Text("Conteúdo da política de privacidade...")
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
.background((Color(UIColor { trait in trait.userInterfaceStyle == .dark ? .black : UIColor(AppColors.backgroundLight) })).ignoresSafeArea())
|
||||
.navigationTitle("Privacidade")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
84
pedi-foods/Sources/PediFoods/Views/Auth/AuthFormatters.swift
Normal file
84
pedi-foods/Sources/PediFoods/Views/Auth/AuthFormatters.swift
Normal file
@@ -0,0 +1,84 @@
|
||||
import Foundation
|
||||
|
||||
func formatPhoneBR(_ input: String) -> String {
|
||||
let digits = input.filter(\.isNumber)
|
||||
let limited = String(digits.prefix(11))
|
||||
let count = limited.count
|
||||
guard count > 0 else { return "" }
|
||||
|
||||
if count <= 2 {
|
||||
return "(\(limited)"
|
||||
}
|
||||
|
||||
let area = String(limited.prefix(2))
|
||||
let remainder = String(limited.dropFirst(2))
|
||||
|
||||
if count <= 7 {
|
||||
return "(\(area)) \(remainder)"
|
||||
}
|
||||
|
||||
let firstPart = String(remainder.prefix(5))
|
||||
let secondPart = String(remainder.dropFirst(5))
|
||||
return "(\(area)) \(firstPart)-\(secondPart)"
|
||||
}
|
||||
|
||||
func normalizePhoneNumberForAPI(_ input: String) -> String {
|
||||
let digitsOnly = input.filter(\.isNumber)
|
||||
if digitsOnly.count < 10 {
|
||||
return ""
|
||||
}
|
||||
|
||||
if digitsOnly.hasPrefix("55") {
|
||||
return "+\(digitsOnly)"
|
||||
}
|
||||
|
||||
return "+55\(digitsOnly)"
|
||||
}
|
||||
|
||||
func userFacingAuthErrorMessage(_ error: Error) -> String {
|
||||
if let serviceError = error as? ApiServiceError {
|
||||
switch serviceError {
|
||||
case .sessionExpired(let message):
|
||||
if let message, !message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
return message
|
||||
}
|
||||
return "Sua sessão expirou. Faça login novamente."
|
||||
}
|
||||
}
|
||||
|
||||
if let networkError = error as? NetworkError {
|
||||
switch networkError {
|
||||
case .unauthorized(let message):
|
||||
if let message, !message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
return message
|
||||
}
|
||||
return "Seu acesso expirou. Solicite um novo código para continuar."
|
||||
case .httpError(let code, let message):
|
||||
if let message, !message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
return message
|
||||
}
|
||||
switch code {
|
||||
case 400:
|
||||
return "Não foi possível validar seus dados. Revise as informações e tente novamente."
|
||||
case 401, 403:
|
||||
return "Seu acesso expirou. Solicite um novo código para continuar."
|
||||
case 404:
|
||||
return "Não encontramos seu cadastro com os dados informados."
|
||||
case 429:
|
||||
return "Muitas tentativas em sequência. Aguarde um instante e tente novamente."
|
||||
case 500...599:
|
||||
return "Nossos servidores estão instáveis no momento. Tente novamente em alguns minutos."
|
||||
default:
|
||||
return "Não foi possível concluir a operação agora. Tente novamente."
|
||||
}
|
||||
case .rateLimited:
|
||||
return "Muitas tentativas em sequência. Aguarde um instante e tente novamente."
|
||||
case .transportError:
|
||||
return "Não foi possível se conectar ao servidor. Tente novamente."
|
||||
case .invalidURL, .invalidResponse, .decodeError:
|
||||
return "Ocorreu uma instabilidade ao processar sua solicitação. Tente novamente."
|
||||
}
|
||||
}
|
||||
|
||||
return "Não foi possível concluir a operação. Tente novamente."
|
||||
}
|
||||
47
pedi-foods/Sources/PediFoods/Views/Auth/LegalViews.swift
Normal file
47
pedi-foods/Sources/PediFoods/Views/Auth/LegalViews.swift
Normal file
@@ -0,0 +1,47 @@
|
||||
import SwiftUI
|
||||
|
||||
struct TermsOfUseView: View {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Termos de Uso")
|
||||
.font(AppTypography.heading1)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
Text("Conteúdo dos termos de uso...")
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
.background((colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea())
|
||||
.navigationTitle("Termos de Uso")
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
struct PrivacyPolicyView: View {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Política de Privacidade")
|
||||
.font(AppTypography.heading1)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
Text("Conteúdo da política de privacidade...")
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
.background((colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea())
|
||||
.navigationTitle("Privacidade")
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
148
pedi-foods/Sources/PediFoods/Views/Auth/LoginEmailView.swift
Normal file
148
pedi-foods/Sources/PediFoods/Views/Auth/LoginEmailView.swift
Normal file
@@ -0,0 +1,148 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LoginEmailView: View {
|
||||
@Binding var root: RootFlow
|
||||
@Binding var selectedTab: MainTab
|
||||
let tokenStore: TokenStore
|
||||
@Binding var appState: AppState
|
||||
let navigate: (Route) -> Void
|
||||
@State var email = ""
|
||||
@State var phone = ""
|
||||
@State var isLoading = false
|
||||
@State var errorMessage: String?
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
@ViewBuilder private var logoImage: some View {
|
||||
#if os(Android)
|
||||
SwiftUI.Image("pedifoods")
|
||||
.resizable()
|
||||
#else
|
||||
SwiftUI.Image("pedifoods")
|
||||
.resizable()
|
||||
#endif
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
(colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
logoImage
|
||||
.scaledToFit()
|
||||
.frame(width: 120, height: 120)
|
||||
|
||||
Text("Boas-vindas!")
|
||||
.font(AppTypography.heading1)
|
||||
.foregroundStyle(colorScheme == .dark ? Color.white : AppColors.textPrimary)
|
||||
.padding(Edge.Set.top, 8)
|
||||
.padding(.bottom, 16)
|
||||
|
||||
VStack(spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
LoginField(icon: "envelope", placeholder: "seu@email.com", text: $email)
|
||||
|
||||
LoginField(icon: "phone", placeholder: "(00) 00000-0000", text: $phone)
|
||||
.onChange(of: phone) { _, newValue in
|
||||
let masked = formatPhoneBR(newValue)
|
||||
if masked != newValue {
|
||||
phone = masked
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 16)
|
||||
|
||||
PrimaryButton(title: "Receber Código", image: Image(systemName: "arrow.right")) {
|
||||
requestOtp()
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.tint(AppColors.tertiary)
|
||||
.disabled(isLoading || email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || normalizePhoneNumberForAPI(phone).isEmpty)
|
||||
.opacity((isLoading || email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || normalizePhoneNumberForAPI(phone).isEmpty) ? 0.6 : 1.0)
|
||||
|
||||
Text("Enviaremos um código de verificação por SMS \nou E-mail para confirmar seu acesso.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.gray)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 40)
|
||||
.padding([.top, .bottom], 16)
|
||||
|
||||
HStack(spacing: 6) {
|
||||
Text("Novo por aqui?")
|
||||
.foregroundStyle(colorScheme == .dark ? Color.white.opacity(0.8) : Color.gray)
|
||||
Text("Crie sua conta")
|
||||
.foregroundStyle(AppColors.primary)
|
||||
.onTapGesture {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
.font(AppTypography.body)
|
||||
.padding(.top, 8)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func requestOtp() {
|
||||
let sanitizedEmail = email.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let normalizedPhone = normalizePhoneNumberForAPI(phone)
|
||||
guard !sanitizedEmail.isEmpty, !normalizedPhone.isEmpty else { return }
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
Task {
|
||||
do {
|
||||
let service = ApiService()
|
||||
let response = try await service.requestOtp(email: sanitizedEmail, phoneNumber: normalizedPhone)
|
||||
await MainActor.run {
|
||||
if response.error {
|
||||
isLoading = false
|
||||
let message = response.message ?? "Nao foi possivel enviar o codigo."
|
||||
errorMessage = message
|
||||
SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 4.0)
|
||||
return
|
||||
}
|
||||
isLoading = false
|
||||
SnackbarCenter.shared.show(title: "Codigo enviado com sucesso.", style: .info, icon: "paperplane.fill", duration: 3.0)
|
||||
appState.profile.email = sanitizedEmail
|
||||
appState.profile.phone = phone
|
||||
navigate(.otp(email: sanitizedEmail, phoneNumber: normalizedPhone))
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
isLoading = false
|
||||
let message = userFacingAuthErrorMessage(error)
|
||||
errorMessage = message
|
||||
SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 4.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LoginField: View {
|
||||
let icon: String
|
||||
let placeholder: String
|
||||
@Binding var text: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: icon)
|
||||
.foregroundStyle(Color.gray)
|
||||
.frame(width: 28)
|
||||
TextField(text: $text, prompt: Text(placeholder).foregroundColor(Color.gray)) { }
|
||||
.appNoAutoCap()
|
||||
.foregroundColor(.black)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.frame(height: 52)
|
||||
.background(Color.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.stroke(Color.black.opacity(0.06), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
91
pedi-foods/Sources/PediFoods/Views/Auth/LoginView.swift
Normal file
91
pedi-foods/Sources/PediFoods/Views/Auth/LoginView.swift
Normal file
@@ -0,0 +1,91 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LoginView: View {
|
||||
@Binding var root: RootFlow
|
||||
@Binding var selectedTab: MainTab
|
||||
let tokenStore: TokenStore
|
||||
@Binding var appState: AppState
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
let navigate: (Route) -> Void
|
||||
|
||||
@ViewBuilder private var logoImage: some View {
|
||||
#if os(Android)
|
||||
SwiftUI.Image("pedifoods")
|
||||
.resizable()
|
||||
#else
|
||||
SwiftUI.Image("pedifoods")
|
||||
.resizable()
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder private var pinHeroImage: some View {
|
||||
#if os(Android)
|
||||
SwiftUI.Image(colorScheme == .dark ? "pin_image_app_dark" : "pin_image_app")
|
||||
.resizable()
|
||||
#else
|
||||
SwiftUI.Image("pin_image_app")
|
||||
.resizable()
|
||||
#endif
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
let heroHeight = max(360, geo.size.height * 0.44)
|
||||
|
||||
ZStack(alignment: .top) {
|
||||
(colorScheme == .dark ? Color.black : AppColors.backgroundLight)
|
||||
.ignoresSafeArea()
|
||||
|
||||
pinHeroImage
|
||||
.scaledToFill()
|
||||
.frame(height: heroHeight + 80)
|
||||
.offset(y: -60)
|
||||
.mask(
|
||||
LinearGradient(
|
||||
colors: [.black, .black, .black.opacity(0.0)],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
|
||||
VStack(spacing: 18) {
|
||||
Spacer().frame(height: heroHeight - 100)
|
||||
|
||||
logoImage
|
||||
.scaledToFit()
|
||||
.frame(height: 180)
|
||||
|
||||
Text("Descubra lorem ipsum ba bla")
|
||||
.font(AppTypography.heading1)
|
||||
.foregroundStyle(colorScheme == .dark ? AppColors.textInverse : AppColors.textPrimary)
|
||||
.multilineTextAlignment(.leading)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 28)
|
||||
|
||||
Spacer().frame(height: 40)
|
||||
|
||||
Button {
|
||||
navigate(.loginEmail)
|
||||
} label: {
|
||||
PrimaryButtonLabel(title: "ENTRAR")
|
||||
}
|
||||
.padding(.horizontal, 28)
|
||||
.tint(AppColors.tertiary)
|
||||
|
||||
HStack(spacing: 6) {
|
||||
Text("Não tem conta ainda?")
|
||||
.foregroundStyle(colorScheme == .dark ? AppColors.textInverse.opacity(0.9) : AppColors.textPrimary)
|
||||
Button("Criar conta") {
|
||||
navigate(.registration)
|
||||
}
|
||||
.foregroundStyle(AppColors.primary)
|
||||
}
|
||||
.font(AppTypography.body)
|
||||
|
||||
Spacer().frame(height: 12)
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
}
|
||||
290
pedi-foods/Sources/PediFoods/Views/Auth/OtpView.swift
Normal file
290
pedi-foods/Sources/PediFoods/Views/Auth/OtpView.swift
Normal file
@@ -0,0 +1,290 @@
|
||||
import SwiftUI
|
||||
|
||||
struct OtpView: View {
|
||||
private let resendDelaySeconds = 45
|
||||
|
||||
let email: String
|
||||
let phoneNumber: String
|
||||
@Binding var root: RootFlow
|
||||
@Binding var selectedTab: MainTab
|
||||
let tokenStore: TokenStore
|
||||
@Binding var appState: AppState
|
||||
@State var otp = ""
|
||||
@State var isLoading = false
|
||||
@State var isResending = false
|
||||
@State var resendCountdown = 45
|
||||
@State var canResend = false
|
||||
@State var errorMessage: String?
|
||||
@State var countdownTask: Task<Void, Never>?
|
||||
@FocusState var isOtpFocused: Bool
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
SwiftUI.Image("pedifoods")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 74, height: 74)
|
||||
.padding(.top, 140)
|
||||
|
||||
Text("Verificação")
|
||||
.font(AppTypography.heading1)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.padding(.top, 26)
|
||||
|
||||
Text("Insira o código de 8 dígitos enviado")
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
.padding(.top, 14)
|
||||
|
||||
Text("para \(email)")
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.85)
|
||||
.padding(.top, 2)
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
ZStack {
|
||||
otpGrid
|
||||
TextField("", text: $otp)
|
||||
.appOTPKeyboard()
|
||||
.foregroundStyle(Color.clear)
|
||||
.tint(Color.clear)
|
||||
.focused($isOtpFocused)
|
||||
.frame(maxWidth: CGFloat.greatestFiniteMagnitude, maxHeight: CGFloat.greatestFiniteMagnitude)
|
||||
.opacity(0.02)
|
||||
.onChange(of: otp) { _, newValue in
|
||||
let digits = newValue.filter { $0.isNumber }
|
||||
let trimmed = String(digits.prefix(8))
|
||||
if trimmed != newValue {
|
||||
otp = trimmed
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 204)
|
||||
.onTapGesture {
|
||||
isOtpFocused = true
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, 26)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Text("Não recebeu o código?")
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
Button(resendButtonTitle) {
|
||||
resendOtp()
|
||||
}
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.primary)
|
||||
.disabled(isResending || !canResend)
|
||||
}
|
||||
.padding(.top, 22)
|
||||
|
||||
PrimaryButton(title: "Verificar e Entrar") {
|
||||
validateOtp()
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, 28)
|
||||
.disabled(isLoading || otp.count != 8)
|
||||
.opacity((isLoading || otp.count != 8) ? 0.6 : 1.0)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.caption)
|
||||
Text("Conexão segura e criptografada")
|
||||
.font(AppTypography.body)
|
||||
}
|
||||
.foregroundStyle(AppColors.textMuted.opacity(0.8))
|
||||
.padding(.top, 120)
|
||||
.padding(.bottom, 18)
|
||||
}
|
||||
}
|
||||
.background(AppColors.backgroundLight)
|
||||
.ignoresSafeArea()
|
||||
.onAppear {
|
||||
isOtpFocused = true
|
||||
startResendCooldown()
|
||||
}
|
||||
.onDisappear {
|
||||
countdownTask?.cancel()
|
||||
countdownTask = nil
|
||||
}
|
||||
}
|
||||
|
||||
private var resendButtonTitle: String {
|
||||
if !canResend {
|
||||
return String(format: "Reenviar em 00:%02d", resendCountdown)
|
||||
}
|
||||
return "Reenviar código"
|
||||
}
|
||||
|
||||
private var otpGrid: some View {
|
||||
VStack(spacing: 16) {
|
||||
HStack(spacing: 14) {
|
||||
otpCell(index: 0)
|
||||
otpCell(index: 1)
|
||||
otpCell(index: 2)
|
||||
otpCell(index: 3)
|
||||
}
|
||||
HStack(spacing: 14) {
|
||||
otpCell(index: 4)
|
||||
otpCell(index: 5)
|
||||
otpCell(index: 6)
|
||||
otpCell(index: 7)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func otpCell(index: Int) -> some View {
|
||||
let char = otpCharacter(at: index)
|
||||
return ZStack {
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.fill(Color.white)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.stroke(Color(hex: "#EEF3FA"), lineWidth: 2)
|
||||
)
|
||||
|
||||
if let char {
|
||||
Text(String(char))
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
} else {
|
||||
Circle()
|
||||
.fill(AppColors.textMuted.opacity(0.8))
|
||||
.frame(width: 10, height: 10)
|
||||
}
|
||||
}
|
||||
.frame(height: 94)
|
||||
}
|
||||
|
||||
private func otpCharacter(at index: Int) -> Character? {
|
||||
guard index < otp.count else { return nil }
|
||||
return Array(otp)[index]
|
||||
}
|
||||
|
||||
private func validateOtp() {
|
||||
let code = otp.filter(\.isNumber)
|
||||
guard code.count == 8 else { return }
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
Task {
|
||||
do {
|
||||
let service = ApiService()
|
||||
let response = try await service.validateOtp(email: email, phoneNumber: phoneNumber, otp: code)
|
||||
await MainActor.run {
|
||||
if response.error {
|
||||
isLoading = false
|
||||
let message = response.message ?? "Codigo invalido."
|
||||
errorMessage = message
|
||||
SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 4.0)
|
||||
return
|
||||
}
|
||||
isLoading = false
|
||||
SnackbarCenter.shared.show(title: "Login realizado com sucesso.", style: .success, icon: "checkmark.seal.fill", duration: 2.5)
|
||||
appState.session.isAuthenticated = true
|
||||
appState.session.jwt = response.result?.token
|
||||
appState.profile.email = email
|
||||
routeAfterLogin()
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
isLoading = false
|
||||
let message = userFacingAuthErrorMessage(error)
|
||||
errorMessage = message
|
||||
SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 4.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func routeAfterLogin() {
|
||||
if let cached = LocationService.shared.cachedLocation() {
|
||||
appState.address.latitude = cached.0
|
||||
appState.address.longitude = cached.1
|
||||
selectedTab = .home
|
||||
root = .main
|
||||
return
|
||||
}
|
||||
|
||||
if hasConfiguredAddress() {
|
||||
selectedTab = .home
|
||||
root = .main
|
||||
return
|
||||
}
|
||||
|
||||
appState.address.onboardingMessage = "Vamos criar um endereço para poder visualizar os estabelecimentos mais próximos de você?"
|
||||
selectedTab = .profile
|
||||
root = .main
|
||||
}
|
||||
|
||||
private func hasConfiguredAddress() -> Bool {
|
||||
if appState.address.selectedId != nil {
|
||||
return true
|
||||
}
|
||||
|
||||
if appState.address.latitude != nil, appState.address.longitude != nil {
|
||||
return true
|
||||
}
|
||||
|
||||
let normalized = appState.address.display
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
|
||||
return normalized.isEmpty == false && normalized != "defina seu endereco"
|
||||
}
|
||||
|
||||
private func resendOtp() {
|
||||
guard canResend, !isResending else { return }
|
||||
isResending = true
|
||||
startResendCooldown()
|
||||
errorMessage = nil
|
||||
Task {
|
||||
do {
|
||||
let service = ApiService()
|
||||
let response = try await service.requestOtp(email: email, phoneNumber: phoneNumber)
|
||||
await MainActor.run {
|
||||
isResending = false
|
||||
if response.error {
|
||||
canResend = true
|
||||
resendCountdown = 0
|
||||
let message = response.message ?? "Nao foi possivel reenviar o código."
|
||||
errorMessage = message
|
||||
SnackbarCenter.shared.show(title: message, style: .warning, icon: "exclamationmark.triangle.fill", duration: 4.0)
|
||||
} else {
|
||||
SnackbarCenter.shared.show(title: "Codigo reenviado.", style: .info, icon: "paperplane.fill", duration: 2.5)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
isResending = false
|
||||
canResend = true
|
||||
resendCountdown = 0
|
||||
let message = userFacingAuthErrorMessage(error)
|
||||
errorMessage = message
|
||||
SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 4.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startResendCooldown() {
|
||||
countdownTask?.cancel()
|
||||
canResend = false
|
||||
resendCountdown = resendDelaySeconds
|
||||
countdownTask = Task {
|
||||
var remaining = resendDelaySeconds
|
||||
while !Task.isCancelled && remaining > 0 {
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
remaining -= 1
|
||||
await MainActor.run {
|
||||
resendCountdown = max(remaining, 0)
|
||||
canResend = remaining == 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
173
pedi-foods/Sources/PediFoods/Views/Auth/RegistrationView.swift
Normal file
173
pedi-foods/Sources/PediFoods/Views/Auth/RegistrationView.swift
Normal file
@@ -0,0 +1,173 @@
|
||||
import SwiftUI
|
||||
|
||||
struct RegistrationView: View {
|
||||
@Binding var root: RootFlow
|
||||
@Binding var selectedTab: MainTab
|
||||
let tokenStore: TokenStore
|
||||
@Binding var appState: AppState
|
||||
@State var name = ""
|
||||
@State var email = ""
|
||||
@State var phone = ""
|
||||
@State var acceptedTerms = false
|
||||
@State var isLoading = false
|
||||
@State var errorMessage: String?
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
let navigate: (Route) -> Void
|
||||
|
||||
private var isFormValid: Bool { !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !phone.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && acceptedTerms }
|
||||
|
||||
@ViewBuilder private var logoImage: some View {
|
||||
#if os(Android)
|
||||
SwiftUI.Image("pedifoods")
|
||||
.resizable()
|
||||
#else
|
||||
SwiftUI.Image("pedifoods")
|
||||
.resizable()
|
||||
#endif
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
(colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea()
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
logoImage
|
||||
.scaledToFit()
|
||||
.frame(width: 120, height: 120)
|
||||
|
||||
Text("Crie sua conta")
|
||||
.font(AppTypography.heading1)
|
||||
.foregroundStyle(colorScheme == .dark ? Color.white : AppColors.textPrimary)
|
||||
|
||||
Text("Preencha os dados abaixo para começar.")
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(colorScheme == .dark ? Color.white : AppColors.textPrimary)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 20)
|
||||
|
||||
VStack(spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
LoginField(icon: "person", placeholder: "Ex: Maria Silva", text: $name)
|
||||
LoginField(icon: "envelope", placeholder: "seu@email.com", text: $email)
|
||||
LoginField(icon: "phone", placeholder: "(00) 00000-0000", text: $phone)
|
||||
.onChange(of: phone) { _, newValue in
|
||||
let masked = formatPhoneBR(newValue)
|
||||
if masked != newValue {
|
||||
phone = masked
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Toggle("", isOn: $acceptedTerms)
|
||||
.labelsHidden()
|
||||
.tint(AppColors.primary)
|
||||
|
||||
Group {
|
||||
Text("Ao se cadastrar, você concorda com nossos [Termos de Uso](app://terms) e [Política de Privacidade](app://policy)")
|
||||
.foregroundStyle(colorScheme == .dark ? Color.white : AppColors.textPrimary)
|
||||
.tint(AppColors.primary)
|
||||
.environment(\.openURL, OpenURLAction { url in
|
||||
guard url.scheme == "app" else { return .handled }
|
||||
|
||||
switch url.host {
|
||||
case "terms":
|
||||
navigate(.terms)
|
||||
return .handled
|
||||
case "policy":
|
||||
navigate(.policy)
|
||||
return .handled
|
||||
default:
|
||||
return .handled
|
||||
}
|
||||
})
|
||||
}
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, 16)
|
||||
.padding(.bottom, 16)
|
||||
|
||||
PrimaryButton(title: "Cadastrar e Receber Código", image: Image(systemName: "arrow.right")) {
|
||||
registerAndRequestOtp()
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, 6)
|
||||
.disabled(!isFormValid || isLoading)
|
||||
.opacity((!isFormValid || isLoading) ? 0.5 : 1.0)
|
||||
.tint(AppColors.tertiary)
|
||||
|
||||
HStack(spacing: 6) {
|
||||
Text("Já tem uma conta?")
|
||||
.foregroundStyle(colorScheme == .dark ? Color.white.opacity(0.8) : AppColors.textPrimary)
|
||||
NavigationLink("Entrar") {
|
||||
LoginEmailView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState, navigate: navigate)
|
||||
}
|
||||
.foregroundStyle(AppColors.primary)
|
||||
}
|
||||
.font(AppTypography.body)
|
||||
.padding(.top, 16)
|
||||
|
||||
Spacer().frame(height: 12)
|
||||
}
|
||||
}
|
||||
.padding(.top, -40)
|
||||
}
|
||||
}
|
||||
|
||||
private func registerAndRequestOtp() {
|
||||
let sanitizedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let sanitizedEmail = email.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let normalizedPhone = normalizePhoneNumberForAPI(phone)
|
||||
guard !sanitizedName.isEmpty, !sanitizedEmail.isEmpty, !normalizedPhone.isEmpty else { return }
|
||||
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
Task {
|
||||
do {
|
||||
let service = ApiService()
|
||||
let registration = try await service.registerCustomer(
|
||||
name: sanitizedName,
|
||||
email: sanitizedEmail,
|
||||
phoneNumber: normalizedPhone
|
||||
)
|
||||
if registration.error {
|
||||
await MainActor.run {
|
||||
isLoading = false
|
||||
let message = registration.message ?? "Nao foi possivel concluir o cadastro."
|
||||
errorMessage = message
|
||||
SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 4.0)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let otp = try await service.requestOtp(email: sanitizedEmail, phoneNumber: normalizedPhone)
|
||||
await MainActor.run {
|
||||
isLoading = false
|
||||
if otp.error {
|
||||
let message = otp.message ?? "Cadastro concluido, mas nao foi possivel enviar o codigo."
|
||||
errorMessage = message
|
||||
SnackbarCenter.shared.show(title: message, style: .warning, icon: "exclamationmark.triangle.fill", duration: 4.0)
|
||||
return
|
||||
}
|
||||
SnackbarCenter.shared.show(title: "Cadastro concluido. Codigo enviado.", style: .success, icon: "checkmark.seal.fill", duration: 3.0)
|
||||
appState.profile.email = sanitizedEmail
|
||||
appState.profile.phone = phone
|
||||
navigate(.otp(email: sanitizedEmail, phoneNumber: normalizedPhone))
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
isLoading = false
|
||||
let message = userFacingAuthErrorMessage(error)
|
||||
errorMessage = message
|
||||
SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 4.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user