Login flow fixed

This commit is contained in:
Daniel Arantes Loverde
2026-02-05 16:57:27 -03:00
parent 01c1da0430
commit 51d02f6715
12 changed files with 859 additions and 108 deletions

View File

@@ -3,41 +3,69 @@ import SwiftUI
struct PrimaryButton: View { struct PrimaryButton: View {
let title: String let title: String
var fullWidth: Bool = true var fullWidth: Bool = true
var image: Image? = nil
let action: @MainActor @Sendable () -> Void let action: @MainActor @Sendable () -> Void
var body: some View { var body: some View {
Button(action: { action() }) { Button(action: { action() }) {
Text(title) HStack {
.font(AppTypography.button) Text(title)
.tracking(AppTypography.buttonLetterSpacing) .font(AppTypography.button)
.foregroundStyle(AppColors.textInverse) .tracking(AppTypography.buttonLetterSpacing)
.frame(maxWidth: fullWidth ? .infinity : nil) if let image {
.frame(height: 56) image
.background(AppColors.primary) }
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) }
.foregroundStyle(AppColors.textInverse)
.frame(maxWidth: fullWidth ? .infinity : nil)
.frame(height: 56)
.background(AppColors.primary)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
} }
struct PrimaryButtonLabel: View {
let title: String
var fullWidth: Bool = true
var body: some View {
Text(title)
.font(AppTypography.button)
.tracking(AppTypography.buttonLetterSpacing)
.foregroundStyle(AppColors.textInverse)
.frame(maxWidth: fullWidth ? .infinity : nil)
.frame(height: 56)
.background(AppColors.primary)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
}
struct SecondaryButton: View { struct SecondaryButton: View {
let title: String let title: String
var fullWidth: Bool = true var fullWidth: Bool = true
var image: Image? = nil
let action: @MainActor @Sendable () -> Void let action: @MainActor @Sendable () -> Void
var body: some View { var body: some View {
Button(action: { action() }) { Button(action: { action() }) {
Text(title) HStack {
.font(AppTypography.button) Text(title)
.tracking(AppTypography.buttonLetterSpacing) .font(AppTypography.button)
.foregroundStyle(AppColors.primary) .tracking(AppTypography.buttonLetterSpacing)
.frame(maxWidth: fullWidth ? .infinity : nil) if let image {
.frame(height: 56) image
.background(AppColors.backgroundLight) }
.overlay( }
RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous) .foregroundStyle(AppColors.primary)
.stroke(AppColors.primary.opacity(0.3), lineWidth: 1) .frame(maxWidth: fullWidth ? .infinity : nil)
) .frame(height: 56)
.background(AppColors.primary)
.overlay(
RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)
.stroke(AppColors.primary.opacity(0.3), lineWidth: 1)
)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }

View File

@@ -4,13 +4,14 @@ struct ContentView: View {
@State var root: RootFlow = DefaultTokenStore().jwt == nil ? .auth : .main @State var root: RootFlow = DefaultTokenStore().jwt == nil ? .auth : .main
@State var selectedTab: MainTab = .home @State var selectedTab: MainTab = .home
private let tokenStore: TokenStore = DefaultTokenStore() private let tokenStore: TokenStore = DefaultTokenStore()
@State var appState = AppState()
var body: some View { var body: some View {
switch root { switch root {
case .auth: case .auth:
AuthFlowView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore) AuthFlowView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState)
case .main: case .main:
MainTabView(selectedTab: $selectedTab, root: $root, tokenStore: tokenStore) MainTabView(selectedTab: $selectedTab, root: $root, tokenStore: tokenStore, appState: $appState)
} }
} }
} }

View File

@@ -1,6 +1,9 @@
{ {
"sourceLanguage" : "en", "sourceLanguage" : "en",
"strings" : { "strings" : {
"" : {
},
"·" : { "·" : {
"comment" : "A period character used to separate different pieces of information in a list.", "comment" : "A period character used to separate different pieces of information in a list.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
@@ -73,6 +76,10 @@
"comment" : "A link to the help section of the app.", "comment" : "A link to the help section of the app.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Ao se cadastrar, você concorda com nossos [Termos de Uso](app://terms) e [Política de Privacidade](app://policy)" : {
"comment" : "A text that informs the user that registering implies acceptance of the app's terms of use and privacy policy. The text is clickable and navigates to the respective views when tapped.",
"isCommentAutoGenerated" : true
},
"Appearance" : { "Appearance" : {
"comment" : "Settings select label for the interface style of the controls (light, dark, or default)", "comment" : "Settings select label for the interface style of the controls (light, dark, or default)",
"extractionState" : "stale", "extractionState" : "stale",
@@ -103,8 +110,8 @@
} }
} }
}, },
"Cadastro" : { "Boas-vindas!" : {
"comment" : "A label displayed above the registration form.", "comment" : "A welcome message displayed in the login view.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Cancel" : { "Cancel" : {
@@ -137,10 +144,6 @@
} }
} }
}, },
"Canggu, Kuta Utara, Bali" : {
"comment" : "The address of the user's delivery location.",
"isCommentAutoGenerated" : true
},
"Carrinho" : { "Carrinho" : {
"comment" : "A label for the cart section of the app.", "comment" : "A label for the cart section of the app.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
@@ -149,10 +152,22 @@
"comment" : "A section header that lists available food categories.", "comment" : "A section header that lists available food categories.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Conteúdo da política de privacidade..." : {
"comment" : "A placeholder text describing the content of the privacy policy.",
"isCommentAutoGenerated" : true
},
"Conteúdo dos termos de uso..." : {
"comment" : "A placeholder text describing the content of the terms of use.",
"isCommentAutoGenerated" : true
},
"Criar conta" : { "Criar conta" : {
"comment" : "A link that navigates to the registration screen.", "comment" : "A link that navigates to the registration screen.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Crie sua conta" : {
"comment" : "A link that directs users to create an account.",
"isCommentAutoGenerated" : true
},
"Daniel Loverde" : { "Daniel Loverde" : {
"comment" : "The name of the user in the profile view header.", "comment" : "The name of the user in the profile view header.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
@@ -233,18 +248,22 @@
"comment" : "An abbreviation for \"Delivery Lady\" used in the user's profile picture circle.", "comment" : "An abbreviation for \"Delivery Lady\" used in the user's profile picture circle.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Email" : {
"comment" : "A text field for entering the user's email address.",
"isCommentAutoGenerated" : true
},
"Enderecos" : { "Enderecos" : {
"comment" : "A link to the user's address list.", "comment" : "A link to the user's address list.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Entrar" : {
"comment" : "A link that directs the user to the login screen.",
"isCommentAutoGenerated" : true
},
"Enviado para %@" : { "Enviado para %@" : {
"comment" : "A label below the \"OTP\" title that indicates the email to which the OTP was sent. The argument is the email address to which the OTP was sent.", "comment" : "A label below the \"OTP\" title that indicates the email to which the OTP was sent. The argument is the email address to which the OTP was sent.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Enviaremos um código de verificação por SMS \nou E-mail para confirmar seu acesso." : {
"comment" : "A description below the login fields, explaining that a verification code will be sent via SMS or email to confirm access.",
"isCommentAutoGenerated" : true
},
"Favorite" : { "Favorite" : {
"comment" : "Item editor title label for marking the item as a favorite", "comment" : "Item editor title label for marking the item as a favorite",
"extractionState" : "stale", "extractionState" : "stale",
@@ -338,6 +357,10 @@
} }
} }
}, },
"Já tem uma conta?" : {
"comment" : "A question displayed below the \"Cadastrar e Receber Código\" button in the registration view. The text is a hyperlink to the login view.",
"isCommentAutoGenerated" : true
},
"Light" : { "Light" : {
"comment" : "Menu item indicating that the appearance should be in light mode", "comment" : "Menu item indicating that the appearance should be in light mode",
"extractionState" : "stale", "extractionState" : "stale",
@@ -400,10 +423,6 @@
}, },
"Não tem conta ainda?" : { "Não tem conta ainda?" : {
},
"Nome" : {
"comment" : "A label for the user to input their name.",
"isCommentAutoGenerated" : true
}, },
"Notes" : { "Notes" : {
"comment" : "Item editor form label for the Notes field", "comment" : "Item editor form label for the Notes field",
@@ -435,6 +454,10 @@
} }
} }
}, },
"Novo por aqui?" : {
"comment" : "A text that appears at the bottom of the screen, inviting users to create an account.",
"isCommentAutoGenerated" : true
},
"O que vai querer pedir hoje?" : { "O que vai querer pedir hoje?" : {
"comment" : "A heading displayed above a search bar in the home view.", "comment" : "A heading displayed above a search bar in the home view.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
@@ -455,6 +478,10 @@
"comment" : "A label for the \"Profile\" tab in the main tab view.", "comment" : "A label for the \"Profile\" tab in the main tab view.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Política de Privacidade" : {
"comment" : "The title of the privacy policy section.",
"isCommentAutoGenerated" : true
},
"Powered by [Skip](https://skip.tools)" : { "Powered by [Skip](https://skip.tools)" : {
"comment" : "Link markdown text for the Powered by… label", "comment" : "Link markdown text for the Powered by… label",
"extractionState" : "stale", "extractionState" : "stale",
@@ -485,6 +512,18 @@
} }
} }
}, },
"Preencha os dados abaixo para começar." : {
"comment" : "A description below the form fields in the registration view, instructing the user to fill them out to start.",
"isCommentAutoGenerated" : true
},
"Privacidade" : {
"comment" : "The title of the privacy policy screen.",
"isCommentAutoGenerated" : true
},
"Receber Código" : {
"comment" : "A button label that says \"Receive Code\".",
"isCommentAutoGenerated" : true
},
"Save" : { "Save" : {
"comment" : "Button title indicating that the current contents should be saved", "comment" : "Button title indicating that the current contents should be saved",
"extractionState" : "stale", "extractionState" : "stale",
@@ -579,8 +618,8 @@
} }
} }
}, },
"Telefone" : { "Termos de Uso" : {
"comment" : "A label for a text field where a user can enter their phone number.", "comment" : "A link to the app's \"Terms of Use\".",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Title" : { "Title" : {

View File

@@ -0,0 +1,46 @@
import Foundation
#if os(iOS)
import CoreLocation
#endif
final class LocationService: NSObject {
#if os(iOS)
private let manager = CLLocationManager()
private var completion: ((Double, Double) -> Void)?
#endif
override init() {
super.init()
#if os(iOS)
manager.delegate = self
manager.desiredAccuracy = kCLLocationAccuracyHundredMeters
#endif
}
func requestLocation(_ completion: @escaping (Double, Double) -> Void) {
#if os(iOS)
self.completion = completion
manager.requestWhenInUseAuthorization()
manager.requestLocation()
#else
// Android: implement later with platform-specific bridge
_ = completion
#endif
}
}
#if os(iOS)
extension LocationService: CLLocationManagerDelegate {
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let location = locations.first else { return }
completion?(location.coordinate.latitude, location.coordinate.longitude)
completion = nil
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
// Silence for now; UI can handle missing location.
completion = nil
}
}
#endif

View File

@@ -0,0 +1,44 @@
import Foundation
struct AppState {
var session = SessionState()
var profile = ProfileState()
var cart = CartState()
var address = AddressState()
var favorites = FavoritesState()
}
struct SessionState {
var isAuthenticated: Bool = false
var jwt: String? = nil
}
struct ProfileState {
var id: String? = nil
var name: String = ""
var email: String = ""
var phone: String = ""
}
struct AddressState {
var selectedId: String? = nil
var display: String = "Rua das Flores, 123"
var latitude: Double? = nil
var longitude: Double? = nil
}
struct FavoritesState {
var storeIds: Set<String> = []
}
struct CartState {
var items: [CartItemState] = []
var total: Double = 0
}
struct CartItemState: Identifiable {
let id: String
var name: String
var quantity: Int
var price: Double
}

View File

@@ -1,13 +1,35 @@
import SwiftUI import SwiftUI
enum Route: Hashable {
case terms, policy
case registration, loginEmail
}
struct AuthFlowView: View { struct AuthFlowView: View {
@Binding var root: RootFlow @Binding var root: RootFlow
@Binding var selectedTab: MainTab @Binding var selectedTab: MainTab
let tokenStore: TokenStore let tokenStore: TokenStore
@Binding var appState: AppState
@State var path: [Route] = []
var body: some View { var body: some View {
NavigationStack { NavigationStack(path: $path) {
LoginView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore) LoginView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState) { 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)
}
}
} }
} }
} }
@@ -16,9 +38,10 @@ struct LoginView: View {
@Binding var root: RootFlow @Binding var root: RootFlow
@Binding var selectedTab: MainTab @Binding var selectedTab: MainTab
let tokenStore: TokenStore let tokenStore: TokenStore
@State var email = "" @Binding var appState: AppState
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
let navigate: (Route) -> Void
@ViewBuilder private var logoImage: some View { @ViewBuilder private var logoImage: some View {
#if os(Android) #if os(Android)
Image(colorScheme == .dark ? "pedifoods_dark" : "pedifoods", bundle: .module) Image(colorScheme == .dark ? "pedifoods_dark" : "pedifoods", bundle: .module)
@@ -40,59 +63,64 @@ struct LoginView: View {
} }
var body: some View { var body: some View {
ZStack(alignment: .top) { GeometryReader { geo in
(colorScheme == .dark ? AppColors.backgroundDark : AppColors.backgroundLight) let heroHeight = max(360, geo.size.height * 0.44)
.ignoresSafeArea()
pinHeroImage ZStack(alignment: .top) {
.scaledToFill() (colorScheme == .dark ? Color.black : AppColors.backgroundLight)
.frame(height: 520) .ignoresSafeArea()
.offset(y: -90)
.mask( pinHeroImage
LinearGradient( .scaledToFill()
colors: [.black, .black, .black.opacity(0.0)], .frame(height: heroHeight + 80)
startPoint: .top, .offset(y: -60)
endPoint: .bottom .mask(
LinearGradient(
colors: [.black, .black, .black.opacity(0.0)],
startPoint: .top,
endPoint: .bottom
)
) )
)
VStack(spacing: 20) { VStack(spacing: 18) {
Spacer().frame(height: 280) Spacer().frame(height: heroHeight - 100)
logoImage
.scaledToFit()
.frame(height: 200)
Text("Descubra lorem ipsum ba bla") logoImage
.font(AppTypography.heading1) .scaledToFit()
.foregroundStyle(colorScheme == .dark ? AppColors.textInverse : AppColors.textPrimary) .frame(height: 180)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 28)
Spacer()
PrimaryButton(title: "ENTRAR") {
// TODO: integrar com ApiService e OTP
tokenStore.jwt = "demo-token"
selectedTab = .home
root = .main
}
.padding(.horizontal, 28)
HStack(spacing: 6) { Text("Descubra lorem ipsum ba bla")
Text("Não tem conta ainda?") .font(AppTypography.heading1)
.foregroundStyle(colorScheme == .dark ? AppColors.textInverse.opacity(0.9) : AppColors.textPrimary) .foregroundStyle(colorScheme == .dark ? AppColors.textInverse : AppColors.textPrimary)
NavigationLink("Criar conta") { .multilineTextAlignment(.leading)
RegistrationView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore) .frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 28)
Spacer().frame(height: 40)
Button {
navigate(.loginEmail)
} label: {
PrimaryButtonLabel(title: "ENTRAR")
} }
.foregroundStyle(AppColors.primary) .padding(.horizontal, 28)
} .tint(AppColors.tertiary)
.font(AppTypography.body)
Spacer().frame(height: 24) 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()
} }
.ignoresSafeArea()
} }
} }
@@ -100,33 +128,228 @@ struct RegistrationView: View {
@Binding var root: RootFlow @Binding var root: RootFlow
@Binding var selectedTab: MainTab @Binding var selectedTab: MainTab
let tokenStore: TokenStore let tokenStore: TokenStore
@Binding var appState: AppState
@State var name = "" @State var name = ""
@State var email = "" @State var email = ""
@State var phone = "" @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 { var body: some View {
ScrollView { ZStack {
VStack(spacing: 20) { (colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea()
Text("Cadastro")
.font(AppTypography.heading1)
.foregroundStyle(AppColors.textPrimary)
VStack(spacing: 12) { ScrollView {
TextField("Nome", text: $name) VStack(spacing: 0) {
.textFieldStyle(.roundedBorder) logoImage
TextField("Email", text: $email) .scaledToFit()
.textFieldStyle(.roundedBorder) .frame(width: 120, height: 120)
TextField("Telefone", text: $phone)
.textFieldStyle(.roundedBorder)
PrimaryButton(title: "Enviar OTP") { Text("Crie sua conta")
// TODO: solicitar OTP .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.secondary)
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(24) .padding(.top, -40)
} }
.background(AppColors.backgroundLight) }
}
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)
)
} }
} }
@@ -137,13 +360,52 @@ struct OtpView: View {
VStack(spacing: 16) { VStack(spacing: 16) {
Text("OTP") Text("OTP")
.font(AppTypography.heading1) .font(AppTypography.heading1)
.foregroundStyle(AppColors.textPrimary) .foregroundStyle(Color.white)
Text("Enviado para \(email)") Text("Enviado para \(email)")
.font(.caption) .font(.caption)
.foregroundStyle(AppColors.secondary) .foregroundStyle(Color.white.opacity(0.8))
} }
.padding(24) .padding(24)
.background(AppColors.backgroundLight) .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)
}
}

View File

@@ -0,0 +1,172 @@
# Mobile — SwiftUI
## Etapa 01 - Contexto e Objetivos
O objetivo é criar uma base mínima para um aplicativo de pedidos com autenticação via login, cadastro e código OTP.
----
## Etapa 02 — Esqueleto do Projeto e Views
- Criar projeto SwiftUI.
- Criar NavigationStack e views LoginView, RegistrationView e OtpView.
- Navegar entre telas.
----
## Etapa 03 — LoginView
- Logo e título "Boas-vindas!".
- Descrição "Entre para continuar seu pedido.".
- Inputs para e-mail e telefone.
- Botão "Receber Código".
- Link "Crie sua conta".
----
## Etapa 04 — RegistrationView
- Título "Crie sua conta".
- Descrição "Preencha os dados abaixo...".
- Inputs nome, e-mail e telefone.
- Termos com switch.
- Botão "Cadastrar e Receber Código".
- Link "Já tem uma conta? Entrar".
----
## Etapa 05 — OtpView
- Título "OTP".
- Texto "Enviado para <email>".
- Inputs para código OTP.
- Botão para enviar código.
- Ação "Reenviar código".
----
## Etapa 06 — View Models
- Criar view models para cada view.
- Validar campos básicos.
- Gerenciar estados de loading e erros.
----
## Etapa 07 — Validações e Máscaras
- Validar e-mail válido.
- Máscara para telefone.
- Limitar número de dígitos do OTP.
----
## Etapa 08 — Estados e Feedbacks
- Mostrar loading no botão.
- Desabilitar botão se inválido.
- Feedback de erros inline.
----
## Etapa 09 — Acessibilidade
- Labels claros.
- Áreas de toque adequadas.
- Suporte a Dynamic Type.
----
## Etapa 10 — Dark Mode
- Cores adaptadas para modo escuro.
- Testar contrastes e legibilidade.
----
## Etapa 11 — Ajustes de UI (referencias)
- Espaçamentos consistentes.
- Tipografia alinhada ao design system.
- Botões com padding padrão.
- Estados visuais claros.
----
## Etapa 11 — Checklist de Refinamento (Autenticação)
### Itens Globais
- [ ] Espaçamentos consistentes (8 / 16 / 24) entre títulos, descrições, inputs e botões.
- [ ] Cores alinhadas ao design system (backgroundLight/dark, textPrimary, primary para links/CTAs).
- [ ] Tipografia: heading1 para títulos, body para textos e labels, botão usando componente padrão.
- [ ] Estados: carregamento, desabilitado (opacidade), erro/validação inline quando necessário.
- [ ] Acessibilidade: áreas de toque ≥ 44x44, labels claros, suporte a tamanhos dinâmicos de fonte.
### Login (LoginView)
- [ ] Respiro após logo (≥ 16pt) e título “Boasvindas!” com heading1.
- [ ] Descrição “Entre para continuar seu pedido.” com cor secundária e bom contraste no dark mode.
- [ ] Inputs “Email” e “Telefone” com `LoginField` padronizado; autocapitalization off (ok).
- [ ] (Opcional) Validação leve: email válido e telefone numérico/máscara.
- [ ] CTA “Receber Código” com padding horizontal 24 e top 816; estado desabilitado se necessário.
- [ ] Texto de ajuda centralizado com padding horizontal 40.
- [ ] Link “Crie sua conta” com espaçamento 816 do texto anterior.
### Cadastro (RegistrationView)
- [ ] Título “Crie sua conta” com heading1.
- [ ] Descrição “Preencha os dados abaixo...” com body, padding top 8 e bottom 20 (ok).
- [ ] Inputs: Nome, Email, Telefone com labels; ajustar keyboardType para email/telefone.
- [ ] Termos: frase contínua sem quebras estranhas; links “Termos de Uso” e “Política de Privacidade” em primary.
- [ ] Switch dos termos posicionado à direita (ok).
- [ ] CTA “Cadastrar e Receber Código” desabilitado até Nome/Email/Telefone preenchidos e switch ativado (ok); opacidade 0.5 quando desabilitado.
- [ ] Bloco “Já tem uma conta? Entrar” com espaçamento superior 16 (ok).
- [ ] Verificar contraste dos inputs e textos em dark mode.
### OTP (OtpView)
- [ ] Título “OTP” com heading1 e texto “Enviado para <email>”.
- [ ] Inputs para 68 dígitos (conforme definição final); foco automático no primeiro campo.
- [ ] CTA desabilitado até todos os dígitos preenchidos.
- [ ] Ação “Reenviar código” com timeout/contador.
- [ ] Estados de erro (código inválido/expirado) e mensagens claras.
### Decisões em Aberto
- [ ] Termos/Privacidade: navegação interna (views com conteúdo) ou URLs externas (Link/SFSafariView)?
- [ ] Validações: mínima (campos preenchidos) ou reforçada (formato + mensagens inline)?
- [ ] Máscara de telefone: aplicar padrão BR (ex.: (11) 988887777)?
- [ ] Dark mode: paleta de cinzas/texto secundário para contraste ideal.
## Progresso — Etapa 11 (Autenticação)
Data de atualização: 2026-02-05
### Itens Globais
- [x] Espaçamentos consistentes básicos nas telas de Login e Cadastro.
- [x] Cores e tipografia aplicadas via AppColors/AppTypography.
- [ ] Revisão completa de dark mode (contraste de textos secundários).
- [ ] Estados de carregamento e mensagens de erro padronizados.
### Login (LoginView)
- [x] Estrutura com logo, título e descrição.
- [x] Inputs padronizados (LoginField) para email e telefone.
- [ ] Validação leve (formato de email/telefone) e mensagens inline.
- [ ] Estado desabilitado do CTA quando inputs vazios (opcional nesta etapa).
### Cadastro (RegistrationView)
- [x] Título e descrição com espaçamentos ajustados.
- [x] Inputs com labels e `LoginField`.
- [x] Termos com texto contínuo; links para “Termos de Uso” e “Política de Privacidade”.
- [x] Switch dos termos à direita.
- [x] CTA desabilitado até Nome/Email/Telefone preenchidos e switch ativado.
- [x] Espaçamento superior 16 no bloco “Já tem uma conta? Entrar”.
- [ ] Ajustar keyboardType para email/telefone e (opcional) máscara BR.
### OTP (OtpView)
- [x] Estrutura básica com título e indicação de email.
- [ ] Campos de dígitos (68) com foco e validação.
- [ ] CTA desabilitado até preenchimento completo.
- [ ] Reenvio de código com contador.
### Decisões em Aberto
- [ ] Termos/Privacidade: interno vs. URL externa.
- [ ] Validações: mínima vs. reforçada (com mensagens inline).
- [ ] Máscara de telefone: padrão BR.
- [ ] Dark mode: paleta de cinzas/texto secundário.

View File

@@ -0,0 +1,135 @@
# Mobile — SwiftUI
## Etapa 01 - Contexto e Objetivos
O objetivo é criar uma base mínima para um aplicativo de pedidos com autenticação via login, cadastro e código OTP.
----
## Etapa 02 — Esqueleto do Projeto e Views
- Criar projeto SwiftUI.
- Criar NavigationStack e views LoginView, RegistrationView e OtpView.
- Navegar entre telas.
----
## Etapa 03 — LoginView
- Logo e título "Boas-vindas!".
- Descrição "Entre para continuar seu pedido.".
- Inputs para e-mail e telefone.
- Botão "Receber Código".
- Link "Crie sua conta".
----
## Etapa 04 — RegistrationView
- Título "Crie sua conta".
- Descrição "Preencha os dados abaixo...".
- Inputs nome, e-mail e telefone.
- Termos com switch.
- Botão "Cadastrar e Receber Código".
- Link "Já tem uma conta? Entrar".
----
## Etapa 05 — OtpView
- Título "OTP".
- Texto "Enviado para <email>".
- Inputs para código OTP.
- Botão para enviar código.
- Ação "Reenviar código".
----
## Etapa 06 — View Models
- Criar view models para cada view.
- Validar campos básicos.
- Gerenciar estados de loading e erros.
----
## Etapa 07 — Validações e Máscaras
- Validar e-mail válido.
- Máscara para telefone.
- Limitar número de dígitos do OTP.
----
## Etapa 08 — Estados e Feedbacks
- Mostrar loading no botão.
- Desabilitar botão se inválido.
- Feedback de erros inline.
----
## Etapa 09 — Acessibilidade
- Labels claros.
- Áreas de toque adequadas.
- Suporte a Dynamic Type.
----
## Etapa 10 — Dark Mode
- Cores adaptadas para modo escuro.
- Testar contrastes e legibilidade.
----
## Etapa 11 — Ajustes de UI (referencias)
- Espaçamentos consistentes.
- Tipografia alinhada ao design system.
- Botões com padding padrão.
- Estados visuais claros.
----
## Etapa 11 — Checklist de Refinamento (Autenticação)
### Itens Globais
- [ ] Espaçamentos consistentes (8 / 16 / 24) entre títulos, descrições, inputs e botões.
- [ ] Cores alinhadas ao design system (backgroundLight/dark, textPrimary, primary para links/CTAs).
- [ ] Tipografia: heading1 para títulos, body para textos e labels, botão usando componente padrão.
- [ ] Estados: carregamento, desabilitado (opacidade), erro/validação inline quando necessário.
- [ ] Acessibilidade: áreas de toque ≥ 44x44, labels claros, suporte a tamanhos dinâmicos de fonte.
### Login (LoginView)
- [ ] Respiro após logo (≥ 16pt) e título “Boasvindas!” com heading1.
- [ ] Descrição “Entre para continuar seu pedido.” com cor secundária e bom contraste no dark mode.
- [ ] Inputs “Email” e “Telefone” com `LoginField` padronizado; autocapitalization off (ok).
- [ ] (Opcional) Validação leve: email válido e telefone numérico/máscara.
- [ ] CTA “Receber Código” com padding horizontal 24 e top 816; estado desabilitado se necessário.
- [ ] Texto de ajuda centralizado com padding horizontal 40.
- [ ] Link “Crie sua conta” com espaçamento 816 do texto anterior.
### Cadastro (RegistrationView)
- [ ] Título “Crie sua conta” com heading1.
- [ ] Descrição “Preencha os dados abaixo...” com body, padding top 8 e bottom 20 (ok).
- [ ] Inputs: Nome, Email, Telefone com labels; ajustar keyboardType para email/telefone.
- [ ] Termos: frase contínua sem quebras estranhas; links “Termos de Uso” e “Política de Privacidade” em primary.
- [ ] Switch dos termos posicionado à direita (ok).
- [ ] CTA “Cadastrar e Receber Código” desabilitado até Nome/Email/Telefone preenchidos e switch ativado (ok); opacidade 0.5 quando desabilitado.
- [ ] Bloco “Já tem uma conta? Entrar” com espaçamento superior 16 (ok).
- [ ] Verificar contraste dos inputs e textos em dark mode.
### OTP (OtpView)
- [ ] Título “OTP” com heading1 e texto “Enviado para <email>”.
- [ ] Inputs para 68 dígitos (conforme definição final); foco automático no primeiro campo.
- [ ] CTA desabilitado até todos os dígitos preenchidos.
- [ ] Ação “Reenviar código” com timeout/contador.
- [ ] Estados de erro (código inválido/expirado) e mensagens claras.
### Decisões em Aberto
- [ ] Termos/Privacidade: navegação interna (views com conteúdo) ou URLs externas (Link/SFSafariView)?
- [ ] Validações: mínima (campos preenchidos) ou reforçada (formato + mensagens inline)?
- [ ] Máscara de telefone: aplicar padrão BR (ex.: (11) 988887777)?
- [ ] Dark mode: paleta de cinzas/texto secundário para contraste ideal.

View File

@@ -4,9 +4,12 @@ import UIKit
#endif #endif
struct HomeView: View { struct HomeView: View {
@Binding var appState: AppState
@State var searchText = "" @State var searchText = ""
@State var selectedCategory = "Stores" @State var selectedCategory = "Stores"
@State var scrollOffset: CGFloat = 0 @State var scrollOffset: CGFloat = 0
@State var hasRequestedLocation = false
let locationService = LocationService()
private let categories: [CategoryModel] = [ private let categories: [CategoryModel] = [
.init(title: "Stores", systemIcon: "storefront"), .init(title: "Stores", systemIcon: "storefront"),
@@ -104,6 +107,15 @@ struct HomeView: View {
.background(AppColors.backgroundLight) .background(AppColors.backgroundLight)
.ignoresSafeArea(edges: .top) .ignoresSafeArea(edges: .top)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.onAppear {
if hasRequestedLocation == false {
hasRequestedLocation = true
locationService.requestLocation { lat, lng in
appState.address.latitude = lat
appState.address.longitude = lng
}
}
}
} }
private var contentStack: some View { private var contentStack: some View {
@@ -175,7 +187,7 @@ struct HomeView: View {
.foregroundStyle(AppColors.brandSoft) .foregroundStyle(AppColors.brandSoft)
HStack(spacing: 6) { HStack(spacing: 6) {
Text("Canggu, Kuta Utara, Bali") Text(appState.address.display)
.font(AppTypography.heading3) .font(AppTypography.heading3)
.foregroundStyle(AppColors.textInverse) .foregroundStyle(AppColors.textInverse)
Image(systemName: "chevron.down") Image(systemName: "chevron.down")

View File

@@ -4,11 +4,12 @@ struct MainTabView: View {
@Binding var selectedTab: MainTab @Binding var selectedTab: MainTab
@Binding var root: RootFlow @Binding var root: RootFlow
let tokenStore: TokenStore let tokenStore: TokenStore
@Binding var appState: AppState
var body: some View { var body: some View {
TabView(selection: $selectedTab) { TabView(selection: $selectedTab) {
NavigationStack { NavigationStack {
HomeView() HomeView(appState: $appState)
} }
.tabItem { Label("Home", systemImage: "house.fill") } .tabItem { Label("Home", systemImage: "house.fill") }
.tag(MainTab.home) .tag(MainTab.home)
@@ -20,7 +21,7 @@ struct MainTabView: View {
.tag(MainTab.cart) .tag(MainTab.cart)
NavigationStack { NavigationStack {
ProfileView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore) ProfileView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState)
} }
.tabItem { Label("Perfil", systemImage: "person.fill") } .tabItem { Label("Perfil", systemImage: "person.fill") }
.tag(MainTab.profile) .tag(MainTab.profile)

View File

@@ -4,6 +4,7 @@ struct ProfileView: View {
@Binding var root: RootFlow @Binding var root: RootFlow
@Binding var selectedTab: MainTab @Binding var selectedTab: MainTab
let tokenStore: TokenStore let tokenStore: TokenStore
@Binding var appState: AppState
var body: some View { var body: some View {
VStack(spacing: 20) { VStack(spacing: 20) {
@@ -29,6 +30,8 @@ struct ProfileView: View {
SecondaryButton(title: "Sair") { SecondaryButton(title: "Sair") {
tokenStore.clear() tokenStore.clear()
appState.session.isAuthenticated = false
appState.session.jwt = nil
root = .auth root = .auth
} }
.padding(.horizontal, 20) .padding(.horizontal, 20)
@@ -49,11 +52,11 @@ struct ProfileView: View {
.foregroundStyle(AppColors.textInverse) .foregroundStyle(AppColors.textInverse)
) )
Text("Daniel Loverde") Text(appState.profile.name.isEmpty ? "Daniel Loverde" : appState.profile.name)
.font(AppTypography.heading2) .font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary) .foregroundStyle(AppColors.textPrimary)
Text("daniel@pedifoods.com.br") Text(appState.profile.email.isEmpty ? "daniel@pedifoods.com.br" : appState.profile.email)
.font(.caption) .font(.caption)
.foregroundStyle(AppColors.secondary) .foregroundStyle(AppColors.secondary)
} }

View File

@@ -80,3 +80,11 @@
4. Build iOS e Android 4. Build iOS e Android
5. Export APK/AAB 5. Export APK/AAB
## Etapa 11 — Ajustes de UI (referencias)
1. Login (light/dark)
2. Cadastro
3. OTP
4. Carrinho
5. Checkout
6. Perfil
7. Pedidos