diff --git a/pedi-foods/Sources/PediFoods/Components/Buttons.swift b/pedi-foods/Sources/PediFoods/Components/Buttons.swift index 6b88292..3201097 100644 --- a/pedi-foods/Sources/PediFoods/Components/Buttons.swift +++ b/pedi-foods/Sources/PediFoods/Components/Buttons.swift @@ -3,41 +3,69 @@ import SwiftUI struct PrimaryButton: View { let title: String var fullWidth: Bool = true + var image: Image? = nil let action: @MainActor @Sendable () -> Void var body: some View { Button(action: { action() }) { - 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)) + HStack { + Text(title) + .font(AppTypography.button) + .tracking(AppTypography.buttonLetterSpacing) + if let image { + image + } + } + .foregroundStyle(AppColors.textInverse) + .frame(maxWidth: fullWidth ? .infinity : nil) + .frame(height: 56) + .background(AppColors.primary) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) } .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 { let title: String var fullWidth: Bool = true + var image: Image? = nil let action: @MainActor @Sendable () -> Void var body: some View { Button(action: { action() }) { - Text(title) - .font(AppTypography.button) - .tracking(AppTypography.buttonLetterSpacing) - .foregroundStyle(AppColors.primary) - .frame(maxWidth: fullWidth ? .infinity : nil) - .frame(height: 56) - .background(AppColors.backgroundLight) - .overlay( - RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous) - .stroke(AppColors.primary.opacity(0.3), lineWidth: 1) - ) + HStack { + Text(title) + .font(AppTypography.button) + .tracking(AppTypography.buttonLetterSpacing) + if let image { + image + } + } + .foregroundStyle(AppColors.primary) + .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) } diff --git a/pedi-foods/Sources/PediFoods/ContentView.swift b/pedi-foods/Sources/PediFoods/ContentView.swift index f62f516..c7574f5 100644 --- a/pedi-foods/Sources/PediFoods/ContentView.swift +++ b/pedi-foods/Sources/PediFoods/ContentView.swift @@ -4,13 +4,14 @@ struct ContentView: View { @State var root: RootFlow = DefaultTokenStore().jwt == nil ? .auth : .main @State var selectedTab: MainTab = .home private let tokenStore: TokenStore = DefaultTokenStore() + @State var appState = AppState() var body: some View { switch root { case .auth: - AuthFlowView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore) + AuthFlowView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState) case .main: - MainTabView(selectedTab: $selectedTab, root: $root, tokenStore: tokenStore) + MainTabView(selectedTab: $selectedTab, root: $root, tokenStore: tokenStore, appState: $appState) } } } diff --git a/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings b/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings index 83659ca..4f7f85c 100644 --- a/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings +++ b/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings @@ -1,6 +1,9 @@ { "sourceLanguage" : "en", "strings" : { + "" : { + + }, "·" : { "comment" : "A period character used to separate different pieces of information in a list.", "isCommentAutoGenerated" : true @@ -73,6 +76,10 @@ "comment" : "A link to the help section of the app.", "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" : { "comment" : "Settings select label for the interface style of the controls (light, dark, or default)", "extractionState" : "stale", @@ -103,8 +110,8 @@ } } }, - "Cadastro" : { - "comment" : "A label displayed above the registration form.", + "Boas-vindas!" : { + "comment" : "A welcome message displayed in the login view.", "isCommentAutoGenerated" : true }, "Cancel" : { @@ -137,10 +144,6 @@ } } }, - "Canggu, Kuta Utara, Bali" : { - "comment" : "The address of the user's delivery location.", - "isCommentAutoGenerated" : true - }, "Carrinho" : { "comment" : "A label for the cart section of the app.", "isCommentAutoGenerated" : true @@ -149,10 +152,22 @@ "comment" : "A section header that lists available food categories.", "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" : { "comment" : "A link that navigates to the registration screen.", "isCommentAutoGenerated" : true }, + "Crie sua conta" : { + "comment" : "A link that directs users to create an account.", + "isCommentAutoGenerated" : true + }, "Daniel Loverde" : { "comment" : "The name of the user in the profile view header.", "isCommentAutoGenerated" : true @@ -233,18 +248,22 @@ "comment" : "An abbreviation for \"Delivery Lady\" used in the user's profile picture circle.", "isCommentAutoGenerated" : true }, - "Email" : { - "comment" : "A text field for entering the user's email address.", - "isCommentAutoGenerated" : true - }, "Enderecos" : { "comment" : "A link to the user's address list.", "isCommentAutoGenerated" : true }, + "Entrar" : { + "comment" : "A link that directs the user to the login screen.", + "isCommentAutoGenerated" : true + }, "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.", "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" : { "comment" : "Item editor title label for marking the item as a favorite", "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" : { "comment" : "Menu item indicating that the appearance should be in light mode", "extractionState" : "stale", @@ -400,10 +423,6 @@ }, "Não tem conta ainda?" : { - }, - "Nome" : { - "comment" : "A label for the user to input their name.", - "isCommentAutoGenerated" : true }, "Notes" : { "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?" : { "comment" : "A heading displayed above a search bar in the home view.", "isCommentAutoGenerated" : true @@ -455,6 +478,10 @@ "comment" : "A label for the \"Profile\" tab in the main tab view.", "isCommentAutoGenerated" : true }, + "Política de Privacidade" : { + "comment" : "The title of the privacy policy section.", + "isCommentAutoGenerated" : true + }, "Powered by [Skip](https://skip.tools)" : { "comment" : "Link markdown text for the Powered by… label", "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" : { "comment" : "Button title indicating that the current contents should be saved", "extractionState" : "stale", @@ -579,8 +618,8 @@ } } }, - "Telefone" : { - "comment" : "A label for a text field where a user can enter their phone number.", + "Termos de Uso" : { + "comment" : "A link to the app's \"Terms of Use\".", "isCommentAutoGenerated" : true }, "Title" : { diff --git a/pedi-foods/Sources/PediFoods/Services/LocationService.swift b/pedi-foods/Sources/PediFoods/Services/LocationService.swift new file mode 100644 index 0000000..94af9f8 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Services/LocationService.swift @@ -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 diff --git a/pedi-foods/Sources/PediFoods/State/AppState.swift b/pedi-foods/Sources/PediFoods/State/AppState.swift new file mode 100644 index 0000000..e76d477 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/State/AppState.swift @@ -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 = [] +} + +struct CartState { + var items: [CartItemState] = [] + var total: Double = 0 +} + +struct CartItemState: Identifiable { + let id: String + var name: String + var quantity: Int + var price: Double +} diff --git a/pedi-foods/Sources/PediFoods/Views/Auth/AuthFlowView.swift b/pedi-foods/Sources/PediFoods/Views/Auth/AuthFlowView.swift index df6f41d..b50ce23 100644 --- a/pedi-foods/Sources/PediFoods/Views/Auth/AuthFlowView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Auth/AuthFlowView.swift @@ -1,13 +1,35 @@ import SwiftUI +enum Route: Hashable { + case terms, policy + case registration, loginEmail +} + struct AuthFlowView: View { @Binding var root: RootFlow @Binding var selectedTab: MainTab let tokenStore: TokenStore + @Binding var appState: AppState + + @State var path: [Route] = [] var body: some View { - NavigationStack { - LoginView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore) + NavigationStack(path: $path) { + 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 selectedTab: MainTab let tokenStore: TokenStore - @State var email = "" + @Binding var appState: AppState @Environment(\.colorScheme) var colorScheme - + let navigate: (Route) -> Void + @ViewBuilder private var logoImage: some View { #if os(Android) Image(colorScheme == .dark ? "pedifoods_dark" : "pedifoods", bundle: .module) @@ -40,59 +63,64 @@ struct LoginView: View { } var body: some View { - ZStack(alignment: .top) { - (colorScheme == .dark ? AppColors.backgroundDark : AppColors.backgroundLight) - .ignoresSafeArea() + GeometryReader { geo in + let heroHeight = max(360, geo.size.height * 0.44) - pinHeroImage - .scaledToFill() - .frame(height: 520) - .offset(y: -90) - .mask( - LinearGradient( - colors: [.black, .black, .black.opacity(0.0)], - startPoint: .top, - endPoint: .bottom + ZStack(alignment: .top) { + (colorScheme == .dark ? Color.black : AppColors.backgroundLight) + .ignoresSafeArea() + + pinHeroImage + .scaledToFill() + .frame(height: heroHeight + 80) + .offset(y: -60) + .mask( + LinearGradient( + colors: [.black, .black, .black.opacity(0.0)], + startPoint: .top, + endPoint: .bottom + ) ) - ) - VStack(spacing: 20) { - Spacer().frame(height: 280) - logoImage - .scaledToFit() - .frame(height: 200) + VStack(spacing: 18) { + Spacer().frame(height: heroHeight - 100) - Text("Descubra lorem ipsum ba bla") - .font(AppTypography.heading1) - .foregroundStyle(colorScheme == .dark ? AppColors.textInverse : AppColors.textPrimary) - .multilineTextAlignment(.leading) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 28) - - Spacer() - - PrimaryButton(title: "ENTRAR") { - // TODO: integrar com ApiService e OTP - tokenStore.jwt = "demo-token" - selectedTab = .home - root = .main - } - .padding(.horizontal, 28) + logoImage + .scaledToFit() + .frame(height: 180) - HStack(spacing: 6) { - Text("Não tem conta ainda?") - .foregroundStyle(colorScheme == .dark ? AppColors.textInverse.opacity(0.9) : AppColors.textPrimary) - NavigationLink("Criar conta") { - RegistrationView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore) + Text("Descubra lorem ipsum ba bla") + .font(AppTypography.heading1) + .foregroundStyle(colorScheme == .dark ? AppColors.textInverse : AppColors.textPrimary) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 28) + + Spacer().frame(height: 40) + + Button { + navigate(.loginEmail) + } label: { + PrimaryButtonLabel(title: "ENTRAR") } - .foregroundStyle(AppColors.primary) - } - .font(AppTypography.body) + .padding(.horizontal, 28) + .tint(AppColors.tertiary) - 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 selectedTab: MainTab let tokenStore: TokenStore + @Binding var appState: AppState @State var name = "" @State var email = "" @State var phone = "" + @State var acceptedTerms = false + @Environment(\.dismiss) var dismiss + @Environment(\.colorScheme) var colorScheme + let navigate: (Route) -> Void + + private var isFormValid: Bool { !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !phone.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && acceptedTerms } + + @ViewBuilder private var logoImage: some View { +#if os(Android) + Image(colorScheme == .dark ? "pedifoods_dark" : "pedifoods", bundle: .module) + .resizable() +#else + SwiftUI.Image("pedifoods") + .resizable() +#endif + } var body: some View { - ScrollView { - VStack(spacing: 20) { - Text("Cadastro") - .font(AppTypography.heading1) - .foregroundStyle(AppColors.textPrimary) + ZStack { + (colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea() - VStack(spacing: 12) { - TextField("Nome", text: $name) - .textFieldStyle(.roundedBorder) - TextField("Email", text: $email) - .textFieldStyle(.roundedBorder) - TextField("Telefone", text: $phone) - .textFieldStyle(.roundedBorder) + ScrollView { + VStack(spacing: 0) { + logoImage + .scaledToFit() + .frame(width: 120, height: 120) - PrimaryButton(title: "Enviar OTP") { - // TODO: solicitar OTP + Text("Crie sua conta") + .font(AppTypography.heading1) + .foregroundStyle(colorScheme == .dark ? Color.white : AppColors.textPrimary) + + Text("Preencha os dados abaixo para começar.") + .font(AppTypography.body) + .foregroundStyle(colorScheme == .dark ? Color.white : AppColors.textPrimary) + .padding(.top, 8) + .padding(.bottom, 20) + + VStack(spacing: 16) { + VStack(alignment: .leading, spacing: 16) { + LoginField(icon: "person", placeholder: "Ex: Maria Silva", text: $name) + LoginField(icon: "envelope", placeholder: "seu@email.com", text: $email) + LoginField(icon: "phone", placeholder: "(00) 00000-0000", text: $phone) + } } + .padding(.horizontal, 24) + + HStack(alignment: .top, spacing: 12) { + + Toggle("", isOn: $acceptedTerms) + .labelsHidden() + .toggleStyle(SwitchToggleStyle(tint: AppColors.primary)) + + Group { + Text("Ao se cadastrar, você concorda com nossos [Termos de Uso](app://terms) e [Política de Privacidade](app://policy)") + .foregroundStyle(colorScheme == .dark ? Color.white : AppColors.textPrimary) + .tint(AppColors.primary) + .environment(\.openURL, OpenURLAction { url in + guard url.scheme == "app" else { return .handled } + + switch url.host { + case "terms": + navigate(.terms) + return .handled + case "policy": + navigate(.policy) + return .handled + default: + return .handled + } + }) + } + .multilineTextAlignment(.leading) + } + .padding(.horizontal, 24) + .padding(.top, 16) + .padding(.bottom, 16) + + PrimaryButton(title: "Cadastrar e Receber Código", image: Image(systemName: "arrow.right")) { + // TODO: integrar com ApiService e OTP + tokenStore.jwt = "demo-token" + appState.session.isAuthenticated = true + appState.session.jwt = "demo-token" + appState.profile.email = email + selectedTab = .home + root = .main + } + .padding(.horizontal, 24) + .padding(.top, 6) + .disabled(!isFormValid) + .opacity(isFormValid ? 1.0 : 0.5) + .tint(AppColors.tertiary) + + HStack(spacing: 6) { + Text("Já tem uma conta?") + .foregroundStyle(colorScheme == .dark ? Color.white.opacity(0.8) : AppColors.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) { Text("OTP") .font(AppTypography.heading1) - .foregroundStyle(AppColors.textPrimary) + .foregroundStyle(Color.white) Text("Enviado para \(email)") .font(.caption) - .foregroundStyle(AppColors.secondary) + .foregroundStyle(Color.white.opacity(0.8)) } .padding(24) .background(AppColors.backgroundLight) + .ignoresSafeArea() } } +struct TermsOfUseView: View { + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + Text("Termos de Uso") + .font(AppTypography.heading1) + .foregroundStyle(AppColors.textPrimary) + Text("Conteúdo dos termos de uso...") + .font(AppTypography.body) + .foregroundStyle(AppColors.textPrimary) + } + .padding(24) + } + .background((Color(UIColor { trait in trait.userInterfaceStyle == .dark ? .black : UIColor(AppColors.backgroundLight) })).ignoresSafeArea()) + .navigationTitle("Termos de Uso") + .navigationBarTitleDisplayMode(.inline) + } +} + +struct PrivacyPolicyView: View { + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + Text("Política de Privacidade") + .font(AppTypography.heading1) + .foregroundStyle(AppColors.textPrimary) + Text("Conteúdo da política de privacidade...") + .font(AppTypography.body) + .foregroundStyle(AppColors.textPrimary) + } + .padding(24) + } + .background((Color(UIColor { trait in trait.userInterfaceStyle == .dark ? .black : UIColor(AppColors.backgroundLight) })).ignoresSafeArea()) + .navigationTitle("Privacidade") + .navigationBarTitleDisplayMode(.inline) + } +} + diff --git a/pedi-foods/Sources/PediFoods/Views/Auth/pedifoods_app_plan 2.md b/pedi-foods/Sources/PediFoods/Views/Auth/pedifoods_app_plan 2.md new file mode 100644 index 0000000..9b345be --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Auth/pedifoods_app_plan 2.md @@ -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 ". +- 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 ”. +- [ ] 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. diff --git a/pedi-foods/Sources/PediFoods/Views/Auth/pedifoods_app_plan.md b/pedi-foods/Sources/PediFoods/Views/Auth/pedifoods_app_plan.md new file mode 100644 index 0000000..52be0b2 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Auth/pedifoods_app_plan.md @@ -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 ". +- 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 ”. +- [ ] 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. diff --git a/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift b/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift index 4815237..e747ace 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift @@ -4,9 +4,12 @@ import UIKit #endif struct HomeView: View { + @Binding var appState: AppState @State var searchText = "" @State var selectedCategory = "Stores" @State var scrollOffset: CGFloat = 0 + @State var hasRequestedLocation = false + let locationService = LocationService() private let categories: [CategoryModel] = [ .init(title: "Stores", systemIcon: "storefront"), @@ -104,6 +107,15 @@ struct HomeView: View { .background(AppColors.backgroundLight) .ignoresSafeArea(edges: .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 { @@ -175,7 +187,7 @@ struct HomeView: View { .foregroundStyle(AppColors.brandSoft) HStack(spacing: 6) { - Text("Canggu, Kuta Utara, Bali") + Text(appState.address.display) .font(AppTypography.heading3) .foregroundStyle(AppColors.textInverse) Image(systemName: "chevron.down") diff --git a/pedi-foods/Sources/PediFoods/Views/Main/MainTabView.swift b/pedi-foods/Sources/PediFoods/Views/Main/MainTabView.swift index a525676..5cbab15 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/MainTabView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/MainTabView.swift @@ -4,11 +4,12 @@ struct MainTabView: View { @Binding var selectedTab: MainTab @Binding var root: RootFlow let tokenStore: TokenStore + @Binding var appState: AppState var body: some View { TabView(selection: $selectedTab) { NavigationStack { - HomeView() + HomeView(appState: $appState) } .tabItem { Label("Home", systemImage: "house.fill") } .tag(MainTab.home) @@ -20,7 +21,7 @@ struct MainTabView: View { .tag(MainTab.cart) NavigationStack { - ProfileView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore) + ProfileView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState) } .tabItem { Label("Perfil", systemImage: "person.fill") } .tag(MainTab.profile) diff --git a/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift b/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift index f51b537..1e3e408 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift @@ -4,6 +4,7 @@ struct ProfileView: View { @Binding var root: RootFlow @Binding var selectedTab: MainTab let tokenStore: TokenStore + @Binding var appState: AppState var body: some View { VStack(spacing: 20) { @@ -29,6 +30,8 @@ struct ProfileView: View { SecondaryButton(title: "Sair") { tokenStore.clear() + appState.session.isAuthenticated = false + appState.session.jwt = nil root = .auth } .padding(.horizontal, 20) @@ -49,11 +52,11 @@ struct ProfileView: View { .foregroundStyle(AppColors.textInverse) ) - Text("Daniel Loverde") + Text(appState.profile.name.isEmpty ? "Daniel Loverde" : appState.profile.name) .font(AppTypography.heading2) .foregroundStyle(AppColors.textPrimary) - Text("daniel@pedifoods.com.br") + Text(appState.profile.email.isEmpty ? "daniel@pedifoods.com.br" : appState.profile.email) .font(.caption) .foregroundStyle(AppColors.secondary) } diff --git a/pedifoods_app_plan.md b/pedifoods_app_plan.md index d502cad..5f903fe 100644 --- a/pedifoods_app_plan.md +++ b/pedifoods_app_plan.md @@ -80,3 +80,11 @@ 4. Build iOS e Android 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