migration

This commit is contained in:
Daniel Arantes Loverde
2026-08-11 13:22:02 -03:00
parent c4d6998595
commit 7702836fe7
231 changed files with 4329 additions and 1958 deletions

View File

@@ -0,0 +1,47 @@
import SwiftUI
enum Route: Hashable {
case terms, policy
case registration, loginEmail
case otp(email: String, phoneNumber: String)
}
struct AuthFlowView: View {
@Binding var root: RootFlow
@Binding var selectedTab: MainTab
let tokenStore: TokenStore
@Binding var appState: AppState
var shouldPrepareLoginEntry: Bool = false
var authEntryAnimationToken: Int = 0
@State var path: [Route] = []
var body: some View {
NavigationStack(path: $path) {
LoginView(
root: $root,
selectedTab: $selectedTab,
tokenStore: tokenStore,
appState: $appState,
shouldPrepareEntryAnimation: shouldPrepareLoginEntry,
authEntryAnimationToken: authEntryAnimationToken
) { route in
path.append(route)
}
.navigationDestination(for: Route.self) { route in
switch route {
case .terms:
TermsOfUseView()
case .policy:
PrivacyPolicyView()
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, 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)
}
}
}
}
}

View File

@@ -0,0 +1,88 @@
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."
case .cancelled:
return "Cancelado"
case .timedOut:
return "O servidor demorou demais para responder. Tente novamente."
}
}
return "Não foi possível concluir a operação. Tente novamente."
}

View File

@@ -0,0 +1,131 @@
import SwiftUI
import WebKit
private struct RemotePDFView: View {
let url: URL
@Binding var isLoading: Bool
@Binding var hasError: Bool
var body: some View {
RemotePDFRepresentable(url: url, isLoading: $isLoading, hasError: $hasError)
}
}
private struct RemotePDFRepresentable: UIViewRepresentable {
let url: URL
@Binding var isLoading: Bool
@Binding var hasError: Bool
func makeUIView(context: Context) -> WKWebView {
let webView = WKWebView()
webView.navigationDelegate = context.coordinator
webView.load(URLRequest(url: url))
return webView
}
func updateUIView(_ uiView: WKWebView, context: Context) {}
func makeCoordinator() -> RemotePDFCoordinator {
RemotePDFCoordinator(isLoading: $isLoading, hasError: $hasError)
}
}
private final class RemotePDFCoordinator: NSObject, WKNavigationDelegate {
@Binding var isLoading: Bool
@Binding var hasError: Bool
init(isLoading: Binding<Bool>, hasError: Binding<Bool>) {
self._isLoading = isLoading
self._hasError = hasError
}
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
isLoading = true
hasError = false
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
isLoading = false
}
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
isLoading = false
hasError = true
}
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
isLoading = false
hasError = true
}
}
private struct LegalDocumentScreen: View {
let title: String
let headerTitle: String
let document: LegalDocument
@Environment(\.colorScheme) var colorScheme
@Environment(\.dismiss) var dismiss
@State private var isLoading = true
@State private var hasError = false
var body: some View {
VStack(spacing: 0) {
screenHeader
.padding(24)
ZStack {
RemotePDFView(url: document.url, isLoading: $isLoading, hasError: $hasError)
if isLoading {
ProgressView()
}
if hasError {
VStack(spacing: 8) {
Text("Não foi possível carregar \(title.lowercased()).")
.font(AppTypography.body)
.foregroundStyle(AppColors.textPrimary)
.multilineTextAlignment(.center)
}
.padding(24)
}
}
}
.background((colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea())
.appHiddenNavigationBar()
.navigationBarBackButtonHidden(true)
}
private var screenHeader: some View {
ZStack {
Text(headerTitle)
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
HStack {
Button(action: { dismiss() }) {
Image(systemName: "chevron.left")
.font(.system(size: 24, weight: .semibold))
.foregroundStyle(AppColors.textPrimary)
.frame(width: 52, height: 52)
.background(AppColors.surface)
.clipShape(Circle())
.shadow(color: .black.opacity(0.06), radius: 8, y: 2)
}
.buttonStyle(.plain)
Spacer()
}
}
}
}
struct TermsOfUseView: View {
var body: some View {
LegalDocumentScreen(title: "os Termos de Uso", headerTitle: "Termos de Uso", document: .terms)
}
}
struct PrivacyPolicyView: View {
var body: some View {
LegalDocumentScreen(title: "a Política de Privacidade", headerTitle: "Privacidade", document: .privacyPolicy)
}
}

View File

@@ -0,0 +1,146 @@
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 {
SwiftUI.Image("pedifoods")
.resizable()
}
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", keyboardType: .emailAddress, text: $email)
LoginField(icon: "phone", placeholder: "(00) 00000-0000", keyboardType: .numberPad, 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 \ne 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()
}
}
.buttonStyle(.plain)
.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
let keyboardType: UIKeyboardType
@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)
.keyboardType(keyboardType)
}
.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)
)
}
}

View File

@@ -0,0 +1,142 @@
import SwiftUI
struct LoginView: View {
@Binding var root: RootFlow
@Binding var selectedTab: MainTab
let tokenStore: TokenStore
@Binding var appState: AppState
@Environment(\.colorScheme) var colorScheme
var shouldPrepareEntryAnimation: Bool = false
var authEntryAnimationToken: Int = 0
let navigate: (Route) -> Void
@State var heroVisible = true
@State var textVisible = true
@State var buttonVisible = true
@State var lastAnimatedToken = 0
@ViewBuilder private var logoImage: some View {
SwiftUI.Image("pedifoods")
.resizable()
}
@ViewBuilder private var pinHeroImage: some View {
SwiftUI.Image("pin_image_app")
.resizable()
}
var body: some View {
GeometryReader { geo in
let heroHeight = max(360, geo.size.height * 0.44)
let logoTopInset = max(0, (geo.size.height - 180) / 2)
ZStack(alignment: .top) {
(colorScheme == .dark ? Color.black : AppColors.backgroundLight)
.ignoresSafeArea()
pinHeroImage
.scaledToFill()
.frame(height: heroHeight + 80)
.offset(y: heroVisible ? -60 : -(heroHeight + 220))
.mask(
LinearGradient(
colors: [.black, .black, .black.opacity(0.0)],
startPoint: .top,
endPoint: .bottom
)
)
VStack(spacing: 18) {
Spacer().frame(height: logoTopInset)
logoImage
.scaledToFit()
.frame(height: 180)
Text("Sua vontade, no seu tempo.\nTudo o que você precisa, em um toque.")
.font(AppTypography.heading25)
.foregroundStyle(colorScheme == .dark ? AppColors.textInverse : AppColors.textPrimary)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 28)
.opacity(textVisible ? 1.0 : 0.0)
.offset(y: textVisible ? 0 : 24)
Spacer().frame(height: 40)
Button {
navigate(.loginEmail)
} label: {
PrimaryButtonLabel(title: "ENTRAR")
}
.padding(.horizontal, 28)
.tint(AppColors.tertiary)
.offset(y: buttonVisible ? 0 : 140)
.opacity(buttonVisible ? 1.0 : 0.0)
.buttonStyle(.plain)
HStack(spacing: 6) {
Text("Não tem conta ainda?")
.foregroundStyle(colorScheme == .dark ? AppColors.textInverse.opacity(0.9) : AppColors.textPrimary)
Button("Criar conta") {
navigate(.registration)
}
.buttonStyle(.plain)
.foregroundStyle(AppColors.primary)
}
.font(AppTypography.body)
.opacity(textVisible ? 1.0 : 0.0)
.offset(y: textVisible ? 0 : 24)
Spacer().frame(height: 12)
}
}
.ignoresSafeArea()
.onAppear {
if shouldPrepareEntryAnimation {
applyHiddenStateWithoutAnimation()
} else {
showFinalStateWithoutAnimation()
}
}
.task(id: authEntryAnimationToken) {
await runEntryAnimationIfNeeded(for: authEntryAnimationToken)
}
}
}
@MainActor
private func applyHiddenStateWithoutAnimation() {
heroVisible = false
textVisible = false
buttonVisible = false
}
@MainActor
private func showFinalStateWithoutAnimation() {
heroVisible = true
textVisible = true
buttonVisible = true
}
@MainActor
private func runEntryAnimationIfNeeded(for token: Int) async {
guard token > 0 else { return }
guard token != lastAnimatedToken else { return }
lastAnimatedToken = token
applyHiddenStateWithoutAnimation()
try? await Task.sleep(nanoseconds: 40_000_000)
withAnimation(.spring(response: 0.64, dampingFraction: 0.9)) {
heroVisible = true
}
try? await Task.sleep(nanoseconds: 160_000_000)
withAnimation(.easeOut(duration: 0.42)) {
textVisible = true
}
try? await Task.sleep(nanoseconds: 150_000_000)
withAnimation(.spring(response: 0.52, dampingFraction: 0.86)) {
buttonVisible = true
}
}
}

View File

@@ -0,0 +1,391 @@
import SwiftUI
import UIKit
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(otpDeliveryMessage)
.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
}
if trimmed.count == 8 && !isLoading {
isOtpFocused = false
validateOtp()
}
}
}
.frame(height: 204)
.onTapGesture {
isOtpFocused = true
autoFillOtpFromClipboardIfAvailable()
}
.onLongPressGesture {
pasteOtpFromClipboard()
}
.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()
}
.buttonStyle(.plain)
.font(AppTypography.heading3)
.foregroundStyle(AppColors.primary)
.disabled(isResending || !canResend)
}
.padding(.top, 22)
Button("Colar código") {
pasteOtpFromClipboard()
}
.buttonStyle(.plain)
.font(AppTypography.heading3)
.foregroundStyle(AppColors.primary)
.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)
}
}
.scrollDismissesKeyboard(.interactively)
.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 pasteOtpFromClipboard() {
let raw = appReadClipboardText() ?? ""
let digits = raw.filter(\.isNumber)
let trimmed = String(digits.prefix(8))
if trimmed.isEmpty == false {
otp = trimmed
}
}
private func autoFillOtpFromClipboardIfAvailable() {
guard otp.isEmpty else { return }
let raw = appReadClipboardText() ?? ""
let digits = raw.filter(\.isNumber)
guard digits.count >= 8 else { return }
otp = String(digits.prefix(8))
}
private var otpDeliveryMessage: String {
"para o seu telefone \(maskedPhoneForDisplay) e seu email \(maskedEmailForDisplay)"
}
private var maskedPhoneForDisplay: String {
let digits = phoneNumber.filter(\.isNumber)
guard digits.isEmpty == false else { return "XXXX" }
let visibleSuffix = String(digits.suffix(min(4, digits.count)))
return "XXXX\(visibleSuffix)"
}
private var maskedEmailForDisplay: String {
let trimmed = email.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.isEmpty == false else { return "XXXX" }
let parts = trimmed.split(separator: "@", omittingEmptySubsequences: false)
guard parts.count == 2 else { return "XXXX" }
let domain = String(parts[1])
return "XXXX@\(domain)"
}
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
let hasServerAddress = hydrateUserState(from: response.result?.customer)
routeAfterLogin(hasServerAddress: hasServerAddress)
Task { await PushNotificationCoordinator.shared.syncCustomerAttributes() }
}
} 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(hasServerAddress: Bool) {
if hasServerAddress || 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 hydrateUserState(from customer: CustomerProfile?) -> Bool {
if let customer {
appState.profile.id = customer.id
appState.profile.name = customer.name
appState.profile.email = customer.email
appState.profile.phone = customer.phoneNumber ?? ""
appState.profile.profilePicture = customer.profilePicture ?? ""
appState.profile.notificationsEnabled = customer.notificationsEnabled ?? false
appState.profile.faceIdEnabled = customer.faceIdEnabled ?? false
appState.favorites.storeIds = Set(customer.favorites ?? [])
SessionStateStore.setActiveUserKey(
SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email)
)
if let cachedCart = SessionStateStore.loadCart() {
appState.cart = cachedCart
} else {
appState.cart = CartState()
}
let addresses = customer.addressBook ?? []
if let preferred = addresses.first {
appState.address.selectedId = preferred.id
let label = (preferred.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
appState.address.display = label.isEmpty ? "Defina seu endereco" : label
if let lat = preferred.latLong?.first, let lng = preferred.latLong?.dropFirst().first {
appState.address.latitude = lat
appState.address.longitude = lng
} else {
appState.address.latitude = nil
appState.address.longitude = nil
}
SessionStateStore.saveAddress(appState.address)
} else {
appState.address = AddressState()
SessionStateStore.clearAddress()
}
return addresses.isEmpty == false
}
appState.profile.email = email
appState.favorites = FavoritesState()
SessionStateStore.setActiveUserKey(
SessionStateStore.makeUserKey(profileId: nil, email: email)
)
if let cachedCart = SessionStateStore.loadCart() {
appState.cart = cachedCart
} else {
appState.cart = CartState()
}
appState.address = AddressState()
return false
}
private func hasConfiguredAddress() -> Bool {
if appState.address.selectedId != 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
}
}
}
}
}

View File

@@ -0,0 +1,168 @@
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 {
SwiftUI.Image("pedifoods")
.resizable()
}
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", keyboardType: .default, text: $name)
LoginField(icon: "envelope", placeholder: "seu@email.com", keyboardType: .emailAddress, text: $email)
LoginField(icon: "phone", placeholder: "(00) 00000-0000", keyboardType: .namePhonePad, 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)
}
}
}
}
}