Login flow fixed
This commit is contained in:
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" : {
|
||||||
|
|||||||
46
pedi-foods/Sources/PediFoods/Services/LocationService.swift
Normal file
46
pedi-foods/Sources/PediFoods/Services/LocationService.swift
Normal 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
|
||||||
44
pedi-foods/Sources/PediFoods/State/AppState.swift
Normal file
44
pedi-foods/Sources/PediFoods/State/AppState.swift
Normal 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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
172
pedi-foods/Sources/PediFoods/Views/Auth/pedifoods_app_plan 2.md
Normal file
172
pedi-foods/Sources/PediFoods/Views/Auth/pedifoods_app_plan 2.md
Normal 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 “Boas‑vindas!” com heading1.
|
||||||
|
- [ ] Descrição “Entre para continuar seu pedido.” com cor secundária e bom contraste no dark mode.
|
||||||
|
- [ ] Inputs “E‑mail” e “Telefone” com `LoginField` padronizado; autocapitalization off (ok).
|
||||||
|
- [ ] (Opcional) Validação leve: e‑mail válido e telefone numérico/máscara.
|
||||||
|
- [ ] CTA “Receber Código” com padding horizontal 24 e top 8–16; estado desabilitado se necessário.
|
||||||
|
- [ ] Texto de ajuda centralizado com padding horizontal 40.
|
||||||
|
- [ ] Link “Crie sua conta” com espaçamento 8–16 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, E‑mail, Telefone com labels; ajustar keyboardType para e‑mail/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/E‑mail/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 6–8 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) 98888‑7777)?
|
||||||
|
- [ ] 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 e‑mail e telefone.
|
||||||
|
- [ ] Validação leve (formato de e‑mail/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/E‑mail/Telefone preenchidos e switch ativado.
|
||||||
|
- [x] Espaçamento superior 16 no bloco “Já tem uma conta? Entrar”.
|
||||||
|
- [ ] Ajustar keyboardType para e‑mail/telefone e (opcional) máscara BR.
|
||||||
|
|
||||||
|
### OTP (OtpView)
|
||||||
|
- [x] Estrutura básica com título e indicação de e‑mail.
|
||||||
|
- [ ] Campos de dígitos (6–8) 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.
|
||||||
135
pedi-foods/Sources/PediFoods/Views/Auth/pedifoods_app_plan.md
Normal file
135
pedi-foods/Sources/PediFoods/Views/Auth/pedifoods_app_plan.md
Normal 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 “Boas‑vindas!” com heading1.
|
||||||
|
- [ ] Descrição “Entre para continuar seu pedido.” com cor secundária e bom contraste no dark mode.
|
||||||
|
- [ ] Inputs “E‑mail” e “Telefone” com `LoginField` padronizado; autocapitalization off (ok).
|
||||||
|
- [ ] (Opcional) Validação leve: e‑mail válido e telefone numérico/máscara.
|
||||||
|
- [ ] CTA “Receber Código” com padding horizontal 24 e top 8–16; estado desabilitado se necessário.
|
||||||
|
- [ ] Texto de ajuda centralizado com padding horizontal 40.
|
||||||
|
- [ ] Link “Crie sua conta” com espaçamento 8–16 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, E‑mail, Telefone com labels; ajustar keyboardType para e‑mail/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/E‑mail/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 6–8 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) 98888‑7777)?
|
||||||
|
- [ ] Dark mode: paleta de cinzas/texto secundário para contraste ideal.
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user