diff --git a/pedi-foods/PROFILE_PLAN.md b/pedi-foods/PROFILE_PLAN.md new file mode 100644 index 0000000..adc64da --- /dev/null +++ b/pedi-foods/PROFILE_PLAN.md @@ -0,0 +1,30 @@ +# Plano - Tela de Perfil + +Legenda: +- 🟢 Concluído +- 🟡 Em validação +- 🔴 Pendente + +Última atualização: 2026-02-13 + +## Entregas + +- 🟢 Redesign do conteúdo da tela de perfil no estilo do modelo (header + cards de menu) +- 🟢 Header ajustada para encostar no topo e nas laterais +- 🟢 Redução de escala dos itens da lista (ícones, fonte, altura e paddings) +- 🟢 Faixa de proteção no rodapé para não deixar conteúdo por trás da tabbar +- 🟢 Confirmação com alert padrão ao tocar em "Sair da Conta" +- 🟢 Fluxo de editar endereço reutilizando a tela de novo endereço +- 🟢 Swipe para excluir endereço com compatibilidade Android (gesto custom) +- 🟢 Polimento visual do swipe/delete (sem vazamento de fundo vermelho) + +## Pendências + +- 🟡 Validação visual final no device (iOS e Android) com screenshots finais +- 🔴 Definir destinos reais dos itens ainda placeholder: + - Meus Cartões + - Minhas Avaliações + - Cupons de Desconto + - Configurações/Ajuda (confirmar nomenclatura final) +- 🔴 Corrigir build global do projeto (dependência `LCEssentials` ausente em `StoreDetailView.swift`) + diff --git a/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings b/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings index 9ba309c..93fc197 100644 --- a/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings +++ b/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings @@ -15,6 +15,10 @@ "comment" : "A text element showing the number of reviews for a store. The content inside the parentheses is the number of reviews.", "isCommentAutoGenerated" : true }, + "%lld" : { + "comment" : "A label displaying the quantity of a particular item in the user's cart.", + "isCommentAutoGenerated" : true + }, "%lld Items" : { "comment" : "Header title for a list that contains the number of items", "extractionState" : "stale", @@ -83,6 +87,10 @@ "comment" : "A button label that translates to \"Add new address\" in English.", "isCommentAutoGenerated" : true }, + "Adicione produtos para continuar." : { + "comment" : "A message displayed when the cart is empty, encouraging the user to add products.", + "isCommentAutoGenerated" : true + }, "Ajuda" : { "comment" : "A link to the help section of the app.", "isCommentAutoGenerated" : true @@ -167,6 +175,10 @@ } } }, + "Cancelar" : { + "comment" : "The buttons in an alert that ask the user to confirm leaving their account.", + "isCommentAutoGenerated" : true + }, "Cardápio indisponível no momento." : { "comment" : "A message displayed when a store's menu is unavailable.", "isCommentAutoGenerated" : true @@ -203,14 +215,6 @@ "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 - }, - "daniel@pedifoods.com.br" : { - "comment" : "An email address displayed in the user's profile.", - "isCommentAutoGenerated" : true - }, "Dark" : { "comment" : "Menu item indicating that the appearance should be in dark mode", "extractionState" : "stale", @@ -283,16 +287,8 @@ "comment" : "The title of the navigation bar at the top of the product detail sheet.", "isCommentAutoGenerated" : true }, - "DL" : { - "comment" : "An abbreviation for \"Delivery Lady\" used in the user's profile picture circle.", - "isCommentAutoGenerated" : true - }, "Editar endereço" : { - }, - "Enderecos" : { - "comment" : "A link to the user's address list.", - "isCommentAutoGenerated" : true }, "Entrar" : { "comment" : "A link that directs the user to the login screen.", @@ -411,6 +407,10 @@ } } }, + "Inclui adicionais: %@" : { + "comment" : "A footnote at the bottom of the checkout view showing the cost of any selected additional items. The argument is the total cost of the selected additional items.", + "isCommentAutoGenerated" : true + }, "Insira o código de 8 dígitos enviado" : { "comment" : "A description below the text field where the user inputs their OTP code.", "isCommentAutoGenerated" : true @@ -449,10 +449,18 @@ } } }, + "Limpar carrinho e adicionar" : { + "comment" : "A button that clears the current cart and adds the selected product.", + "isCommentAutoGenerated" : true + }, "Meus Endereços" : { "comment" : "A label displayed at the top of the view.", "isCommentAutoGenerated" : true }, + "Minhas Avaliações" : { + "comment" : "A button that links to their reviews.", + "isCommentAutoGenerated" : true + }, "Name" : { "comment" : "Placeholder title for the Name field in a form", "extractionState" : "stale", @@ -619,6 +627,16 @@ }, "RESTAURANT" : { + }, + "Sair" : { + + }, + "Sair da Conta" : { + "comment" : "A button label that translates to \"Log out\" in English.", + "isCommentAutoGenerated" : true + }, + "Sair da conta?" : { + }, "Save" : { "comment" : "Button title indicating that the current contents should be saved", @@ -680,8 +698,12 @@ } } }, - "Seu carrinho esta vazio" : { - "comment" : "A description displayed when a user's cart is empty.", + "Seu carrinho está vazio" : { + "comment" : "A message indicating that the user's cart is empty.", + "isCommentAutoGenerated" : true + }, + "Seu carrinho tem itens de outra loja. Deseja limpar o carrinho atual para adicionar este produto?" : { + "comment" : "A message displayed when the user attempts to add a product to their cart from a different store. Asks if the user wants to clear their current cart before adding the new product.", "isCommentAutoGenerated" : true }, "System" : { @@ -713,6 +735,9 @@ } } } + }, + "Tem certeza que deseja sair da sua conta?" : { + }, "Tentar novamente" : { "comment" : "A button label that attempts to reload data when an error occurs.", @@ -752,10 +777,26 @@ } } }, + "Total" : { + "comment" : "A label displayed above the total price in the cart.", + "isCommentAutoGenerated" : true + }, + "Trocar de loja?" : { + "comment" : "A title for an alert that prompts the user to switch stores.", + "isCommentAutoGenerated" : true + }, + "Ver Perfil" : { + "comment" : "A button label that translates to \"View Profile\" in English.", + "isCommentAutoGenerated" : true + }, "Verificação" : { "comment" : "A title for the OTP verification screen.", "isCommentAutoGenerated" : true }, + "Versão 4.20.1" : { + "comment" : "The version number of the app.", + "isCommentAutoGenerated" : true + }, "Version %@ (%@)" : { "comment" : "Settings label showing the current version of the app", "extractionState" : "stale", diff --git a/pedi-foods/Sources/PediFoods/Services/ApiModels.swift b/pedi-foods/Sources/PediFoods/Services/ApiModels.swift new file mode 100644 index 0000000..cb9088d --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Services/ApiModels.swift @@ -0,0 +1,412 @@ +import Foundation + +// MARK: - DTOs + +struct EmptyResult: Decodable {} + +struct RegistrationResult: Decodable { + let id: String? + let name: String? + let email: String? +} + +struct LoginResult: Decodable { + let token: String + let customer: CustomerProfile? +} + +struct CustomerProfile: Decodable { + let id: String + let name: String + let email: String + let phoneNumber: String? + let profilePicture: String? + let addressBook: [CustomerAddress]? + + enum CodingKeys: String, CodingKey { + case id + case name + case email + case phoneNumber + case profilePicture + case addressBook = "address_book" + } +} + +struct CustomerAddress: Decodable { + let id: String? + let label: String? + let address: String? + let number: String? + let complement: String? + let neighborhood: String? + let city: String? + let state: String? + let zipCode: String? + let latLong: [Double]? + + enum CodingKeys: String, CodingKey { + case id + case label + case address + case number + case complement + case neighborhood + case city + case state + case zipCode + case latLong = "lat_long" + } +} + +struct StoreSummary: Decodable { + let id: String + let name: String + let logo: String? + let cover: String? + let category: String? + let rating: Double? + let deliveryTime: String? + let deliveryFee: Double? + let distance: Double? + let isOpen: Bool? + let statusLabel: String? +} + +struct StoreInfoResult: Decodable { + let isOpen: Bool? + let statusLabel: String? + let deliveryTime: String? + let minOrder: Double? + let address: StoreAddressInfo? + let paymentMethods: StorePaymentMethodsInfo? + + enum CodingKeys: String, CodingKey { + case isOpen + case statusLabel + case deliveryTime + case minOrder + case address + case paymentMethods + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + isOpen = try? container.decode(Bool.self, forKey: .isOpen) + statusLabel = try? container.decode(String.self, forKey: .statusLabel) + deliveryTime = try? container.decode(String.self, forKey: .deliveryTime) + minOrder = ApiService.decodeFlexibleDouble(from: container, keys: [.minOrder]) + address = try? container.decode(StoreAddressInfo.self, forKey: .address) + paymentMethods = try? container.decode(StorePaymentMethodsInfo.self, forKey: .paymentMethods) + } +} + +struct StoreAddressInfo: Decodable { + let street: String? + let number: String? + let neighborhood: String? + let city: String? + let state: String? + let latitude: Double? + let longitude: Double? + + enum CodingKeys: String, CodingKey { + case street + case number + case neighborhood + case city + case state + case latitude + case longitude + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + street = try? container.decode(String.self, forKey: .street) + number = try? container.decode(String.self, forKey: .number) + neighborhood = try? container.decode(String.self, forKey: .neighborhood) + city = try? container.decode(String.self, forKey: .city) + state = try? container.decode(String.self, forKey: .state) + latitude = ApiService.decodeFlexibleDouble(from: container, keys: [.latitude]) + longitude = ApiService.decodeFlexibleDouble(from: container, keys: [.longitude]) + } +} + +struct StorePaymentMethodsInfo: Decodable { + let acceptPix: Bool? + let acceptCash: Bool? + let acceptCreditCard: Bool? + let acceptDebitCard: Bool? +} + +struct StoreCatalogCategory: Decodable { + let id: String + let name: String + let products: [StoreCatalogProduct] + + enum CodingKeys: String, CodingKey { + case id + case name + case products + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString + name = (try? container.decode(String.self, forKey: .name)) ?? "Categoria" + products = (try? container.decode([StoreCatalogProduct].self, forKey: .products)) ?? [] + } +} + +struct StoreCatalogProduct: Decodable, Identifiable { + let id: String + let name: String + let description: String? + let image: String? + let price: Double? + let originalPrice: Double? + let addonGroups: [StoreAddonGroup] + + enum CodingKeys: String, CodingKey { + case id + case name + case description + case desc + case image + case cover + case photo + case price + case originalPrice + case oldPrice + case addonGroups + case addons + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString + name = (try? container.decode(String.self, forKey: .name)) ?? "Produto" + description = (try? container.decode(String.self, forKey: .description)) ?? (try? container.decode(String.self, forKey: .desc)) + image = (try? container.decode(String.self, forKey: .image)) + ?? (try? container.decode(String.self, forKey: .cover)) + ?? (try? container.decode(String.self, forKey: .photo)) + price = ApiService.decodeFlexibleDouble(from: container, keys: [.price]) + originalPrice = ApiService.decodeFlexibleDouble(from: container, keys: [.originalPrice, .oldPrice]) + addonGroups = (try? container.decode([StoreAddonGroup].self, forKey: .addonGroups)) + ?? (try? container.decode([StoreAddonGroup].self, forKey: .addons)) + ?? [] + } +} + +struct StoreAddonGroup: Decodable, Identifiable { + let id: String + let name: String + let minSelectors: Int? + let maxSelectors: Int? + let items: [StoreAddonItem] + + enum CodingKeys: String, CodingKey { + case id + case name + case minSelectors + case maxSelectors + case items + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString + name = (try? container.decode(String.self, forKey: .name)) ?? "Adicionais" + minSelectors = try? container.decode(Int.self, forKey: .minSelectors) + maxSelectors = try? container.decode(Int.self, forKey: .maxSelectors) + items = (try? container.decode([StoreAddonItem].self, forKey: .items)) ?? [] + } +} + +struct StoreAddonItem: Decodable, Identifiable { + let id: String + let name: String + let price: Double? + + enum CodingKeys: String, CodingKey { + case id + case name + case price + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString + name = (try? container.decode(String.self, forKey: .name)) ?? "Item" + price = ApiService.decodeFlexibleDouble(from: container, keys: [.price]) + } +} + +struct PublicCategory: Decodable { + let id: String + let name: String + let icon: String? +} + +struct CustomerProfileUpdatePayload: Encodable { + let addressBook: [CustomerAddressPayload] + + enum CodingKeys: String, CodingKey { + case addressBook = "address_book" + } +} + +struct CustomerAddressPayload: Encodable { + let label: String? + let address: String? + let number: String? + let complement: String? + let neighborhood: String? + let city: String? + let state: String? + let zipCode: String? + let latLong: [Double]? + + enum CodingKeys: String, CodingKey { + case label + case address + case number + case complement + case neighborhood + case city + case state + case zipCode + case latLong = "lat_long" + } + + init(from address: CustomerAddress) { + self.label = address.label + self.address = address.address + self.number = address.number + self.complement = address.complement + self.neighborhood = address.neighborhood + self.city = address.city + self.state = address.state + self.zipCode = address.zipCode + self.latLong = address.latLong + } +} + +struct CepLookupResult: Decodable { + let zipCode: String? + let street: String? + let neighborhood: String? + let city: String? + let state: String? + let complement: String? + let latitude: Double? + let longitude: Double? + + enum CodingKeys: String, CodingKey { + case zipCode + case cep + case zip + case normalized + case raw + case street + case logradouro + case address + case neighborhood + case bairro + case district + case city + case cidade + case localidade + case state + case estado + case uf + case complement + case complemento + case latitude + case lat + case longitude + case lng + } + + enum NormalizedKeys: String, CodingKey { + case cep + case logradouro + case bairro + case cidade + case uf + case latitude + case longitude + } + + enum RawKeys: String, CodingKey { + case cep + case address + case district + case city + case state + case lat + case lng + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let normalizedContainer = try? container.nestedContainer(keyedBy: NormalizedKeys.self, forKey: .normalized) + let rawContainer = try? container.nestedContainer(keyedBy: RawKeys.self, forKey: .raw) + + let directZip = CepLookupResult.decodeString(from: container, keys: [.zipCode, .cep, .zip]) + let directStreet = CepLookupResult.decodeString(from: container, keys: [.street, .logradouro, .address]) + let directNeighborhood = CepLookupResult.decodeString(from: container, keys: [.neighborhood, .bairro, .district]) + let directCity = CepLookupResult.decodeString(from: container, keys: [.city, .cidade, .localidade]) + let directState = CepLookupResult.decodeString(from: container, keys: [.state, .estado, .uf]) + let directComplement = CepLookupResult.decodeString(from: container, keys: [.complement, .complemento]) + let directLatitude = CepLookupResult.decodeFlexibleDouble(from: container, keys: [.latitude, .lat]) + let directLongitude = CepLookupResult.decodeFlexibleDouble(from: container, keys: [.longitude, .lng]) + + let normalizedZip = normalizedContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.cep]) } + let normalizedStreet = normalizedContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.logradouro]) } + let normalizedNeighborhood = normalizedContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.bairro]) } + let normalizedCity = normalizedContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.cidade]) } + let normalizedState = normalizedContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.uf]) } + let normalizedLatitude = normalizedContainer.flatMap { CepLookupResult.decodeFlexibleDouble(from: $0, keys: [.latitude]) } + let normalizedLongitude = normalizedContainer.flatMap { CepLookupResult.decodeFlexibleDouble(from: $0, keys: [.longitude]) } + + let rawZip = rawContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.cep]) } + let rawStreet = rawContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.address]) } + let rawNeighborhood = rawContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.district]) } + let rawCity = rawContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.city]) } + let rawState = rawContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.state]) } + let rawLatitude = rawContainer.flatMap { CepLookupResult.decodeFlexibleDouble(from: $0, keys: [.lat]) } + let rawLongitude = rawContainer.flatMap { CepLookupResult.decodeFlexibleDouble(from: $0, keys: [.lng]) } + + zipCode = directZip ?? normalizedZip ?? rawZip + street = directStreet ?? normalizedStreet ?? rawStreet + neighborhood = directNeighborhood ?? normalizedNeighborhood ?? rawNeighborhood + city = directCity ?? normalizedCity ?? rawCity + state = directState ?? normalizedState ?? rawState + complement = directComplement + latitude = directLatitude ?? normalizedLatitude ?? rawLatitude + longitude = directLongitude ?? normalizedLongitude ?? rawLongitude + } + + private static func decodeString(from container: KeyedDecodingContainer, keys: [K]) -> String? { + for key in keys { + if let value = try? container.decode(String.self, forKey: key) { + return value + } + } + return nil + } + + private static func decodeFlexibleDouble(from container: KeyedDecodingContainer, keys: [K]) -> Double? { + for key in keys { + if let value = try? container.decode(Double.self, forKey: key) { + return value + } + if let valueAsString = try? container.decode(String.self, forKey: key), + let parsed = Double(valueAsString.replacingOccurrences(of: ",", with: ".")) { + return parsed + } + } + return nil + } +} diff --git a/pedi-foods/Sources/PediFoods/Services/ApiService.swift b/pedi-foods/Sources/PediFoods/Services/ApiService.swift index ad59d35..4493af5 100644 --- a/pedi-foods/Sources/PediFoods/Services/ApiService.swift +++ b/pedi-foods/Sources/PediFoods/Services/ApiService.swift @@ -236,419 +236,7 @@ final class ApiService { return try await sendEnvelope(req) } } - -// MARK: - DTOs - -struct EmptyResult: Decodable {} - -struct RegistrationResult: Decodable { - let id: String? - let name: String? - let email: String? -} - -struct LoginResult: Decodable { - let token: String - let customer: CustomerProfile? -} - -struct CustomerProfile: Decodable { - let id: String - let name: String - let email: String - let phoneNumber: String? - let profilePicture: String? - let addressBook: [CustomerAddress]? - - enum CodingKeys: String, CodingKey { - case id - case name - case email - case phoneNumber - case profilePicture - case addressBook = "address_book" - } -} - -struct CustomerAddress: Decodable { - let id: String? - let label: String? - let address: String? - let number: String? - let complement: String? - let neighborhood: String? - let city: String? - let state: String? - let zipCode: String? - let latLong: [Double]? - - enum CodingKeys: String, CodingKey { - case id - case label - case address - case number - case complement - case neighborhood - case city - case state - case zipCode - case latLong = "lat_long" - } -} - -struct StoreSummary: Decodable { - let id: String - let name: String - let logo: String? - let cover: String? - let category: String? - let rating: Double? - let deliveryTime: String? - let deliveryFee: Double? - let distance: Double? - let isOpen: Bool? - let statusLabel: String? -} - -struct StoreInfoResult: Decodable { - let isOpen: Bool? - let statusLabel: String? - let deliveryTime: String? - let minOrder: Double? - let address: StoreAddressInfo? - let paymentMethods: StorePaymentMethodsInfo? - - enum CodingKeys: String, CodingKey { - case isOpen - case statusLabel - case deliveryTime - case minOrder - case address - case paymentMethods - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - isOpen = try? container.decode(Bool.self, forKey: .isOpen) - statusLabel = try? container.decode(String.self, forKey: .statusLabel) - deliveryTime = try? container.decode(String.self, forKey: .deliveryTime) - minOrder = ApiService.decodeFlexibleDouble(from: container, keys: [.minOrder]) - address = try? container.decode(StoreAddressInfo.self, forKey: .address) - paymentMethods = try? container.decode(StorePaymentMethodsInfo.self, forKey: .paymentMethods) - } -} - -struct StoreAddressInfo: Decodable { - let street: String? - let number: String? - let neighborhood: String? - let city: String? - let state: String? - let latitude: Double? - let longitude: Double? - - enum CodingKeys: String, CodingKey { - case street - case number - case neighborhood - case city - case state - case latitude - case longitude - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - street = try? container.decode(String.self, forKey: .street) - number = try? container.decode(String.self, forKey: .number) - neighborhood = try? container.decode(String.self, forKey: .neighborhood) - city = try? container.decode(String.self, forKey: .city) - state = try? container.decode(String.self, forKey: .state) - latitude = ApiService.decodeFlexibleDouble(from: container, keys: [.latitude]) - longitude = ApiService.decodeFlexibleDouble(from: container, keys: [.longitude]) - } -} - -struct StorePaymentMethodsInfo: Decodable { - let acceptPix: Bool? - let acceptCash: Bool? - let acceptCreditCard: Bool? - let acceptDebitCard: Bool? -} - -struct StoreCatalogCategory: Decodable { - let id: String - let name: String - let products: [StoreCatalogProduct] - - enum CodingKeys: String, CodingKey { - case id - case name - case products - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString - name = (try? container.decode(String.self, forKey: .name)) ?? "Categoria" - products = (try? container.decode([StoreCatalogProduct].self, forKey: .products)) ?? [] - } -} - -struct StoreCatalogProduct: Decodable, Identifiable { - let id: String - let name: String - let description: String? - let image: String? - let price: Double? - let originalPrice: Double? - let addonGroups: [StoreAddonGroup] - - enum CodingKeys: String, CodingKey { - case id - case name - case description - case desc - case image - case cover - case photo - case price - case originalPrice - case oldPrice - case addonGroups - case addons - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString - name = (try? container.decode(String.self, forKey: .name)) ?? "Produto" - description = (try? container.decode(String.self, forKey: .description)) ?? (try? container.decode(String.self, forKey: .desc)) - image = (try? container.decode(String.self, forKey: .image)) - ?? (try? container.decode(String.self, forKey: .cover)) - ?? (try? container.decode(String.self, forKey: .photo)) - price = ApiService.decodeFlexibleDouble(from: container, keys: [.price]) - originalPrice = ApiService.decodeFlexibleDouble(from: container, keys: [.originalPrice, .oldPrice]) - addonGroups = (try? container.decode([StoreAddonGroup].self, forKey: .addonGroups)) - ?? (try? container.decode([StoreAddonGroup].self, forKey: .addons)) - ?? [] - } -} - -struct StoreAddonGroup: Decodable, Identifiable { - let id: String - let name: String - let minSelectors: Int? - let maxSelectors: Int? - let items: [StoreAddonItem] - - enum CodingKeys: String, CodingKey { - case id - case name - case minSelectors - case maxSelectors - case items - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString - name = (try? container.decode(String.self, forKey: .name)) ?? "Adicionais" - minSelectors = try? container.decode(Int.self, forKey: .minSelectors) - maxSelectors = try? container.decode(Int.self, forKey: .maxSelectors) - items = (try? container.decode([StoreAddonItem].self, forKey: .items)) ?? [] - } -} - -struct StoreAddonItem: Decodable, Identifiable { - let id: String - let name: String - let price: Double? - - enum CodingKeys: String, CodingKey { - case id - case name - case price - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString - name = (try? container.decode(String.self, forKey: .name)) ?? "Item" - price = ApiService.decodeFlexibleDouble(from: container, keys: [.price]) - } -} - -struct PublicCategory: Decodable { - let id: String - let name: String - let icon: String? -} - -struct CustomerProfileUpdatePayload: Encodable { - let addressBook: [CustomerAddressPayload] - - enum CodingKeys: String, CodingKey { - case addressBook = "address_book" - } -} - -struct CustomerAddressPayload: Encodable { - let label: String? - let address: String? - let number: String? - let complement: String? - let neighborhood: String? - let city: String? - let state: String? - let zipCode: String? - let latLong: [Double]? - - enum CodingKeys: String, CodingKey { - case label - case address - case number - case complement - case neighborhood - case city - case state - case zipCode - case latLong = "lat_long" - } - - init(from address: CustomerAddress) { - self.label = address.label - self.address = address.address - self.number = address.number - self.complement = address.complement - self.neighborhood = address.neighborhood - self.city = address.city - self.state = address.state - self.zipCode = address.zipCode - self.latLong = address.latLong - } -} - -struct CepLookupResult: Decodable { - let zipCode: String? - let street: String? - let neighborhood: String? - let city: String? - let state: String? - let complement: String? - let latitude: Double? - let longitude: Double? - - enum CodingKeys: String, CodingKey { - case zipCode - case cep - case zip - case normalized - case raw - case street - case logradouro - case address - case neighborhood - case bairro - case district - case city - case cidade - case localidade - case state - case estado - case uf - case complement - case complemento - case latitude - case lat - case longitude - case lng - } - - enum NormalizedKeys: String, CodingKey { - case cep - case logradouro - case bairro - case cidade - case uf - case latitude - case longitude - } - - enum RawKeys: String, CodingKey { - case cep - case address - case district - case city - case state - case lat - case lng - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let normalizedContainer = try? container.nestedContainer(keyedBy: NormalizedKeys.self, forKey: .normalized) - let rawContainer = try? container.nestedContainer(keyedBy: RawKeys.self, forKey: .raw) - - let directZip = CepLookupResult.decodeString(from: container, keys: [.zipCode, .cep, .zip]) - let directStreet = CepLookupResult.decodeString(from: container, keys: [.street, .logradouro, .address]) - let directNeighborhood = CepLookupResult.decodeString(from: container, keys: [.neighborhood, .bairro, .district]) - let directCity = CepLookupResult.decodeString(from: container, keys: [.city, .cidade, .localidade]) - let directState = CepLookupResult.decodeString(from: container, keys: [.state, .estado, .uf]) - let directComplement = CepLookupResult.decodeString(from: container, keys: [.complement, .complemento]) - let directLatitude = CepLookupResult.decodeFlexibleDouble(from: container, keys: [.latitude, .lat]) - let directLongitude = CepLookupResult.decodeFlexibleDouble(from: container, keys: [.longitude, .lng]) - - let normalizedZip = normalizedContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.cep]) } - let normalizedStreet = normalizedContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.logradouro]) } - let normalizedNeighborhood = normalizedContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.bairro]) } - let normalizedCity = normalizedContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.cidade]) } - let normalizedState = normalizedContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.uf]) } - let normalizedLatitude = normalizedContainer.flatMap { CepLookupResult.decodeFlexibleDouble(from: $0, keys: [.latitude]) } - let normalizedLongitude = normalizedContainer.flatMap { CepLookupResult.decodeFlexibleDouble(from: $0, keys: [.longitude]) } - - let rawZip = rawContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.cep]) } - let rawStreet = rawContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.address]) } - let rawNeighborhood = rawContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.district]) } - let rawCity = rawContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.city]) } - let rawState = rawContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.state]) } - let rawLatitude = rawContainer.flatMap { CepLookupResult.decodeFlexibleDouble(from: $0, keys: [.lat]) } - let rawLongitude = rawContainer.flatMap { CepLookupResult.decodeFlexibleDouble(from: $0, keys: [.lng]) } - - zipCode = directZip ?? normalizedZip ?? rawZip - street = directStreet ?? normalizedStreet ?? rawStreet - neighborhood = directNeighborhood ?? normalizedNeighborhood ?? rawNeighborhood - city = directCity ?? normalizedCity ?? rawCity - state = directState ?? normalizedState ?? rawState - complement = directComplement - latitude = directLatitude ?? normalizedLatitude ?? rawLatitude - longitude = directLongitude ?? normalizedLongitude ?? rawLongitude - } - - private static func decodeString(from container: KeyedDecodingContainer, keys: [K]) -> String? { - for key in keys { - if let value = try? container.decode(String.self, forKey: key) { - return value - } - } - return nil - } - - private static func decodeFlexibleDouble(from container: KeyedDecodingContainer, keys: [K]) -> Double? { - for key in keys { - if let value = try? container.decode(Double.self, forKey: key) { - return value - } - if let valueAsString = try? container.decode(String.self, forKey: key), - let parsed = Double(valueAsString.replacingOccurrences(of: ",", with: ".")) { - return parsed - } - } - return nil - } -} - -private extension ApiService { +extension ApiService { static func decodeFlexibleDouble(from container: KeyedDecodingContainer, keys: [K]) -> Double? { for key in keys { if let value = try? container.decode(Double.self, forKey: key) { diff --git a/pedi-foods/Sources/PediFoods/State/AppState.swift b/pedi-foods/Sources/PediFoods/State/AppState.swift index 6865be2..e734f94 100644 --- a/pedi-foods/Sources/PediFoods/State/AppState.swift +++ b/pedi-foods/Sources/PediFoods/State/AppState.swift @@ -41,13 +41,81 @@ struct FavoritesState { } struct CartState { + var storeId: String? = nil + var storeName: String? = nil var items: [CartItemState] = [] var total: Double = 0 } struct CartItemState: Identifiable { let id: String + var productId: String + var storeId: String var name: String + var details: String? = nil var quantity: Int - var price: Double + var unitPrice: Double +} + +extension CartState { + var totalItems: Int { + items.reduce(0) { $0 + $1.quantity } + } + + mutating func recalculateTotal() { + total = items.reduce(0) { $0 + (Double($1.quantity) * $1.unitPrice) } + } + + mutating func clear() { + storeId = nil + storeName = nil + items = [] + total = 0 + } + + mutating func add(item: CartItemState) { + if let index = items.firstIndex(where: { $0.id == item.id }) { + items[index].quantity += item.quantity + } else { + items.append(item) + } + recalculateTotal() + } + + mutating func set(item: CartItemState) { + if let index = items.firstIndex(where: { $0.id == item.id }) { + if item.quantity <= 0 { + items.remove(at: index) + } else { + items[index] = item + } + } else if item.quantity > 0 { + items.append(item) + } + + if items.isEmpty { + storeId = nil + storeName = nil + } + recalculateTotal() + } + + mutating func increment(itemId: String) { + guard let index = items.firstIndex(where: { $0.id == itemId }) else { return } + items[index].quantity += 1 + recalculateTotal() + } + + mutating func decrement(itemId: String) { + guard let index = items.firstIndex(where: { $0.id == itemId }) else { return } + items[index].quantity -= 1 + if items[index].quantity <= 0 { + items.remove(at: index) + } + if items.isEmpty { + storeId = nil + storeName = nil + } + recalculateTotal() + } } diff --git a/pedi-foods/Sources/PediFoods/Views/Main/AddAddressFormView.swift b/pedi-foods/Sources/PediFoods/Views/Main/AddAddressFormView.swift new file mode 100644 index 0000000..a077f12 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Main/AddAddressFormView.swift @@ -0,0 +1,251 @@ +import SwiftUI + +struct AddAddressFormView: View { + @Environment(\.dismiss) var dismiss + @Environment(\.colorScheme) var colorScheme + @State var label = "" + @State var zipCode = "" + @State var address = "" + @State var number = "" + @State var complement = "" + @State var neighborhood = "" + @State var city = "" + @State var state = "" + @State var isLoading = false + @State var isLookingUpZipCode = false + @State var zipLookupMessage: String? = nil + @State var lastLookedUpZipCode = "" + @State var lookedUpLatitude: Double? = nil + @State var lookedUpLongitude: Double? = nil + + let existingAddress: CustomerAddress? + let onSave: ([CustomerAddress], Bool) -> Void + + private var isFormValid: Bool { + !label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + normalizeZipCodeForAPI(zipCode).count == 8 && + !address.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + !number.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + !neighborhood.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + !city.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + !state.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + @ViewBuilder private var logoImage: some View { +#if os(Android) + SwiftUI.Image("pedifoods") + .resizable() +#else + SwiftUI.Image("pedifoods") + .resizable() +#endif + } + + var body: some View { + ZStack { + (colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea() + + ScrollView { + VStack(spacing: 0) { + logoImage + .scaledToFit() + .frame(width: 120, height: 120) + + Text(existingAddress == nil ? "Novo endereço" : "Editar endereço") + .font(AppTypography.heading1) + .foregroundStyle(colorScheme == .dark ? Color.white : AppColors.textPrimary) + + Text(existingAddress == nil ? "Preencha os dados abaixo para adicionar um endereço." : "Atualize os dados do endereço abaixo.") + .font(AppTypography.body) + .foregroundStyle(colorScheme == .dark ? Color.white : AppColors.textPrimary) + .multilineTextAlignment(.center) + .padding(.top, 8) + .padding(.bottom, 20) + + VStack(alignment: .leading, spacing: 16) { + LoginField(icon: "tag", placeholder: "Ex: Casa, Trabalho", text: $label) + LoginField(icon: "mail", placeholder: "CEP", text: $zipCode) + .onChange(of: zipCode) { _, newValue in + let masked = formatZipCodeBR(newValue) + if masked != newValue { + zipCode = masked + } + + let normalized = normalizeZipCodeForAPI(masked) + if normalized.count == 8, normalized != lastLookedUpZipCode, !isLookingUpZipCode { + Task { + await lookupAddressByZipCode(normalized) + } + } + } + if isLookingUpZipCode { + HStack(spacing: 8) { + ProgressView() + Text("Buscando endereço pelo CEP...") + .font(.caption) + .foregroundStyle(AppColors.textMuted) + } + .padding(.horizontal, 6) + } else if let zipLookupMessage { + Text(zipLookupMessage) + .font(.caption) + .foregroundStyle(AppColors.textMuted) + .padding(.horizontal, 6) + } + LoginField(icon: "mappin.and.ellipse", placeholder: "Rua / Avenida", text: $address) + LoginField(icon: "number", placeholder: "Número", text: $number) + LoginField(icon: "plus.app", placeholder: "Complemento (opcional)", text: $complement) + LoginField(icon: "square.grid.2x2", placeholder: "Bairro", text: $neighborhood) + LoginField(icon: "building.2", placeholder: "Cidade", text: $city) + LoginField(icon: "map", placeholder: "Estado (UF)", text: $state) + .onChange(of: state) { _, newValue in + let normalized = String(newValue.uppercased().prefix(2)) + if normalized != newValue { + state = normalized + } + } + } + .padding(.horizontal, 24) + .padding(.bottom, 20) + + PrimaryButton(title: existingAddress == nil ? "Salvar endereço" : "Atualizar endereço", image: Image(systemName: "checkmark")) { + saveAddress() + } + .padding(.horizontal, 24) + .disabled(!isFormValid || isLoading) + .opacity((!isFormValid || isLoading) ? 0.5 : 1.0) + .tint(AppColors.tertiary) + + SecondaryButton(title: "Cancelar") { + dismiss() + } + .padding(.horizontal, 24) + .padding(.top, 12) + + Spacer().frame(height: 120) + } + } + .padding(.top, 0) + } + .navigationBarBackButtonHidden(true) + .toolbar(.hidden, for: .navigationBar) + .onAppear { + populateFromExistingAddressIfNeeded() + } + } + + private func saveAddress() { + guard !isLoading else { return } + let latLong: [Double]? = { + if let lat = lookedUpLatitude, let lng = lookedUpLongitude { + return [lat, lng] + } + return nil + }() + + let newAddress = CustomerAddress( + id: existingAddress?.id ?? UUID().uuidString, + label: clean(label), + address: clean(address), + number: clean(number), + complement: optional(clean(complement)), + neighborhood: clean(neighborhood), + city: clean(city), + state: clean(state), + zipCode: optional(normalizeZipCodeForAPI(zipCode)), + latLong: latLong + ) + isLoading = true + zipLookupMessage = nil + + Task { + do { + let response = try await ApiService().saveCustomerAddress(newAddress, replacingAddressId: existingAddress?.id) + await MainActor.run { + isLoading = false + if response.error { + zipLookupMessage = response.message ?? "Não foi possível salvar o endereço." + SnackbarCenter.shared.show(title: zipLookupMessage ?? "Não foi possível salvar o endereço.", style: .error, icon: "xmark.octagon.fill", duration: 4.0) + return + } + + let updatedAddresses = response.result?.addressBook ?? [newAddress] + onSave(updatedAddresses, existingAddress != nil) + dismiss() + } + } catch { + await MainActor.run { + isLoading = false + let message = error.localizedDescription + zipLookupMessage = message + SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 4.0) + } + } + } + } + + private func populateFromExistingAddressIfNeeded() { + guard let existingAddress else { return } + label = existingAddress.label ?? "" + zipCode = formatZipCodeBR(existingAddress.zipCode ?? "") + lastLookedUpZipCode = normalizeZipCodeForAPI(zipCode) + address = existingAddress.address ?? "" + number = existingAddress.number ?? "" + complement = existingAddress.complement ?? "" + neighborhood = existingAddress.neighborhood ?? "" + city = existingAddress.city ?? "" + state = String((existingAddress.state ?? "").uppercased().prefix(2)) + lookedUpLatitude = existingAddress.latLong?.first + lookedUpLongitude = existingAddress.latLong?.dropFirst().first + } + + private func clean(_ value: String) -> String { + value.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func optional(_ value: String) -> String? { + value.isEmpty ? nil : value + } + + @MainActor + private func lookupAddressByZipCode(_ zip: String) async { + isLookingUpZipCode = true + zipLookupMessage = nil + defer { isLookingUpZipCode = false } + + do { + let response = try await ApiService().lookupZipCode(zip) + lastLookedUpZipCode = zip + + guard response.error == false, let result = response.result else { + zipLookupMessage = response.message ?? "Não foi possível consultar este CEP." + return + } + + fillAddressFields(with: result) + zipLookupMessage = "Endereço preenchido automaticamente." + } catch { + zipLookupMessage = "Não foi possível consultar o CEP agora." + } + } + + private func fillAddressFields(with result: CepLookupResult) { + if address.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + address = result.street?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + } + if neighborhood.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + neighborhood = result.neighborhood?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + } + if city.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + city = result.city?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + } + if state.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + state = String((result.state ?? "").uppercased().prefix(2)) + } + if complement.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + complement = result.complement?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + } + lookedUpLatitude = result.latitude + lookedUpLongitude = result.longitude + } +} diff --git a/pedi-foods/Sources/PediFoods/Views/Main/AddressComponents.swift b/pedi-foods/Sources/PediFoods/Views/Main/AddressComponents.swift new file mode 100644 index 0000000..c0f2439 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Main/AddressComponents.swift @@ -0,0 +1,216 @@ +import SwiftUI +#if canImport(UIKit) +import UIKit +#endif + +func formatZipCodeBR(_ input: String) -> String { + let digits = input.filter(\.isNumber) + let limited = String(digits.prefix(8)) + if limited.count <= 5 { + return limited + } + let prefix = String(limited.prefix(5)) + let suffix = String(limited.dropFirst(5)) + return "\(prefix)-\(suffix)" +} + +func normalizeZipCodeForAPI(_ input: String) -> String { + String(input.filter(\.isNumber).prefix(8)) +} + +func triggerLightHaptic() { +#if canImport(UIKit) + UIImpactFeedbackGenerator(style: .light).impactOccurred() +#endif +} + +func triggerSelectionHaptic() { +#if canImport(UIKit) + UISelectionFeedbackGenerator().selectionChanged() +#endif +} + +struct SwipeToDeleteAddressRow: View { + let rowId: String + @Binding var openRowId: String? + let isDeleting: Bool + let onDelete: () -> Void + @ViewBuilder var content: () -> Content + + @State var contentOffset: CGFloat = 0 + + private let deleteWidth: CGFloat = 92 + private let openThreshold: CGFloat = 32 + private var showsDeleteAction: Bool { contentOffset < -2 || openRowId == rowId } + + var body: some View { + ZStack(alignment: .trailing) { + HStack(spacing: 0) { + Spacer(minLength: 0) + Button(action: { + triggerLightHaptic() + onDelete() + }) { + VStack(spacing: 8) { + Image(systemName: "trash.fill") + .font(.system(size: 20, weight: .semibold)) + Text(isDeleting ? "..." : "Excluir") + .font(AppTypography.overline) + } + .foregroundStyle(AppColors.textInverse) + .frame(width: deleteWidth) + .frame(maxHeight: .infinity) + .background(Color.red) + } + .buttonStyle(.plain) + .disabled(isDeleting) + .opacity(showsDeleteAction ? 1 : 0) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous)) + } + + content() + .offset(x: contentOffset) + .gesture( + DragGesture(minimumDistance: 8) + .onChanged { value in + guard isDeleting == false else { return } + if value.translation.width < 0 { + contentOffset = max(-deleteWidth, value.translation.width) + } else if openRowId == rowId { + contentOffset = min(0, -deleteWidth + value.translation.width) + } + } + .onEnded { _ in + guard isDeleting == false else { return } + if contentOffset <= -openThreshold { + let wasClosed = openRowId != rowId + contentOffset = -deleteWidth + openRowId = rowId + if wasClosed { + triggerSelectionHaptic() + } + } else { + contentOffset = 0 + if openRowId == rowId { + openRowId = nil + } + } + } + ) + .animation(.easeOut(duration: 0.18), value: contentOffset) + } + .clipped() + .animation(.easeOut(duration: 0.18), value: showsDeleteAction) + .onChange(of: openRowId) { _, newValue in + if newValue != rowId { + contentOffset = 0 + } + } + .onChange(of: isDeleting) { _, newValue in + if newValue { + contentOffset = 0 + } + } + } +} + +struct AddressCard: View { + let item: AddressListItem + var onEdit: (() -> Void)? = nil + var onDelete: (() -> Void)? = nil + + var body: some View { + HStack(spacing: 14) { + icon + + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 10) { + Text(item.title) + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + .lineLimit(1) + .truncationMode(.tail) + .minimumScaleFactor(0.9) + + if item.isPrimary { + Text("PRINCIPAL") + .font(AppTypography.overline) + .foregroundStyle(AppColors.textPrimary) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + .padding(.horizontal, 9) + .padding(.vertical, 5) + .background(AppColors.tertiary) + .clipShape(Capsule()) + } + } + + Text(item.detail) + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + .lineLimit(2) + } + + Spacer(minLength: 8) + + if onEdit != nil || onDelete != nil { + Rectangle() + .fill(AppColors.backgroundLight) + .frame(width: 1, height: 96) + } + + VStack(spacing: 24) { + if let onEdit { + Button(action: onEdit) { + Image(systemName: "pencil") + .font(.system(size: 22)) + .foregroundStyle(AppColors.textMuted) + } + } + + if let onDelete { + Button(action: onDelete) { + Image(systemName: "trash") + .font(.system(size: 22)) + .foregroundStyle(AppColors.textMuted) + } + } + } + .frame(width: onEdit != nil || onDelete != nil ? 40 : 0) + } + .padding(.horizontal, 16) + .padding(.vertical, 20) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous)) + } + + var icon: some View { + Image(systemName: item.icon) + .font(.system(size: 28)) + .foregroundStyle(item.isPrimary ? AppColors.primary : AppColors.textPrimary) + .frame(width: 84, height: 84) + .background(item.isPrimary ? AppColors.brandSoft : AppColors.backgroundLight) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } +} + +struct AddressListItem: Identifiable { + let id = UUID() + let title: String + let detail: String + let icon: String + let isPrimary: Bool +} + +struct OrdersView: View { + var body: some View { + VStack(spacing: 16) { + Text("Pedidos") + .font(AppTypography.heading1) + Text("Historico vazio") + .foregroundStyle(AppColors.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(AppColors.backgroundLight) + } +} diff --git a/pedi-foods/Sources/PediFoods/Views/Main/AddressesView.swift b/pedi-foods/Sources/PediFoods/Views/Main/AddressesView.swift new file mode 100644 index 0000000..d32d6c9 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Main/AddressesView.swift @@ -0,0 +1,339 @@ +import SwiftUI + +struct AddressesView: View { + let message: String? + @Binding var appState: AppState + var selectionMode: Bool = false + @Environment(\.dismiss) var dismiss + @State var isLoading = false + @State var errorMessage: String? = nil + @State var addresses: [CustomerAddress] = [] + @State var openAddAddressForm = false + @State var editingAddress: CustomerAddress? = nil + @State var openSwipeRowId: String? = nil + @State var deletingRowId: String? = nil + + let tabBarClearance: CGFloat = 96 + + var body: some View { + ZStack { + AppColors.backgroundLight + .ignoresSafeArea() + + ScrollView(showsIndicators: false) { + VStack(spacing: 20) { + header + + if let message { + Text(message) + .font(AppTypography.body) + .foregroundStyle(AppColors.textPrimary) + .multilineTextAlignment(.center) + .padding(.horizontal, 20) + .padding(.vertical, 14) + .frame(maxWidth: .infinity, alignment: .center) + .background(AppColors.brandSoft) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + + VStack(spacing: 16) { + if isLoading { + ProgressView() + .padding(.top, 24) + } else if let errorMessage { + Text(errorMessage) + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + .multilineTextAlignment(.center) + .padding(.top, 24) + } else if addresses.isEmpty { + Text("Nenhum endereço cadastrado") + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + .padding(.top, 24) + } else { + ForEach(Array(addresses.enumerated()), id: \.offset) { index, address in + let rowId = addressRowId(for: address, index: index) + let isSelected: Bool = { + if let selectedId = appState.address.selectedId { + return address.id == selectedId + } + return index == 0 + }() + + if selectionMode { + Button { + selectAddress(address) + } label: { + AddressCard(item: addressToListItem(address, isPrimary: isSelected)) + } + .buttonStyle(.plain) + } else { + SwipeToDeleteAddressRow( + rowId: rowId, + openRowId: $openSwipeRowId, + isDeleting: deletingRowId == rowId, + onDelete: { deleteAddress(address, rowId: rowId) } + ) { + AddressCard( + item: addressToListItem(address, isPrimary: isSelected), + onEdit: { beginEditing(address) } + ) + .contentShape(Rectangle()) + .onTapGesture { + if openSwipeRowId == rowId { + openSwipeRowId = nil + } + } + } + .id(rowId) + .opacity(deletingRowId == rowId ? 0.6 : 1.0) + .disabled(deletingRowId != nil) + } + } + } + } + } + .padding(.horizontal, 20) + .padding(.top, 18) + } + + VStack { + Spacer() + bottomOverlay + .padding(.bottom, tabBarClearance) + } + + } + .navigationBarBackButtonHidden(true) + .toolbar(.hidden, for: .navigationBar) + .sheet(isPresented: $openAddAddressForm) { + NavigationStack { + AddAddressFormView(existingAddress: editingAddress) { updatedAddresses, wasEditing in + addresses = updatedAddresses + applyPreferredAddress(from: updatedAddresses) + let title = wasEditing ? "Endereço atualizado com sucesso." : "Endereço adicionado com sucesso." + SnackbarCenter.shared.show(title: title, style: .success, icon: "checkmark.seal.fill", duration: 3.0) + } + } + } + .onChange(of: openAddAddressForm) { _, isOpen in + if isOpen == false { + editingAddress = nil + } + } + .onAppear { + if isLoading == false, addresses.isEmpty { + Task { + await loadAddresses() + } + } + } + } + + var header: some View { + ZStack { + Text("Meus Endereços") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + + HStack { + Button(action: { dismiss() }) { + Image(systemName: "chevron.left") + .font(.system(size: 24, weight: .semibold)) + .foregroundStyle(AppColors.textPrimary) + .frame(width: 52, height: 52) + .background(AppColors.surface) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.06), radius: 8, y: 2) + } + + Spacer() + } + } + } + + var bottomOverlay: some View { + ZStack(alignment: .bottom) { + Rectangle() + .fill(AppColors.backgroundLight) + .frame(height: 136) + + Button(action: { + editingAddress = nil + openAddAddressForm = true + }) { + HStack(spacing: 12) { + Image(systemName: "mappin.circle.fill") + .font(.system(size: 24)) + Text("Adicionar novo endereço") + .font(AppTypography.heading3) + } + .foregroundStyle(AppColors.textInverse) + .frame(maxWidth: .infinity) + .padding(.vertical, 18) + .background(AppColors.primary) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous)) + } + .buttonStyle(.plain) + .padding(.horizontal, 20) + .padding(.bottom, 14) + } + } + + private func selectAddress(_ address: CustomerAddress) { + appState.address.selectedId = address.id + let label = (address.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + appState.address.display = label.isEmpty ? "Defina seu endereco" : label + + if let lat = address.latLong?.first, let lng = address.latLong?.dropFirst().first { + appState.address.latitude = lat + appState.address.longitude = lng + } else { + appState.address.latitude = nil + appState.address.longitude = nil + } + + SessionStateStore.saveAddress(appState.address) + + if selectionMode { + dismiss() + } + } + + private func beginEditing(_ address: CustomerAddress) { + openSwipeRowId = nil + editingAddress = address + openAddAddressForm = true + } + + private func applyPreferredAddress(from updatedAddresses: [CustomerAddress]) { + let selected = updatedAddresses.first(where: { $0.id == appState.address.selectedId }) ?? updatedAddresses.first + appState.address.selectedId = selected?.id + appState.address.display = selected?.label?.isEmpty == false ? (selected?.label ?? "Defina seu endereco") : "Defina seu endereco" + + if let lat = selected?.latLong?.first, let lng = selected?.latLong?.dropFirst().first { + appState.address.latitude = lat + appState.address.longitude = lng + } else { + appState.address.latitude = nil + appState.address.longitude = nil + } + + SessionStateStore.saveAddress(appState.address) + } + + private func addressRowId(for address: CustomerAddress, index: Int) -> String { + if let id = address.id, id.isEmpty == false { + return "addr:\(id)" + } + return "idx:\(index):\(address.label ?? ""):\(address.address ?? ""):\(address.number ?? "")" + } + + private func deleteAddress(_ address: CustomerAddress, rowId: String) { + guard deletingRowId == nil else { return } + deletingRowId = rowId + openSwipeRowId = nil + + Task { + do { + let response = try await ApiService().deleteCustomerAddress(address) + await MainActor.run { + deletingRowId = nil + + guard response.error == false else { + let message = response.message ?? "Não foi possível excluir o endereço." + SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 4.0) + return + } + + let updatedAddresses = response.result?.addressBook ?? [] + addresses = updatedAddresses + applyPreferredAddress(from: updatedAddresses) + SnackbarCenter.shared.show(title: "Endereço removido com sucesso.", style: .success, icon: "checkmark.seal.fill", duration: 3.0) + } + } catch { + await MainActor.run { + deletingRowId = nil + let message = error.localizedDescription + SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 4.0) + } + } + } + } + + func addressToListItem(_ address: CustomerAddress, isPrimary: Bool) -> AddressListItem { + let title = address.label?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? (address.label ?? "Endereço") : "Endereço" + let line1 = [address.address, address.number] + .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { $0.isEmpty == false } + .joined(separator: ", ") + let line2 = [address.neighborhood, address.city, address.state] + .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { $0.isEmpty == false } + .joined(separator: ", ") + let detail = [line1, line2] + .filter { $0.isEmpty == false } + .joined(separator: " - ") + + return AddressListItem( + title: title, + detail: detail.isEmpty ? "Endereço sem detalhes" : detail, + icon: iconName(for: title), + isPrimary: isPrimary + ) + } + + func iconName(for label: String) -> String { + let normalized = label.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) + if normalized.contains("casa") { + return "house.fill" + } + if normalized.contains("trabalho") { + return "briefcase.fill" + } + return "mappin.and.ellipse" + } + + @MainActor + func loadAddresses() async { + isLoading = true + errorMessage = nil + + do { + let service = ApiService() + let response = try await service.profile() + guard response.error == false else { + errorMessage = response.message ?? "Não foi possível carregar os endereços." + isLoading = false + return + } + + if let customer = response.result { + appState.profile.id = customer.id + appState.profile.name = customer.name + appState.profile.email = customer.email + appState.profile.phone = customer.phoneNumber ?? appState.profile.phone + SessionStateStore.setActiveUserKey( + SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email) + ) + addresses = customer.addressBook ?? [] + } else { + addresses = [] + } + if let selected = addresses.first(where: { $0.id == appState.address.selectedId }) ?? addresses.first { + appState.address.selectedId = selected.id + appState.address.display = selected.label ?? "Defina seu endereco" + if let lat = selected.latLong?.first, let lng = selected.latLong?.dropFirst().first { + appState.address.latitude = lat + appState.address.longitude = lng + } + SessionStateStore.saveAddress(appState.address) + } + } catch { + errorMessage = error.localizedDescription + } + + isLoading = false + } +} diff --git a/pedi-foods/Sources/PediFoods/Views/Main/CartView.swift b/pedi-foods/Sources/PediFoods/Views/Main/CartView.swift index 5982c25..f18279f 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/CartView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/CartView.swift @@ -1,15 +1,127 @@ +import Foundation import SwiftUI struct CartView: View { + @Binding var appState: AppState + var body: some View { - VStack(spacing: 16) { + VStack(spacing: 14) { Text("Carrinho") .font(AppTypography.heading1) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 20) + .padding(.top, 20) - Text("Seu carrinho esta vazio") - .foregroundStyle(AppColors.secondary) + if appState.cart.items.isEmpty { + VStack(spacing: 10) { + Text("Seu carrinho está vazio") + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + Text("Adicione produtos para continuar.") + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } else { + ScrollView(showsIndicators: false) { + VStack(spacing: 12) { + if let storeName = appState.cart.storeName, storeName.isEmpty == false { + Text(storeName) + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 20) + } + + ForEach(appState.cart.items) { item in + cartItemRow(item) + } + } + .padding(.bottom, 120) + } + } } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(AppColors.backgroundLight) + .safeAreaInset(edge: .bottom) { + if appState.cart.items.isEmpty == false { + VStack(spacing: 10) { + HStack { + Text("Total") + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + Spacer() + Text(formatCurrency(appState.cart.total)) + .font(AppTypography.heading2) + .foregroundStyle(AppColors.primary) + } + .padding(.horizontal, 20) + + PrimaryButton(title: "Finalizar pedido", action: {}) + .padding(.horizontal, 20) + } + .padding(.top, 10) + .padding(.bottom, 12) + .background(.ultraThinMaterial) + } + } + } + + private func cartItemRow(_ item: CartItemState) -> some View { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(item.name) + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + .lineLimit(1) + if let details = item.details, details.isEmpty == false { + Text(details) + .font(.caption) + .foregroundStyle(AppColors.textMuted) + .lineLimit(2) + } + Text(formatCurrency(item.unitPrice)) + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + } + + Spacer() + + HStack(spacing: 8) { + Button(action: { appState.cart.decrement(itemId: item.id) }) { + Image(systemName: "minus") + .font(.system(size: 13, weight: .bold)) + .foregroundStyle(AppColors.textPrimary) + .frame(width: 28, height: 28) + .background(AppColors.brandSoft) + .clipShape(Circle()) + } + .buttonStyle(.plain) + + Text("\(item.quantity)") + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + .frame(minWidth: 18) + + Button(action: { appState.cart.increment(itemId: item.id) }) { + Image(systemName: "plus") + .font(.system(size: 13, weight: .bold)) + .foregroundStyle(AppColors.textPrimary) + .frame(width: 28, height: 28) + .background(AppColors.tertiary) + .clipShape(Circle()) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + .padding(.horizontal, 20) + } + + private func formatCurrency(_ value: Double) -> String { + String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",") } } diff --git a/pedi-foods/Sources/PediFoods/Views/Main/HomeScrollOffsetObserver.swift b/pedi-foods/Sources/PediFoods/Views/Main/HomeScrollOffsetObserver.swift new file mode 100644 index 0000000..4489f2b --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Main/HomeScrollOffsetObserver.swift @@ -0,0 +1,76 @@ +import SwiftUI +#if canImport(UIKit) +import UIKit +#endif + +#if os(iOS) +@MainActor +struct ScrollOffsetObserver: UIViewRepresentable { + let onOffsetChange: (CGFloat) -> Void + + func makeUIView(context: Context) -> ScrollOffsetProbeView { + let view = ScrollOffsetProbeView() + view.onOffsetChange = onOffsetChange + return view + } + + func updateUIView(_ uiView: ScrollOffsetProbeView, context: Context) { + uiView.onOffsetChange = onOffsetChange + uiView.attachIfNeeded() + } +} + +@MainActor +final class ScrollOffsetProbeView: UIView { + var onOffsetChange: (CGFloat) -> Void = { _ in } + + private weak var observedScrollView: UIScrollView? + private var observation: NSKeyValueObservation? + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear + isUserInteractionEnabled = false + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + } + + deinit { + observation?.invalidate() + } + + override func didMoveToSuperview() { + super.didMoveToSuperview() + attachIfNeeded() + } + + override func didMoveToWindow() { + super.didMoveToWindow() + attachIfNeeded() + } + + func attachIfNeeded() { + guard let scrollView = findEnclosingScrollView() else { return } + guard scrollView !== observedScrollView else { return } + + observation?.invalidate() + observedScrollView = scrollView + observation = scrollView.observe(\.contentOffset, options: [.initial, .new]) { [weak self] sv, _ in + self?.onOffsetChange(sv.contentOffset.y) + } + } + + private func findEnclosingScrollView() -> UIScrollView? { + var view: UIView? = self + while let current = view { + if let scrollView = current as? UIScrollView { + return scrollView + } + view = current.superview + } + return nil + } +} +#endif diff --git a/pedi-foods/Sources/PediFoods/Views/Main/HomeView+Data.swift b/pedi-foods/Sources/PediFoods/Views/Main/HomeView+Data.swift new file mode 100644 index 0000000..155d45f --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Main/HomeView+Data.swift @@ -0,0 +1,126 @@ +import Foundation +import SwiftUI + +extension HomeView { + func buildCategories(from stores: [StoreSummary]) -> [CategoryModel] { + var unique: [CategoryModel] = [ + .init(id: "all", title: "Todas", systemIcon: "line.3.horizontal.decrease.circle", emojiIcon: nil) + ] + var seen = Set() + + for store in stores { + let raw = (store.category ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if raw.isEmpty { continue } + let dedupe = raw.lowercased() + if seen.contains(dedupe) { continue } + seen.insert(dedupe) + unique.append(.init(id: raw, title: raw, systemIcon: categoryIcon(for: raw), emojiIcon: nil)) + } + return unique + } + + @MainActor + func loadHomeCategories(withFallbackStores stores: [StoreSummary]) async { + do { + let response = try await ApiService().listPublicCategories() + if response.error == false, let remote = response.result, remote.isEmpty == false { + categories = mapPublicCategories(remote) + return + } + } catch { + // Fallback handled below. + } + + categories = buildCategories(from: stores) + } + + func mapPublicCategories(_ remote: [PublicCategory]) -> [CategoryModel] { + var mapped: [CategoryModel] = [] + var seen = Set() + + for item in remote { + let id = item.id.trimmingCharacters(in: .whitespacesAndNewlines) + let title = item.name.trimmingCharacters(in: .whitespacesAndNewlines) + if id.isEmpty || title.isEmpty { continue } + if seen.contains(id.lowercased()) { continue } + seen.insert(id.lowercased()) + mapped.append( + .init( + id: id, + title: title, + systemIcon: id.lowercased() == "all" ? "line.3.horizontal.decrease.circle" : nil, + emojiIcon: item.icon + ) + ) + } + + if mapped.contains(where: { $0.id.lowercased() == "all" }) == false { + mapped.insert(.init(id: "all", title: "Todas", systemIcon: "line.3.horizontal.decrease.circle", emojiIcon: nil), at: 0) + } else { + mapped.sort { lhs, rhs in + if lhs.id.lowercased() == "all" { return true } + if rhs.id.lowercased() == "all" { return false } + return lhs.title < rhs.title + } + } + + return mapped + } + + func categoryIcon(for category: String) -> String { + let value = category.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current).lowercased() + if value.contains("pizza") { return "takeoutbag.and.cup.and.straw" } + if value.contains("lanche") || value.contains("hamburg") || value.contains("burger") { return "fork.knife" } + if value.contains("cafe") || value.contains("breakfast") { return "sun.max" } + if value.contains("doce") || value.contains("sobremesa") || value.contains("dessert") { return "cup.and.saucer" } + return "storefront" + } + + @MainActor + func resolveCoordinates(forceRefresh: Bool) async -> (Double, Double)? { + if !forceRefresh { + if let lat = appState.address.latitude, let lng = appState.address.longitude { + return (lat, lng) + } + if let cached = LocationService.shared.cachedLocation() { + appState.address.latitude = cached.0 + appState.address.longitude = cached.1 + return cached + } + if hasConfiguredAddress() == false { + return nil + } + } + let coordinate = await LocationService.shared.requestLocationAsync(timeoutSeconds: 3) + if let coordinate { + appState.address.latitude = coordinate.0 + appState.address.longitude = coordinate.1 + } + return coordinate + } + + func hasConfiguredAddress() -> Bool { + if appState.address.selectedId != nil { + return true + } + if appState.address.latitude != nil, appState.address.longitude != nil { + return true + } + + let normalized = appState.address.display + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + + return normalized.isEmpty == false && normalized != "defina seu endereco" + } + + func storesUserMessage(_ error: Error) -> String { + if let service = error as? ApiServiceError { + return service.errorDescription ?? "Não foi possível carregar os estabelecimentos." + } + if let network = error as? NetworkError { + return network.errorDescription ?? "Não foi possível carregar os estabelecimentos." + } + return "Não foi possível carregar os estabelecimentos." + } +} diff --git a/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift b/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift index df12551..01d2425 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift @@ -91,7 +91,8 @@ struct HomeView: View { storeLogoURL: store.logoURL, storeCategory: store.category, storeRating: store.rating, - storeDistance: store.distance + storeDistance: store.distance, + appState: $appState ) } label: { FeaturedStoreCard(store: store) @@ -159,7 +160,8 @@ struct HomeView: View { storeLogoURL: store.logoURL, storeCategory: store.category, storeRating: store.rating, - storeDistance: store.distance + storeDistance: store.distance, + appState: $appState ) } label: { FeaturedStoreCard(store: store) @@ -410,127 +412,6 @@ struct HomeView: View { } } - private func buildCategories(from stores: [StoreSummary]) -> [CategoryModel] { - var unique: [CategoryModel] = [ - .init(id: "all", title: "Todas", systemIcon: "line.3.horizontal.decrease.circle", emojiIcon: nil) - ] - var seen = Set() - - for store in stores { - let raw = (store.category ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - if raw.isEmpty { continue } - let dedupe = raw.lowercased() - if seen.contains(dedupe) { continue } - seen.insert(dedupe) - unique.append(.init(id: raw, title: raw, systemIcon: categoryIcon(for: raw), emojiIcon: nil)) - } - return unique - } - - @MainActor - private func loadHomeCategories(withFallbackStores stores: [StoreSummary]) async { - do { - let response = try await ApiService().listPublicCategories() - if response.error == false, let remote = response.result, remote.isEmpty == false { - categories = mapPublicCategories(remote) - return - } - } catch { - // Fallback handled below. - } - - categories = buildCategories(from: stores) - } - - private func mapPublicCategories(_ remote: [PublicCategory]) -> [CategoryModel] { - var mapped: [CategoryModel] = [] - var seen = Set() - - for item in remote { - let id = item.id.trimmingCharacters(in: .whitespacesAndNewlines) - let title = item.name.trimmingCharacters(in: .whitespacesAndNewlines) - if id.isEmpty || title.isEmpty { continue } - if seen.contains(id.lowercased()) { continue } - seen.insert(id.lowercased()) - mapped.append( - .init( - id: id, - title: title, - systemIcon: id.lowercased() == "all" ? "line.3.horizontal.decrease.circle" : nil, - emojiIcon: item.icon - ) - ) - } - - if mapped.contains(where: { $0.id.lowercased() == "all" }) == false { - mapped.insert(.init(id: "all", title: "Todas", systemIcon: "line.3.horizontal.decrease.circle", emojiIcon: nil), at: 0) - } else { - mapped.sort { lhs, rhs in - if lhs.id.lowercased() == "all" { return true } - if rhs.id.lowercased() == "all" { return false } - return lhs.title < rhs.title - } - } - - return mapped - } - - private func categoryIcon(for category: String) -> String { - let value = category.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current).lowercased() - if value.contains("pizza") { return "takeoutbag.and.cup.and.straw" } - if value.contains("lanche") || value.contains("hamburg") || value.contains("burger") { return "fork.knife" } - if value.contains("cafe") || value.contains("breakfast") { return "sun.max" } - if value.contains("doce") || value.contains("sobremesa") || value.contains("dessert") { return "cup.and.saucer" } - return "storefront" - } - - @MainActor - private func resolveCoordinates(forceRefresh: Bool) async -> (Double, Double)? { - if !forceRefresh { - if let lat = appState.address.latitude, let lng = appState.address.longitude { - return (lat, lng) - } - if let cached = LocationService.shared.cachedLocation() { - appState.address.latitude = cached.0 - appState.address.longitude = cached.1 - return cached - } - if hasConfiguredAddress() == false { - return nil - } - } - let coordinate = await LocationService.shared.requestLocationAsync(timeoutSeconds: 3) - if let coordinate { - appState.address.latitude = coordinate.0 - appState.address.longitude = coordinate.1 - } - return coordinate - } - - private func hasConfiguredAddress() -> Bool { - if appState.address.selectedId != nil { - return true - } - if appState.address.latitude != nil, appState.address.longitude != nil { - return true - } - - let normalized = appState.address.display - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() - - return normalized.isEmpty == false && normalized != "defina seu endereco" - } - - private func storesUserMessage(_ error: Error) -> String { - if let service = error as? ApiServiceError { - return service.errorDescription ?? "Não foi possível carregar os estabelecimentos." - } - if let network = error as? NetworkError { - return network.errorDescription ?? "Não foi possível carregar os estabelecimentos." - } - return "Não foi possível carregar os estabelecimentos." - } @ViewBuilder private var scrollOffsetObserver: some View { @@ -547,136 +428,3 @@ struct HomeView: View { #endif } } - -struct CategoryModel: Identifiable { - let id: String - let title: String - let systemIcon: String? - let emojiIcon: String? -} - -struct CategoryChip: View { - let title: String - let systemIcon: String? - let emojiIcon: String? - let isActive: Bool - - var body: some View { - HStack(spacing: 8) { - if let emojiIcon, emojiIcon.isEmpty == false { - Text(emojiIcon) - .font(.body) - } else if let systemIcon, systemIcon.isEmpty == false { - Image(systemName: systemIcon) - .font(.caption) - } - Text(title) - .font(AppTypography.heading3) - } - .foregroundStyle(isActive ? AppColors.textInverse : AppColors.textPrimary) - .padding(.horizontal, 16) - .padding(.vertical, 10) - .background(isActive ? AppColors.brandDark : AppColors.surface) - .clipShape(Capsule()) - } -} - -struct SearchBar: View { - let placeholder: String - @Binding var text: String - var onFilterTap: () -> Void = {} - - var body: some View { - HStack(spacing: 12) { - Image(systemName: "magnifyingglass") - .foregroundStyle(AppColors.textMuted) - TextField(placeholder, text: $text) - .appNoAutoCap() - Spacer() - Button(action: onFilterTap) { - Image(systemName: "slider.horizontal.3") - .foregroundStyle(AppColors.textMuted) - } - } - .padding(.horizontal, 16) - .frame(height: 52) - .background(AppColors.surface) - .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) - } -} - -func clamp(value: CGFloat, lower: CGFloat, upper: CGFloat) -> CGFloat { - min(max(value, lower), upper) -} - -#if os(iOS) -@MainActor -private struct ScrollOffsetObserver: UIViewRepresentable { - let onOffsetChange: (CGFloat) -> Void - - func makeUIView(context: Context) -> ScrollOffsetProbeView { - let view = ScrollOffsetProbeView() - view.onOffsetChange = onOffsetChange - return view - } - - func updateUIView(_ uiView: ScrollOffsetProbeView, context: Context) { - uiView.onOffsetChange = onOffsetChange - uiView.attachIfNeeded() - } -} - -@MainActor -private final class ScrollOffsetProbeView: UIView { - var onOffsetChange: (CGFloat) -> Void = { _ in } - - private weak var observedScrollView: UIScrollView? - private var observation: NSKeyValueObservation? - - override init(frame: CGRect) { - super.init(frame: frame) - backgroundColor = .clear - isUserInteractionEnabled = false - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - } - - deinit { - observation?.invalidate() - } - - override func didMoveToSuperview() { - super.didMoveToSuperview() - attachIfNeeded() - } - - override func didMoveToWindow() { - super.didMoveToWindow() - attachIfNeeded() - } - - func attachIfNeeded() { - guard let scrollView = findEnclosingScrollView() else { return } - guard scrollView !== observedScrollView else { return } - - observation?.invalidate() - observedScrollView = scrollView - observation = scrollView.observe(\.contentOffset, options: [.initial, .new]) { [weak self] sv, _ in - self?.onOffsetChange(sv.contentOffset.y) - } - } - - private func findEnclosingScrollView() -> UIScrollView? { - var view: UIView? = self - while let current = view { - if let scrollView = current as? UIScrollView { - return scrollView - } - view = current.superview - } - return nil - } -} -#endif diff --git a/pedi-foods/Sources/PediFoods/Views/Main/HomeViewComponents.swift b/pedi-foods/Sources/PediFoods/Views/Main/HomeViewComponents.swift new file mode 100644 index 0000000..495ce35 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Main/HomeViewComponents.swift @@ -0,0 +1,62 @@ +import SwiftUI + +struct CategoryModel: Identifiable { + let id: String + let title: String + let systemIcon: String? + let emojiIcon: String? +} + +struct CategoryChip: View { + let title: String + let systemIcon: String? + let emojiIcon: String? + let isActive: Bool + + var body: some View { + HStack(spacing: 8) { + if let emojiIcon, emojiIcon.isEmpty == false { + Text(emojiIcon) + .font(.body) + } else if let systemIcon, systemIcon.isEmpty == false { + Image(systemName: systemIcon) + .font(.caption) + } + Text(title) + .font(AppTypography.heading3) + } + .foregroundStyle(isActive ? AppColors.textInverse : AppColors.textPrimary) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(isActive ? AppColors.brandDark : AppColors.surface) + .clipShape(Capsule()) + } +} + +struct SearchBar: View { + let placeholder: String + @Binding var text: String + var onFilterTap: () -> Void = {} + + var body: some View { + HStack(spacing: 12) { + Image(systemName: "magnifyingglass") + .foregroundStyle(AppColors.textMuted) + TextField(placeholder, text: $text) + .appNoAutoCap() + Spacer() + Button(action: onFilterTap) { + Image(systemName: "slider.horizontal.3") + .foregroundStyle(AppColors.textMuted) + } + } + .padding(.horizontal, 16) + .frame(height: 52) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } +} + +func clamp(value: CGFloat, lower: CGFloat, upper: CGFloat) -> CGFloat { + min(max(value, lower), upper) +} diff --git a/pedi-foods/Sources/PediFoods/Views/Main/MainTabView.swift b/pedi-foods/Sources/PediFoods/Views/Main/MainTabView.swift index 300209c..8514e8e 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/MainTabView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/MainTabView.swift @@ -16,7 +16,7 @@ struct MainTabView: View { } case .cart: NavigationStack { - CartView() + CartView(appState: $appState) } case .profile: NavigationStack { diff --git a/pedi-foods/Sources/PediFoods/Views/Main/ProductDetailSheet.swift b/pedi-foods/Sources/PediFoods/Views/Main/ProductDetailSheet.swift new file mode 100644 index 0000000..1e3240b --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Main/ProductDetailSheet.swift @@ -0,0 +1,196 @@ +import Foundation +import SwiftUI + +struct ProductDetailSheet: View { + let product: StoreCatalogProduct + let imageURL: String? + let storeId: String + let currentQuantityForItemId: (String) -> Int + let onAdd: (CartItemState) -> Void + @Environment(\.dismiss) var dismiss + @State var selectedAddonItemIds: Set = [] + @State var quantity: Int = 0 + + private var selectedAddonItems: [StoreAddonItem] { + product.addonGroups + .flatMap(\.items) + .filter { selectedAddonItemIds.contains($0.id) } + } + + private var addonsTotal: Double { + selectedAddonItems.reduce(0) { $0 + ($1.price ?? 0) } + } + + private var unitPrice: Double { + (product.price ?? 0) + addonsTotal + } + + private var totalPrice: Double { + unitPrice * Double(quantity) + } + + private var cartItemId: String { + let addonKey = selectedAddonItemIds.sorted().joined(separator: ",") + return "\(storeId)::\(product.id)::\(addonKey)" + } + + private var selectedAddonsSummary: String? { + let names = selectedAddonItems.map(\.name) + if names.isEmpty { return nil } + return names.joined(separator: ", ") + } + + private var addButtonTitle: String { + if quantity <= 0 { + return "Remover do carrinho" + } + return "Atualizar • \(formatCurrency(totalPrice))" + } + + var body: some View { + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 16) { + AsyncStoreImage(imageURL: imageURL) + .frame(height: 220) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous)) + + Text(product.name) + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + + if let description = product.description, description.isEmpty == false { + Text(description) + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + } + + Text(formatCurrency(unitPrice)) + .font(AppTypography.heading2) + .foregroundStyle(AppColors.primary) + + if addonsTotal > 0 { + Text("Inclui adicionais: \(formatCurrency(addonsTotal))") + .font(.caption) + .foregroundStyle(AppColors.textMuted) + } + + if product.addonGroups.isEmpty == false { + Text("Adicionais") + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + + ForEach(product.addonGroups) { group in + VStack(alignment: .leading, spacing: 8) { + Text(group.name) + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + + ForEach(group.items) { item in + Button { + if selectedAddonItemIds.contains(item.id) { + selectedAddonItemIds.remove(item.id) + } else { + selectedAddonItemIds.insert(item.id) + } + } label: { + HStack { + Image(systemName: selectedAddonItemIds.contains(item.id) ? "checkmark.circle.fill" : "circle") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(selectedAddonItemIds.contains(item.id) ? AppColors.primary : AppColors.textMuted) + + Text(item.name) + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + Spacer() + Text(String(format: "+ R$ %.2f", item.price ?? 0).replacingOccurrences(of: ".", with: ",")) + .font(AppTypography.body) + .foregroundStyle(AppColors.textPrimary) + } + } + .buttonStyle(.plain) + } + } + .padding(12) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + } + } + .padding(20) + .padding(.bottom, 80) + } + .safeAreaInset(edge: .bottom) { + HStack(spacing: 12) { + HStack(spacing: 10) { + Button(action: { if quantity > 0 { quantity -= 1 } }) { + Image(systemName: "minus") + .font(.system(size: 14, weight: .bold)) + .foregroundStyle(AppColors.textPrimary) + .frame(width: 32, height: 32) + .background(AppColors.surface) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .disabled(quantity <= 0) + + Text("\(quantity)") + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + .frame(minWidth: 20) + + Button(action: { quantity += 1 }) { + Image(systemName: "plus") + .font(.system(size: 14, weight: .bold)) + .foregroundStyle(AppColors.textPrimary) + .frame(width: 32, height: 32) + .background(AppColors.surface) + .clipShape(Circle()) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 8) + .frame(height: 48) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .buttonStyle(.plain) + + PrimaryButton(title: addButtonTitle) { + let item = CartItemState( + id: cartItemId, + productId: product.id, + storeId: storeId, + name: product.name, + details: selectedAddonsSummary, + quantity: quantity, + unitPrice: unitPrice + ) + onAdd(item) + dismiss() + } + } + .padding(.horizontal, 20) + .padding(.top, 8) + .padding(.bottom, 12) + .background(.ultraThinMaterial) + } + .background(AppColors.backgroundLight.ignoresSafeArea()) + .navigationTitle("Detalhes") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Fechar") { dismiss() } + .foregroundStyle(AppColors.primary) + } + } + .onAppear { + quantity = currentQuantityForItemId(cartItemId) + } + .onChange(of: selectedAddonItemIds) { _, _ in + quantity = currentQuantityForItemId(cartItemId) + } + } + + private func formatCurrency(_ value: Double) -> String { + String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",") + } +} diff --git a/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift b/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift index 11a404a..639fa06 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift @@ -1,4 +1,5 @@ import SwiftUI +import LCEssentials #if canImport(UIKit) import UIKit #endif @@ -10,40 +11,95 @@ struct ProfileView: View { @Binding var appState: AppState @State var openAddressesOnboarding = false @State var onboardingMessage: String? = nil + @State var showLogoutAlert = false + let tabBarClearance: CGFloat = 120 var body: some View { - VStack(spacing: 20) { - header + ScrollView(showsIndicators: false) { + VStack(spacing: 18) { + header - VStack(spacing: 12) { - NavigationLink("Pedidos") { - OrdersView() - } + VStack(spacing: 14) { + NavigationLink { + OrdersView() + } label: { + ProfileMenuRow(icon: "list.bullet.clipboard.fill", title: "Meus Pedidos") + } + .buttonStyle(.plain) - NavigationLink("Enderecos") { - AddressesView(message: nil, appState: $appState) - } + NavigationLink { + AddressesView(message: nil, appState: $appState) + } label: { + ProfileMenuRow(icon: "mappin.circle.fill", title: "Meus Endereços") + } + .buttonStyle(.plain) - NavigationLink("Ajuda") { - Text("Ajuda") +// NavigationLink { +// Text("Meus Cartões") +// } label: { +// ProfileMenuRow(icon: "creditcard.fill", title: "Meus Cartões") +// } +// .buttonStyle(.plain) + + NavigationLink { + Text("Minhas Avaliações") + } label: { + ProfileMenuRow(icon: "star.fill", title: "Minhas Avaliações") + } + .buttonStyle(.plain) + +// NavigationLink { +// Text("Cupons de Desconto") +// } label: { +// ProfileMenuRow(icon: "ticket.fill", title: "Cupons de Desconto", badge: "NOVO") +// } +// .buttonStyle(.plain) + + NavigationLink { + Text("Ajuda") + } label: { + ProfileMenuRow(icon: "gearshape.fill", title: "Configurações") + } + .buttonStyle(.plain) } + .padding(.horizontal, 20) + + Button(action: { showLogoutAlert = true }) { + HStack(spacing: 10) { + Image(systemName: "rectangle.portrait.and.arrow.right") + .font(.system(size: 18, weight: .semibold)) + Text("Sair da Conta") + .font(AppTypography.heading3) + } + .foregroundStyle(Color.red) + } + .buttonStyle(.plain) + .padding(.top, 10) + .padding(.horizontal, 20) + + Text("Versão 4.20.1") + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(AppColors.textMuted) + .padding(.bottom, 12) } - .font(AppTypography.body) - .foregroundStyle(AppColors.textPrimary) - - Spacer() - - SecondaryButton(title: "Sair") { - tokenStore.clear() - SessionStateStore.clearActiveUser() - appState = AppState() - root = .auth - } - .padding(.horizontal, 20) } - .padding(.top, 24) + .ignoresSafeArea(edges: .top) + .safeAreaInset(edge: .bottom) { + Rectangle() + .fill(AppColors.backgroundLight.opacity(0.8)) + .frame(height: tabBarClearance) + .padding(.bottom, -UIDevice.bottomNotch) + } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(AppColors.backgroundLight) + .alert("Sair da conta?", isPresented: $showLogoutAlert) { + Button("Cancelar", role: .cancel) {} + Button("Sair", role: .destructive) { + logout() + } + } message: { + Text("Tem certeza que deseja sair da sua conta?") + } .onAppear { guard let message = appState.address.onboardingMessage else { return @@ -60,823 +116,154 @@ struct ProfileView: View { } private var header: some View { - VStack(spacing: 8) { - Circle() - .fill(AppColors.primary) - .frame(width: 72, height: 72) - .overlay( - Text("DL") - .font(.headline) - .foregroundStyle(AppColors.textInverse) - ) - - Text(appState.profile.name.isEmpty ? "Daniel Loverde" : appState.profile.name) - .font(AppTypography.heading2) - .foregroundStyle(AppColors.textPrimary) - - Text(appState.profile.email.isEmpty ? "daniel@pedifoods.com.br" : appState.profile.email) - .font(.caption) - .foregroundStyle(AppColors.secondary) - } - } -} - -struct AddressesView: View { - let message: String? - @Binding var appState: AppState - var selectionMode: Bool = false - @Environment(\.dismiss) var dismiss - @State var isLoading = false - @State var errorMessage: String? = nil - @State var addresses: [CustomerAddress] = [] - @State var openAddAddressForm = false - @State var editingAddress: CustomerAddress? = nil - @State var openSwipeRowId: String? = nil - @State var deletingRowId: String? = nil - - let tabBarClearance: CGFloat = 96 - - var body: some View { - ZStack { - AppColors.backgroundLight - .ignoresSafeArea() - - ScrollView(showsIndicators: false) { - VStack(spacing: 20) { - header - - if let message { - Text(message) - .font(AppTypography.body) - .foregroundStyle(AppColors.textPrimary) - .multilineTextAlignment(.center) - .padding(.horizontal, 20) - .padding(.vertical, 14) - .frame(maxWidth: .infinity, alignment: .center) - .background(AppColors.brandSoft) - .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) - } - - VStack(spacing: 16) { - if isLoading { - ProgressView() - .padding(.top, 24) - } else if let errorMessage { - Text(errorMessage) - .font(AppTypography.body) - .foregroundStyle(AppColors.textMuted) - .multilineTextAlignment(.center) - .padding(.top, 24) - } else if addresses.isEmpty { - Text("Nenhum endereço cadastrado") - .font(AppTypography.body) - .foregroundStyle(AppColors.textMuted) - .padding(.top, 24) - } else { - ForEach(Array(addresses.enumerated()), id: \.offset) { index, address in - let rowId = addressRowId(for: address, index: index) - let isSelected: Bool = { - if let selectedId = appState.address.selectedId { - return address.id == selectedId - } - return index == 0 - }() - - if selectionMode { - Button { - selectAddress(address) - } label: { - AddressCard(item: addressToListItem(address, isPrimary: isSelected)) - } - .buttonStyle(.plain) - } else { - SwipeToDeleteAddressRow( - rowId: rowId, - openRowId: $openSwipeRowId, - isDeleting: deletingRowId == rowId, - onDelete: { deleteAddress(address, rowId: rowId) } - ) { - AddressCard( - item: addressToListItem(address, isPrimary: isSelected), - onEdit: { beginEditing(address) } - ) - .contentShape(Rectangle()) - .onTapGesture { - if openSwipeRowId == rowId { - openSwipeRowId = nil - } - } - } - .id(rowId) - .opacity(deletingRowId == rowId ? 0.6 : 1.0) - .disabled(deletingRowId != nil) - } - } - } - } - } - .padding(.horizontal, 20) - .padding(.top, 18) - } - - VStack { - Spacer() - bottomOverlay - .padding(.bottom, tabBarClearance) - } - - } - .navigationBarBackButtonHidden(true) - .toolbar(.hidden, for: .navigationBar) - .sheet(isPresented: $openAddAddressForm) { - NavigationStack { - AddAddressFormView(existingAddress: editingAddress) { updatedAddresses, wasEditing in - addresses = updatedAddresses - applyPreferredAddress(from: updatedAddresses) - let title = wasEditing ? "Endereço atualizado com sucesso." : "Endereço adicionado com sucesso." - SnackbarCenter.shared.show(title: title, style: .success, icon: "checkmark.seal.fill", duration: 3.0) - } - } - } - .onChange(of: openAddAddressForm) { _, isOpen in - if isOpen == false { - editingAddress = nil - } - } - .onAppear { - if isLoading == false, addresses.isEmpty { - Task { - await loadAddresses() - } - } - } - } - - var header: some View { - ZStack { - Text("Meus Endereços") - .font(AppTypography.heading2) - .foregroundStyle(AppColors.textPrimary) - - HStack { - Button(action: { dismiss() }) { - Image(systemName: "chevron.left") - .font(.system(size: 24, weight: .semibold)) - .foregroundStyle(AppColors.textPrimary) - .frame(width: 52, height: 52) - .background(AppColors.surface) - .clipShape(Circle()) - .shadow(color: .black.opacity(0.06), radius: 8, y: 2) - } - - Spacer() - } - } - } - - var bottomOverlay: some View { - ZStack(alignment: .bottom) { - Rectangle() - .fill(AppColors.backgroundLight) - .frame(height: 136) - - Button(action: { - editingAddress = nil - openAddAddressForm = true - }) { - HStack(spacing: 12) { - Image(systemName: "mappin.circle.fill") - .font(.system(size: 24)) - Text("Adicionar novo endereço") - .font(AppTypography.heading3) - } - .foregroundStyle(AppColors.textInverse) - .frame(maxWidth: .infinity) - .padding(.vertical, 18) - .background(AppColors.primary) - .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous)) - } - .buttonStyle(.plain) - .padding(.horizontal, 20) - .padding(.bottom, 14) - } - } - - private func selectAddress(_ address: CustomerAddress) { - appState.address.selectedId = address.id - let label = (address.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - appState.address.display = label.isEmpty ? "Defina seu endereco" : label - - if let lat = address.latLong?.first, let lng = address.latLong?.dropFirst().first { - appState.address.latitude = lat - appState.address.longitude = lng - } else { - appState.address.latitude = nil - appState.address.longitude = nil - } - - SessionStateStore.saveAddress(appState.address) - - if selectionMode { - dismiss() - } - } - - private func beginEditing(_ address: CustomerAddress) { - openSwipeRowId = nil - editingAddress = address - openAddAddressForm = true - } - - private func applyPreferredAddress(from updatedAddresses: [CustomerAddress]) { - let selected = updatedAddresses.first(where: { $0.id == appState.address.selectedId }) ?? updatedAddresses.first - appState.address.selectedId = selected?.id - appState.address.display = selected?.label?.isEmpty == false ? (selected?.label ?? "Defina seu endereco") : "Defina seu endereco" - - if let lat = selected?.latLong?.first, let lng = selected?.latLong?.dropFirst().first { - appState.address.latitude = lat - appState.address.longitude = lng - } else { - appState.address.latitude = nil - appState.address.longitude = nil - } - - SessionStateStore.saveAddress(appState.address) - } - - private func addressRowId(for address: CustomerAddress, index: Int) -> String { - if let id = address.id, id.isEmpty == false { - return "addr:\(id)" - } - return "idx:\(index):\(address.label ?? ""):\(address.address ?? ""):\(address.number ?? "")" - } - - private func deleteAddress(_ address: CustomerAddress, rowId: String) { - guard deletingRowId == nil else { return } - deletingRowId = rowId - openSwipeRowId = nil - - Task { - do { - let response = try await ApiService().deleteCustomerAddress(address) - await MainActor.run { - deletingRowId = nil - - guard response.error == false else { - let message = response.message ?? "Não foi possível excluir o endereço." - SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 4.0) - return - } - - let updatedAddresses = response.result?.addressBook ?? [] - addresses = updatedAddresses - applyPreferredAddress(from: updatedAddresses) - SnackbarCenter.shared.show(title: "Endereço removido com sucesso.", style: .success, icon: "checkmark.seal.fill", duration: 3.0) - } - } catch { - await MainActor.run { - deletingRowId = nil - let message = error.localizedDescription - SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 4.0) - } - } - } - } - - func addressToListItem(_ address: CustomerAddress, isPrimary: Bool) -> AddressListItem { - let title = address.label?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? (address.label ?? "Endereço") : "Endereço" - let line1 = [address.address, address.number] - .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { $0.isEmpty == false } - .joined(separator: ", ") - let line2 = [address.neighborhood, address.city, address.state] - .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { $0.isEmpty == false } - .joined(separator: ", ") - let detail = [line1, line2] - .filter { $0.isEmpty == false } - .joined(separator: " - ") - - return AddressListItem( - title: title, - detail: detail.isEmpty ? "Endereço sem detalhes" : detail, - icon: iconName(for: title), - isPrimary: isPrimary - ) - } - - func iconName(for label: String) -> String { - let normalized = label.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) - if normalized.contains("casa") { - return "house.fill" - } - if normalized.contains("trabalho") { - return "briefcase.fill" - } - return "mappin.and.ellipse" - } - - @MainActor - func loadAddresses() async { - isLoading = true - errorMessage = nil - - do { - let service = ApiService() - let response = try await service.profile() - guard response.error == false else { - errorMessage = response.message ?? "Não foi possível carregar os endereços." - isLoading = false - return - } - - if let customer = response.result { - appState.profile.id = customer.id - appState.profile.name = customer.name - appState.profile.email = customer.email - appState.profile.phone = customer.phoneNumber ?? appState.profile.phone - SessionStateStore.setActiveUserKey( - SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email) - ) - addresses = customer.addressBook ?? [] - } else { - addresses = [] - } - if let selected = addresses.first(where: { $0.id == appState.address.selectedId }) ?? addresses.first { - appState.address.selectedId = selected.id - appState.address.display = selected.label ?? "Defina seu endereco" - if let lat = selected.latLong?.first, let lng = selected.latLong?.dropFirst().first { - appState.address.latitude = lat - appState.address.longitude = lng - } - SessionStateStore.saveAddress(appState.address) - } - } catch { - errorMessage = error.localizedDescription - } - - isLoading = false - } -} - -struct AddAddressFormView: View { - @Environment(\.dismiss) var dismiss - @Environment(\.colorScheme) var colorScheme - @State var label = "" - @State var zipCode = "" - @State var address = "" - @State var number = "" - @State var complement = "" - @State var neighborhood = "" - @State var city = "" - @State var state = "" - @State var isLoading = false - @State var isLookingUpZipCode = false - @State var zipLookupMessage: String? = nil - @State var lastLookedUpZipCode = "" - @State var lookedUpLatitude: Double? = nil - @State var lookedUpLongitude: Double? = nil - - let existingAddress: CustomerAddress? - let onSave: ([CustomerAddress], Bool) -> Void - - private var isFormValid: Bool { - !label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && - normalizeZipCodeForAPI(zipCode).count == 8 && - !address.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && - !number.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && - !neighborhood.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && - !city.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && - !state.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - } - - @ViewBuilder private var logoImage: some View { -#if os(Android) - SwiftUI.Image("pedifoods") - .resizable() -#else - SwiftUI.Image("pedifoods") - .resizable() -#endif - } - - var body: some View { - ZStack { - (colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea() - - ScrollView { - VStack(spacing: 0) { - logoImage - .scaledToFit() - .frame(width: 120, height: 120) - - Text(existingAddress == nil ? "Novo endereço" : "Editar endereço") - .font(AppTypography.heading1) - .foregroundStyle(colorScheme == .dark ? Color.white : AppColors.textPrimary) - - Text(existingAddress == nil ? "Preencha os dados abaixo para adicionar um endereço." : "Atualize os dados do endereço abaixo.") - .font(AppTypography.body) - .foregroundStyle(colorScheme == .dark ? Color.white : AppColors.textPrimary) - .multilineTextAlignment(.center) - .padding(.top, 8) - .padding(.bottom, 20) - - VStack(alignment: .leading, spacing: 16) { - LoginField(icon: "tag", placeholder: "Ex: Casa, Trabalho", text: $label) - LoginField(icon: "mail", placeholder: "CEP", text: $zipCode) - .onChange(of: zipCode) { _, newValue in - let masked = formatZipCodeBR(newValue) - if masked != newValue { - zipCode = masked - } - - let normalized = normalizeZipCodeForAPI(masked) - if normalized.count == 8, normalized != lastLookedUpZipCode, !isLookingUpZipCode { - Task { - await lookupAddressByZipCode(normalized) - } - } - } - if isLookingUpZipCode { - HStack(spacing: 8) { - ProgressView() - Text("Buscando endereço pelo CEP...") - .font(.caption) - .foregroundStyle(AppColors.textMuted) - } - .padding(.horizontal, 6) - } else if let zipLookupMessage { - Text(zipLookupMessage) - .font(.caption) - .foregroundStyle(AppColors.textMuted) - .padding(.horizontal, 6) - } - LoginField(icon: "mappin.and.ellipse", placeholder: "Rua / Avenida", text: $address) - LoginField(icon: "number", placeholder: "Número", text: $number) - LoginField(icon: "plus.app", placeholder: "Complemento (opcional)", text: $complement) - LoginField(icon: "square.grid.2x2", placeholder: "Bairro", text: $neighborhood) - LoginField(icon: "building.2", placeholder: "Cidade", text: $city) - LoginField(icon: "map", placeholder: "Estado (UF)", text: $state) - .onChange(of: state) { _, newValue in - let normalized = String(newValue.uppercased().prefix(2)) - if normalized != newValue { - state = normalized - } - } - } - .padding(.horizontal, 24) - .padding(.bottom, 20) - - PrimaryButton(title: existingAddress == nil ? "Salvar endereço" : "Atualizar endereço", image: Image(systemName: "checkmark")) { - saveAddress() - } - .padding(.horizontal, 24) - .disabled(!isFormValid || isLoading) - .opacity((!isFormValid || isLoading) ? 0.5 : 1.0) - .tint(AppColors.tertiary) - - SecondaryButton(title: "Cancelar") { - dismiss() - } - .padding(.horizontal, 24) - .padding(.top, 12) - - Spacer().frame(height: 120) - } - } - .padding(.top, 0) - } - .navigationBarBackButtonHidden(true) - .toolbar(.hidden, for: .navigationBar) - .onAppear { - populateFromExistingAddressIfNeeded() - } - } - - private func saveAddress() { - guard !isLoading else { return } - let latLong: [Double]? = { - if let lat = lookedUpLatitude, let lng = lookedUpLongitude { - return [lat, lng] - } - return nil - }() - - let newAddress = CustomerAddress( - id: existingAddress?.id ?? UUID().uuidString, - label: clean(label), - address: clean(address), - number: clean(number), - complement: optional(clean(complement)), - neighborhood: clean(neighborhood), - city: clean(city), - state: clean(state), - zipCode: optional(normalizeZipCodeForAPI(zipCode)), - latLong: latLong - ) - isLoading = true - zipLookupMessage = nil - - Task { - do { - let response = try await ApiService().saveCustomerAddress(newAddress, replacingAddressId: existingAddress?.id) - await MainActor.run { - isLoading = false - if response.error { - zipLookupMessage = response.message ?? "Não foi possível salvar o endereço." - SnackbarCenter.shared.show(title: zipLookupMessage ?? "Não foi possível salvar o endereço.", style: .error, icon: "xmark.octagon.fill", duration: 4.0) - return - } - - let updatedAddresses = response.result?.addressBook ?? [newAddress] - onSave(updatedAddresses, existingAddress != nil) - dismiss() - } - } catch { - await MainActor.run { - isLoading = false - let message = error.localizedDescription - zipLookupMessage = message - SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 4.0) - } - } - } - } - - private func populateFromExistingAddressIfNeeded() { - guard let existingAddress else { return } - label = existingAddress.label ?? "" - zipCode = formatZipCodeBR(existingAddress.zipCode ?? "") - lastLookedUpZipCode = normalizeZipCodeForAPI(zipCode) - address = existingAddress.address ?? "" - number = existingAddress.number ?? "" - complement = existingAddress.complement ?? "" - neighborhood = existingAddress.neighborhood ?? "" - city = existingAddress.city ?? "" - state = String((existingAddress.state ?? "").uppercased().prefix(2)) - lookedUpLatitude = existingAddress.latLong?.first - lookedUpLongitude = existingAddress.latLong?.dropFirst().first - } - - private func clean(_ value: String) -> String { - value.trimmingCharacters(in: .whitespacesAndNewlines) - } - - private func optional(_ value: String) -> String? { - value.isEmpty ? nil : value - } - - @MainActor - private func lookupAddressByZipCode(_ zip: String) async { - isLookingUpZipCode = true - zipLookupMessage = nil - defer { isLookingUpZipCode = false } - - do { - let response = try await ApiService().lookupZipCode(zip) - lastLookedUpZipCode = zip - - guard response.error == false, let result = response.result else { - zipLookupMessage = response.message ?? "Não foi possível consultar este CEP." - return - } - - fillAddressFields(with: result) - zipLookupMessage = "Endereço preenchido automaticamente." - } catch { - zipLookupMessage = "Não foi possível consultar o CEP agora." - } - } - - private func fillAddressFields(with result: CepLookupResult) { - if address.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - address = result.street?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - } - if neighborhood.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - neighborhood = result.neighborhood?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - } - if city.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - city = result.city?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - } - if state.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - state = String((result.state ?? "").uppercased().prefix(2)) - } - if complement.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - complement = result.complement?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - } - lookedUpLatitude = result.latitude - lookedUpLongitude = result.longitude - } -} - -func formatZipCodeBR(_ input: String) -> String { - let digits = input.filter(\.isNumber) - let limited = String(digits.prefix(8)) - if limited.count <= 5 { - return limited - } - let prefix = String(limited.prefix(5)) - let suffix = String(limited.dropFirst(5)) - return "\(prefix)-\(suffix)" -} - -func normalizeZipCodeForAPI(_ input: String) -> String { - String(input.filter(\.isNumber).prefix(8)) -} - -func triggerLightHaptic() { -#if canImport(UIKit) - UIImpactFeedbackGenerator(style: .light).impactOccurred() -#endif -} - -func triggerSelectionHaptic() { -#if canImport(UIKit) - UISelectionFeedbackGenerator().selectionChanged() -#endif -} - -struct SwipeToDeleteAddressRow: View { - let rowId: String - @Binding var openRowId: String? - let isDeleting: Bool - let onDelete: () -> Void - @ViewBuilder var content: () -> Content - - @State var contentOffset: CGFloat = 0 - - private let deleteWidth: CGFloat = 92 - private let openThreshold: CGFloat = 32 - private var showsDeleteAction: Bool { contentOffset < -2 || openRowId == rowId } - - var body: some View { - ZStack(alignment: .trailing) { - HStack(spacing: 0) { - Spacer(minLength: 0) - Button(action: { - triggerLightHaptic() - onDelete() - }) { - VStack(spacing: 8) { - Image(systemName: "trash.fill") - .font(.system(size: 20, weight: .semibold)) - Text(isDeleting ? "..." : "Excluir") - .font(AppTypography.overline) - } - .foregroundStyle(AppColors.textInverse) - .frame(width: deleteWidth) - .frame(maxHeight: .infinity) - .background(Color.red) - } - .buttonStyle(.plain) - .disabled(isDeleting) - .opacity(showsDeleteAction ? 1 : 0) - .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous)) - } - - content() - .offset(x: contentOffset) - .gesture( - DragGesture(minimumDistance: 8) - .onChanged { value in - guard isDeleting == false else { return } - if value.translation.width < 0 { - contentOffset = max(-deleteWidth, value.translation.width) - } else if openRowId == rowId { - contentOffset = min(0, -deleteWidth + value.translation.width) - } - } - .onEnded { _ in - guard isDeleting == false else { return } - if contentOffset <= -openThreshold { - let wasClosed = openRowId != rowId - contentOffset = -deleteWidth - openRowId = rowId - if wasClosed { - triggerSelectionHaptic() - } - } else { - contentOffset = 0 - if openRowId == rowId { - openRowId = nil - } - } - } - ) - .animation(.easeOut(duration: 0.18), value: contentOffset) - } - .clipped() - .animation(.easeOut(duration: 0.18), value: showsDeleteAction) - .onChange(of: openRowId) { _, newValue in - if newValue != rowId { - contentOffset = 0 - } - } - .onChange(of: isDeleting) { _, newValue in - if newValue { - contentOffset = 0 - } - } - } -} - -struct AddressCard: View { - let item: AddressListItem - var onEdit: (() -> Void)? = nil - var onDelete: (() -> Void)? = nil - - var body: some View { - HStack(spacing: 14) { - icon - - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 10) { - Text(item.title) - .font(AppTypography.heading2) - .foregroundStyle(AppColors.textPrimary) - .lineLimit(1) - .truncationMode(.tail) - .minimumScaleFactor(0.9) - - if item.isPrimary { - Text("PRINCIPAL") - .font(AppTypography.overline) - .foregroundStyle(AppColors.textPrimary) - .lineLimit(1) - .fixedSize(horizontal: true, vertical: false) - .padding(.horizontal, 9) - .padding(.vertical, 5) - .background(AppColors.tertiary) - .clipShape(Capsule()) - } - } - - Text(item.detail) - .font(AppTypography.body) - .foregroundStyle(AppColors.textMuted) - .lineLimit(2) - } - - Spacer(minLength: 8) - - if onEdit != nil || onDelete != nil { - Rectangle() - .fill(AppColors.backgroundLight) - .frame(width: 1, height: 96) - } - - VStack(spacing: 24) { - if let onEdit { - Button(action: onEdit) { + VStack(spacing: 10) { + ZStack(alignment: .bottomTrailing) { + Circle() + .fill(Color.white.opacity(0.18)) + .frame(width: 96, height: 96) + .overlay( + Text(profileInitials) + .font(.system(size: 30, weight: .bold)) + .foregroundStyle(AppColors.textInverse) + ) + + Circle() + .fill(AppColors.tertiary) + .frame(width: 36, height: 36) + .overlay( Image(systemName: "pencil") - .font(.system(size: 22)) - .foregroundStyle(AppColors.textMuted) - } - } - - if let onDelete { - Button(action: onDelete) { - Image(systemName: "trash") - .font(.system(size: 22)) - .foregroundStyle(AppColors.textMuted) - } - } + .font(.system(size: 15, weight: .bold)) + .foregroundStyle(AppColors.textPrimary) + ) + .overlay( + Circle() + .stroke(Color.black.opacity(0.15), lineWidth: 1) + ) } - .frame(width: onEdit != nil || onDelete != nil ? 40 : 0) + + Text(profileName) + .font(.system(size: 22, weight: .heavy)) + .foregroundStyle(AppColors.textInverse) + .lineLimit(1) + .minimumScaleFactor(0.8) + + HStack(spacing: 8) { + Text("Ver Perfil") + .font(.system(size: 16, weight: .medium)) + Image(systemName: "arrow.right") + .font(.system(size: 14, weight: .semibold)) + } + .foregroundStyle(AppColors.tertiary) } - .padding(.horizontal, 16) - .padding(.vertical, 20) - .background(AppColors.surface) - .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous)) + .frame(maxWidth: .infinity) + .padding(.top, 54) + .padding(.bottom, 32) + .background(headerGradient) + .clipShape( + ProfileHeaderShape( + topLeadingRadius: 0, + bottomLeadingRadius: 42, + bottomTrailingRadius: 42, + topTrailingRadius: 0 + ) + ) + .ignoresSafeArea(edges: .top) } - var icon: some View { - Image(systemName: item.icon) - .font(.system(size: 28)) - .foregroundStyle(item.isPrimary ? AppColors.primary : AppColors.textPrimary) - .frame(width: 84, height: 84) - .background(item.isPrimary ? AppColors.brandSoft : AppColors.backgroundLight) - .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + private var headerGradient: LinearGradient { + LinearGradient( + colors: [Color(hex: "#123221"), Color(hex: "#0F2A1C")], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } + + private var profileName: String { + let trimmed = appState.profile.name.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? "Alex Silva" : trimmed + } + + private var profileInitials: String { + let parts = profileName.split(separator: " ").prefix(2) + let joined = parts.compactMap { $0.first }.map(String.init).joined() + return joined.isEmpty ? "AS" : joined.uppercased() + } + + private func logout() { + tokenStore.clear() + SessionStateStore.clearActiveUser() + appState = AppState() + root = .auth } } -struct AddressListItem: Identifiable { - let id = UUID() - let title: String - let detail: String +struct ProfileMenuRow: View { let icon: String - let isPrimary: Bool -} + let title: String + var badge: String? = nil -struct OrdersView: View { var body: some View { - VStack(spacing: 16) { - Text("Pedidos") - .font(AppTypography.heading1) - Text("Historico vazio") - .foregroundStyle(AppColors.secondary) + HStack(spacing: 12) { + Circle() + .fill(Color(hex: "#E9F0E2")) + .frame(width: 44, height: 44) + .overlay( + Image(systemName: icon) + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(Color(hex: "#173824")) + ) + + Text(title) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(Color(hex: "#0F1A34")) + + Spacer(minLength: 10) + + if let badge { + Text(badge) + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(Color(hex: "#1C2A1C")) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color(hex: "#EAF1D6")) + .clipShape(Capsule()) + } + + Image(systemName: "chevron.right") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(Color(hex: "#BFC7D4")) } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(AppColors.backgroundLight) + .padding(.horizontal, 18) + .padding(.vertical, 14) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + .shadow(color: Color.black.opacity(0.02), radius: 6, y: 2) + } +} + +struct ProfileHeaderShape: Shape { + var topLeadingRadius: CGFloat + var bottomLeadingRadius: CGFloat + var bottomTrailingRadius: CGFloat + var topTrailingRadius: CGFloat + + func path(in rect: CGRect) -> Path { + let tl = min(min(topLeadingRadius, rect.width / 2), rect.height / 2) + let tr = min(min(topTrailingRadius, rect.width / 2), rect.height / 2) + let bl = min(min(bottomLeadingRadius, rect.width / 2), rect.height / 2) + let br = min(min(bottomTrailingRadius, rect.width / 2), rect.height / 2) + + var path = Path() + path.move(to: CGPoint(x: rect.minX + tl, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.maxX - tr, y: rect.minY)) + path.addArc(center: CGPoint(x: rect.maxX - tr, y: rect.minY + tr), radius: tr, startAngle: .degrees(-90), endAngle: .degrees(0), clockwise: false) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - br)) + path.addArc(center: CGPoint(x: rect.maxX - br, y: rect.maxY - br), radius: br, startAngle: .degrees(0), endAngle: .degrees(90), clockwise: false) + path.addLine(to: CGPoint(x: rect.minX + bl, y: rect.maxY)) + path.addArc(center: CGPoint(x: rect.minX + bl, y: rect.maxY - bl), radius: bl, startAngle: .degrees(90), endAngle: .degrees(180), clockwise: false) + path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + tl)) + path.addArc(center: CGPoint(x: rect.minX + tl, y: rect.minY + tl), radius: tl, startAngle: .degrees(180), endAngle: .degrees(270), clockwise: false) + path.closeSubpath() + return path } } diff --git a/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailSupport.swift b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailSupport.swift new file mode 100644 index 0000000..e75f0d2 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailSupport.swift @@ -0,0 +1,164 @@ +import SwiftUI +#if canImport(UIKit) +import UIKit +#endif + +struct CategoryHeaderOffsetPreferenceKey: PreferenceKey { + static let defaultValue: [String: CGFloat] = [:] + + static func reduce(value: inout [String: CGFloat], nextValue: () -> [String: CGFloat]) { + value.merge(nextValue(), uniquingKeysWith: { _, new in new }) + } +} + +struct ScrollOffsetReader: View { + @Binding var offsetY: CGFloat + + var body: some View { +#if canImport(UIKit) + ScrollOffsetReaderRepresentable(offsetY: $offsetY) +#else + Color.clear +#endif + } +} + +#if canImport(UIKit) +struct ScrollOffsetReaderRepresentable: UIViewRepresentable { + @Binding var offsetY: CGFloat + + func makeUIView(context: Context) -> OffsetProbeView { + let view = OffsetProbeView() + view.onOffsetChanged = { value in + if offsetY != value { + offsetY = value + } + } + return view + } + + func updateUIView(_ uiView: OffsetProbeView, context: Context) { + uiView.onOffsetChanged = { value in + if offsetY != value { + offsetY = value + } + } + } +} + +final class OffsetProbeView: UIView { + var onOffsetChanged: ((CGFloat) -> Void)? + private var observation: NSKeyValueObservation? + private weak var observedScrollView: UIScrollView? + + override func didMoveToWindow() { + super.didMoveToWindow() + attachIfNeeded() + } + + override func didMoveToSuperview() { + super.didMoveToSuperview() + attachIfNeeded() + } + + private func attachIfNeeded() { + guard observation == nil else { return } + guard observedScrollView == nil else { return } + + if let scrollView = enclosingScrollView() ?? findScrollViewInWindow() { + observe(scrollView) + return + } + + retryAttach() + } + + private func observe(_ scrollView: UIScrollView) { + observedScrollView = scrollView + observation = scrollView.observe(\.contentOffset, options: [.new, .initial]) { [weak self, weak scrollView] _, change in + guard let self, let scrollView, let y = change.newValue?.y else { return } + let adjusted = max(0, y + scrollView.adjustedContentInset.top) + DispatchQueue.main.async { + self.onOffsetChanged?(adjusted) + } + } + } + + private func retryAttach() { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in + self?.attachIfNeeded() + } + } + + private func enclosingScrollView() -> UIScrollView? { + var current: UIView? = self + while let view = current { + if let scrollView = view as? UIScrollView { + return scrollView + } + current = view.superview + } + return nil + } + + private func findScrollViewInWindow() -> UIScrollView? { + guard let window else { return nil } + let targetPoint = convert(CGPoint(x: bounds.midX, y: bounds.midY), to: window) + return findScrollView(in: window, containing: targetPoint) + } + + private func findScrollView(in root: UIView, containing point: CGPoint) -> UIScrollView? { + for subview in root.subviews.reversed() { + if let match = findScrollView(in: subview, containing: point) { + return match + } + } + + if let scrollView = root as? UIScrollView { + let rectInWindow = scrollView.convert(scrollView.bounds, to: window) + if rectInWindow.contains(point) { + return scrollView + } + } + + return nil + } + + deinit { + observation?.invalidate() + } +} +#endif + +struct AsyncStoreImage: View { + let imageURL: String? + + var body: some View { + Group { + if let imageURL, + let url = URL(string: imageURL) { + AsyncImage(url: url) { phase in + switch phase { + case .success(let image): + image + .resizable() + .scaledToFill() + default: + fallback + } + } + } else { + fallback + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(AppColors.brandSoft) + .clipped() + } + + private var fallback: some View { + Image("placeholder-product") + .resizable() + .scaledToFill() + } +} diff --git a/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView+Logic.swift b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView+Logic.swift new file mode 100644 index 0000000..fe68ac4 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView+Logic.swift @@ -0,0 +1,227 @@ +import SwiftUI + +extension StoreDetailView { + func heroIconButton(icon: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Image(systemName: icon) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(Color.white) + .frame(width: 32, height: 32) + .background(Color.white.opacity(0.24)) + .clipShape(Circle()) + } + .buttonStyle(.plain) + } + + func statItem(title: String, value: String) -> some View { + VStack(spacing: 4) { + Text(title) + .font(AppTypography.caption) + .foregroundStyle(AppColors.textMuted) + Text(value) + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + } + .frame(maxWidth: .infinity) + } + + var ratingChip: some View { + HStack(spacing: 6) { + Image(systemName: "star.fill") + .font(.caption) + .foregroundStyle(Color(hex: "#F5B335")) + Text(String(format: "%.1f", storeRating ?? 0)) + .font(AppTypography.caption) + .foregroundStyle(AppColors.textPrimary) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(AppColors.brandSoft) + .clipShape(Capsule()) + } + + var storeSubtitle: String { + let category = (storeCategory ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if category.isEmpty { return "Restaurant" } + return category + } + + var deliveryValueLabel: String { + if let minOrder = info?.minOrder { + return formatCurrency(minOrder) + } + if let storeDistance, storeDistance.isEmpty == false { + return storeDistance + } + return "R$ --" + } + + @MainActor + func loadStoreData() async { + isLoading = true + errorMessage = nil + + do { + async let infoRequest = ApiService().storeInfo(storeId: storeId) + async let catalogRequest = ApiService().storeCatalog(storeId: storeId) + let (infoResponse, catalogResponse) = try await (infoRequest, catalogRequest) + + if infoResponse.error { + errorMessage = infoResponse.message ?? "Não foi possível carregar a loja." + isLoading = false + return + } + if catalogResponse.error { + errorMessage = catalogResponse.message ?? "Não foi possível carregar o catálogo." + isLoading = false + return + } + + info = infoResponse.result + categories = catalogResponse.result ?? [] + selectedCategoryId = categories.first?.id + isLoading = false + } catch { + isLoading = false + if let network = error as? NetworkError { + errorMessage = network.errorDescription ?? "Erro ao carregar loja." + return + } + if let service = error as? ApiServiceError { + errorMessage = service.errorDescription ?? "Erro ao carregar loja." + return + } + errorMessage = "Erro ao carregar loja." + } + } + + func resolvedURL(_ raw: String?) -> String? { + guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), + raw.isEmpty == false else { return nil } + + let lower = raw.lowercased() + if lower.hasPrefix("http://") || lower.hasPrefix("https://") { + return raw + } + let base = ApiConfig.baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + let path = raw.hasPrefix("/") ? raw : "/\(raw)" + return "\(base)\(path)" + } + + func formatCurrency(_ value: Double?) -> String { + guard let value else { return "R$ --" } + return String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",") + } + + var topSectionHeight: CGFloat { + cardTopInset + summaryCardHeight + } + + func sectionAnchorId(for categoryId: String) -> String { + "category-section-\(categoryId)" + } + + func syncCategoryWithScroll() { + guard isLoading == false else { return } + guard isProgrammaticCategoryScroll == false else { return } + guard categoryHeaderOffsets.isEmpty == false else { return } + + // Section whose header is nearest to the top content area wins. + let topThreshold: CGFloat = 180 + let sorted = categoryHeaderOffsets.sorted { $0.value < $1.value } + + if let current = sorted.last(where: { $0.value <= topThreshold })?.key { + selectedCategoryId = current + return + } + + if let firstVisible = sorted.first?.key { + selectedCategoryId = firstVisible + } + } + + func quantityInCart(for productId: String) -> Int { + appState.cart.items + .filter { $0.storeId == storeId && $0.productId == productId } + .reduce(0) { $0 + $1.quantity } + } + + func requestAddToCart(_ item: CartItemState) { + pendingCartAction = .add + if shouldAskForStoreSwitch(for: storeId) { + pendingCartItem = item + pendingProductSheet = nil + showSwitchStoreAlert = true + return + } + applyAddToCart(item) + } + + func requestSetCartItem(_ item: CartItemState) { + pendingCartAction = .set + if shouldAskForStoreSwitch(for: storeId) && item.quantity > 0 { + pendingCartItem = item + pendingProductSheet = nil + showSwitchStoreAlert = true + return + } + applySetCartItem(item) + } + + func requestOpenProductSheet(_ product: StoreCatalogProduct) { + pendingCartAction = .openProductSheet + if shouldAskForStoreSwitch(for: storeId) { + pendingCartItem = nil + pendingProductSheet = product + showSwitchStoreAlert = true + return + } + selectedProduct = product + } + + func applyAddToCart(_ item: CartItemState) { + if appState.cart.storeId == nil { + appState.cart.storeId = storeId + appState.cart.storeName = storeName + } + appState.cart.add(item: item) + SnackbarCenter.shared.show(title: "Produto adicionado ao carrinho.", style: .success, icon: "checkmark.seal.fill", duration: 2.0) + } + + func applySetCartItem(_ item: CartItemState) { + if item.quantity > 0, appState.cart.storeId == nil { + appState.cart.storeId = storeId + appState.cart.storeName = storeName + } + appState.cart.set(item: item) + if item.quantity > 0 { + SnackbarCenter.shared.show(title: "Carrinho atualizado.", style: .success, icon: "checkmark.seal.fill", duration: 1.8) + } else { + SnackbarCenter.shared.show(title: "Produto removido do carrinho.", style: .success, icon: "checkmark.seal.fill", duration: 1.8) + } + } + + func currentQuantity(forCartItemId itemId: String) -> Int { + appState.cart.items.first(where: { $0.id == itemId })?.quantity ?? 0 + } + + func shouldAskForStoreSwitch(for targetStoreId: String) -> Bool { + guard appState.cart.items.isEmpty == false else { return false } + guard let currentStoreId = currentCartStoreId(), + currentStoreId.isEmpty == false else { return false } + return currentStoreId != targetStoreId + } + + func currentCartStoreId() -> String? { + if let storeId = appState.cart.storeId, storeId.isEmpty == false { + return storeId + } + return appState.cart.items.first?.storeId + } +} + +enum CartAction { + case add + case set + case openProductSheet +} diff --git a/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView.swift b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView.swift index 1efd34e..eeb84b3 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView.swift @@ -12,6 +12,7 @@ struct StoreDetailView: View { let storeCategory: String? let storeRating: Double? let storeDistance: String? + @Binding var appState: AppState @Environment(\.dismiss) var dismiss @@ -21,6 +22,10 @@ struct StoreDetailView: View { @State var categories: [StoreCatalogCategory] = [] @State var selectedCategoryId: String? = nil @State var selectedProduct: StoreCatalogProduct? = nil + @State var showSwitchStoreAlert = false + @State var pendingCartItem: CartItemState? = nil + @State var pendingProductSheet: StoreCatalogProduct? = nil + @State var pendingCartAction: CartAction = .add @State var didLoad = false @State var categoryHeaderOffsets: [String: CGFloat] = [:] @State var isProgrammaticCategoryScroll = false @@ -71,9 +76,43 @@ struct StoreDetailView: View { } .sheet(item: $selectedProduct) { product in NavigationStack { - ProductDetailSheet(product: product, imageURL: resolvedURL(product.image)) + ProductDetailSheet( + product: product, + imageURL: resolvedURL(product.image), + storeId: storeId, + currentQuantityForItemId: { itemId in + currentQuantity(forCartItemId: itemId) + }, + onAdd: { item in + requestSetCartItem(item) + } + ) } } + .alert("Trocar de loja?", isPresented: $showSwitchStoreAlert) { + Button("Cancelar", role: .cancel) { + pendingCartItem = nil + pendingProductSheet = nil + } + Button("Limpar carrinho e adicionar", role: .destructive) { + appState.cart.clear() + switch pendingCartAction { + case .add: + guard let pendingCartItem else { return } + applyAddToCart(pendingCartItem) + case .set: + guard let pendingCartItem else { return } + applySetCartItem(pendingCartItem) + case .openProductSheet: + guard let pendingProductSheet else { return } + selectedProduct = pendingProductSheet + } + self.pendingCartItem = nil + self.pendingProductSheet = nil + } + } message: { + Text("Seu carrinho tem itens de outra loja. Deseja limpar o carrinho atual para adicionar este produto?") + } .onPreferenceChange(CategoryHeaderOffsetPreferenceKey.self) { offsets in categoryHeaderOffsets = offsets syncCategoryWithScroll() @@ -322,14 +361,44 @@ struct StoreDetailView: View { .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) Button { - selectedProduct = product + if product.addonGroups.isEmpty { + let basePrice = product.price ?? 0 + let item = CartItemState( + id: "\(storeId)::\(product.id)::base", + productId: product.id, + storeId: storeId, + name: product.name, + quantity: 1, + unitPrice: basePrice + ) + requestAddToCart(item) + } else { + requestOpenProductSheet(product) + } } label: { - Image(systemName: "plus") - .font(.system(size: 16, weight: .bold)) - .foregroundStyle(AppColors.textPrimary) - .frame(width: 30, height: 30) - .background(AppColors.tertiary) - .clipShape(Circle()) + ZStack(alignment: .leading) { + Image(systemName: "plus") + .font(.system(size: 16, weight: .bold)) + .foregroundStyle(AppColors.textPrimary) + .frame(width: 30, height: 30) + .background(AppColors.tertiary) + .clipShape(Circle()) + + let qty = quantityInCart(for: product.id) + if qty > 0 { + Text("\(qty)") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(Color.white) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background(Color.red) + .clipShape(Capsule()) + .offset(x: -6, y: -10) + .zIndex(1) + } + } + .offset(x: 3, y: -3) + .frame(width: 30, height: 30) } .buttonStyle(.plain) .offset(x: 7, y: 7) @@ -340,391 +409,4 @@ struct StoreDetailView: View { .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) } - private func heroIconButton(icon: String, action: @escaping () -> Void) -> some View { - Button(action: action) { - Image(systemName: icon) - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(Color.white) - .frame(width: 32, height: 32) - .background(Color.white.opacity(0.24)) - .clipShape(Circle()) - } - .buttonStyle(.plain) - } - - private func statItem(title: String, value: String) -> some View { - VStack(spacing: 4) { - Text(title) - .font(AppTypography.caption) - .foregroundStyle(AppColors.textMuted) - Text(value) - .font(AppTypography.heading3) - .foregroundStyle(AppColors.textPrimary) - } - .frame(maxWidth: .infinity) - } - - private var ratingChip: some View { - HStack(spacing: 6) { - Image(systemName: "star.fill") - .font(.caption) - .foregroundStyle(Color(hex: "#F5B335")) - Text(String(format: "%.1f", storeRating ?? 0)) - .font(AppTypography.caption) - .foregroundStyle(AppColors.textPrimary) - } - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background(AppColors.brandSoft) - .clipShape(Capsule()) - } - - private var storeSubtitle: String { - let category = (storeCategory ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - if category.isEmpty { return "Restaurant" } - return category - } - - private var deliveryValueLabel: String { - if let minOrder = info?.minOrder { - return formatCurrency(minOrder) - } - if let storeDistance, storeDistance.isEmpty == false { - return storeDistance - } - return "R$ --" - } - - @MainActor - private func loadStoreData() async { - isLoading = true - errorMessage = nil - - do { - async let infoRequest = ApiService().storeInfo(storeId: storeId) - async let catalogRequest = ApiService().storeCatalog(storeId: storeId) - let (infoResponse, catalogResponse) = try await (infoRequest, catalogRequest) - - if infoResponse.error { - errorMessage = infoResponse.message ?? "Não foi possível carregar a loja." - isLoading = false - return - } - if catalogResponse.error { - errorMessage = catalogResponse.message ?? "Não foi possível carregar o catálogo." - isLoading = false - return - } - - info = infoResponse.result - categories = catalogResponse.result ?? [] - selectedCategoryId = categories.first?.id - isLoading = false - } catch { - isLoading = false - if let network = error as? NetworkError { - errorMessage = network.errorDescription ?? "Erro ao carregar loja." - return - } - if let service = error as? ApiServiceError { - errorMessage = service.errorDescription ?? "Erro ao carregar loja." - return - } - errorMessage = "Erro ao carregar loja." - } - } - - private func resolvedURL(_ raw: String?) -> String? { - guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), - raw.isEmpty == false else { return nil } - - let lower = raw.lowercased() - if lower.hasPrefix("http://") || lower.hasPrefix("https://") { - return raw - } - let base = ApiConfig.baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) - let path = raw.hasPrefix("/") ? raw : "/\(raw)" - return "\(base)\(path)" - } - - private func formatCurrency(_ value: Double?) -> String { - guard let value else { return "R$ --" } - return String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",") - } - - private var topSectionHeight: CGFloat { - cardTopInset + summaryCardHeight - } - - private func sectionAnchorId(for categoryId: String) -> String { - "category-section-\(categoryId)" - } - - private func syncCategoryWithScroll() { - guard isLoading == false else { return } - guard isProgrammaticCategoryScroll == false else { return } - guard categoryHeaderOffsets.isEmpty == false else { return } - - // Section whose header is nearest to the top content area wins. - let topThreshold: CGFloat = 180 - let sorted = categoryHeaderOffsets.sorted { $0.value < $1.value } - - if let current = sorted.last(where: { $0.value <= topThreshold })?.key { - selectedCategoryId = current - return - } - - if let firstVisible = sorted.first?.key { - selectedCategoryId = firstVisible - } - } -} - -struct CategoryHeaderOffsetPreferenceKey: PreferenceKey { - static let defaultValue: [String: CGFloat] = [:] - - static func reduce(value: inout [String: CGFloat], nextValue: () -> [String: CGFloat]) { - value.merge(nextValue(), uniquingKeysWith: { _, new in new }) - } -} - -struct ScrollOffsetReader: View { - @Binding var offsetY: CGFloat - - var body: some View { -#if canImport(UIKit) - ScrollOffsetReaderRepresentable(offsetY: $offsetY) -#else - Color.clear -#endif - } -} - -#if canImport(UIKit) -struct ScrollOffsetReaderRepresentable: UIViewRepresentable { - @Binding var offsetY: CGFloat - - func makeUIView(context: Context) -> OffsetProbeView { - let view = OffsetProbeView() - view.onOffsetChanged = { value in - if offsetY != value { - offsetY = value - } - } - return view - } - - func updateUIView(_ uiView: OffsetProbeView, context: Context) { - uiView.onOffsetChanged = { value in - if offsetY != value { - offsetY = value - } - } - } -} - -final class OffsetProbeView: UIView { - var onOffsetChanged: ((CGFloat) -> Void)? - private var observation: NSKeyValueObservation? - private weak var observedScrollView: UIScrollView? - - override func didMoveToWindow() { - super.didMoveToWindow() - attachIfNeeded() - } - - override func didMoveToSuperview() { - super.didMoveToSuperview() - attachIfNeeded() - } - - private func attachIfNeeded() { - guard observation == nil else { return } - guard observedScrollView == nil else { return } - - if let scrollView = enclosingScrollView() ?? findScrollViewInWindow() { - observe(scrollView) - return - } - - retryAttach() - } - - private func observe(_ scrollView: UIScrollView) { - observedScrollView = scrollView - observation = scrollView.observe(\.contentOffset, options: [.new, .initial]) { [weak self, weak scrollView] _, change in - guard let self, let scrollView, let y = change.newValue?.y else { return } - let adjusted = max(0, y + scrollView.adjustedContentInset.top) - DispatchQueue.main.async { - self.onOffsetChanged?(adjusted) - } - } - } - - private func retryAttach() { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in - self?.attachIfNeeded() - } - } - - private func enclosingScrollView() -> UIScrollView? { - var current: UIView? = self - while let view = current { - if let scrollView = view as? UIScrollView { - return scrollView - } - current = view.superview - } - return nil - } - - private func findScrollViewInWindow() -> UIScrollView? { - guard let window else { return nil } - let targetPoint = convert(CGPoint(x: bounds.midX, y: bounds.midY), to: window) - return findScrollView(in: window, containing: targetPoint) - } - - private func findScrollView(in root: UIView, containing point: CGPoint) -> UIScrollView? { - for subview in root.subviews.reversed() { - if let match = findScrollView(in: subview, containing: point) { - return match - } - } - - if let scrollView = root as? UIScrollView { - let rectInWindow = scrollView.convert(scrollView.bounds, to: window) - if rectInWindow.contains(point) { - return scrollView - } - } - - return nil - } - - deinit { - observation?.invalidate() - } -} -#endif - -struct AsyncStoreImage: View { - let imageURL: String? - - var body: some View { - Group { - if let imageURL, - let url = URL(string: imageURL) { - AsyncImage(url: url) { phase in - switch phase { - case .success(let image): - image - .resizable() - .scaledToFill() - default: - fallback - } - } - } else { - fallback - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(AppColors.brandSoft) - .clipped() - } - - private var fallback: some View { - Image("placeholder-product") - .resizable() - .scaledToFill() - } -} - -struct ProductDetailSheet: View { - let product: StoreCatalogProduct - let imageURL: String? - @Environment(\.dismiss) var dismiss - - var body: some View { - ScrollView(showsIndicators: false) { - VStack(alignment: .leading, spacing: 16) { - AsyncStoreImage(imageURL: imageURL) - .frame(height: 220) - .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous)) - - Text(product.name) - .font(AppTypography.heading2) - .foregroundStyle(AppColors.textPrimary) - - if let description = product.description, description.isEmpty == false { - Text(description) - .font(AppTypography.body) - .foregroundStyle(AppColors.textMuted) - } - - Text(String(format: "R$ %.2f", product.price ?? 0).replacingOccurrences(of: ".", with: ",")) - .font(AppTypography.heading2) - .foregroundStyle(AppColors.primary) - - if product.addonGroups.isEmpty == false { - Text("Adicionais") - .font(AppTypography.heading3) - .foregroundStyle(AppColors.textPrimary) - - ForEach(product.addonGroups) { group in - VStack(alignment: .leading, spacing: 8) { - Text(group.name) - .font(AppTypography.heading3) - .foregroundStyle(AppColors.textPrimary) - - ForEach(group.items) { item in - HStack { - Text(item.name) - .font(AppTypography.body) - .foregroundStyle(AppColors.textMuted) - Spacer() - Text(String(format: "+ R$ %.2f", item.price ?? 0).replacingOccurrences(of: ".", with: ",")) - .font(AppTypography.body) - .foregroundStyle(AppColors.textPrimary) - } - } - } - .padding(12) - .background(AppColors.surface) - .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) - } - } - } - .padding(20) - .padding(.bottom, 80) - } - .safeAreaInset(edge: .bottom) { - HStack(spacing: 12) { - Button(action: {}) { - Image(systemName: "bag") - .font(.system(size: 16, weight: .bold)) - .foregroundStyle(AppColors.textPrimary) - .frame(width: 48, height: 48) - .background(AppColors.surface) - .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) - } - .buttonStyle(.plain) - - PrimaryButton(title: "Comprar agora", action: {}) - } - .padding(.horizontal, 20) - .padding(.top, 8) - .padding(.bottom, 12) - .background(.ultraThinMaterial) - } - .background(AppColors.backgroundLight.ignoresSafeArea()) - .navigationTitle("Detalhes") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button("Fechar") { dismiss() } - .foregroundStyle(AppColors.primary) - } - } - } } diff --git a/pedifoods_app_plan.md b/pedifoods_app_plan.md index 538e3b2..a2abe68 100644 --- a/pedifoods_app_plan.md +++ b/pedifoods_app_plan.md @@ -12,6 +12,8 @@ - `[🟨]` Home refeita com layout de referencia (header verde, categorias, cards, specials) e efeito de colapso em iOS. - `[🟩]` Estado global (`AppState`) em uso para sessao, perfil, endereco, carrinho e favoritos. - `[🟩]` API client/service com envelopes, retry/backoff, tratamento de erros/rate limit, politica de sessao expirada e persistencia segura de JWT no iOS. +- `[🟨]` Perfil com redesign visual alinhado a referencia, header no topo, lista de atalhos e confirmacao de logout. +- `[🟨]` Enderecos com lista real, criacao, edicao (reuso da tela de novo endereco) e exclusao por swipe compativel com Android. ## Premissas de design e navegacao - A tabbar e o centro de navegacao. Toda navegacao que nao for modal/over-context acontece dentro da tabbar. @@ -89,12 +91,14 @@ 3. `[🟥]` Tracking de status. ## Etapa 8 — Perfil e enderecos -- `[🟥]` Etapa geral +- `[🟨]` Etapa geral 1. `[🟨]` Perfil do usuario. -- `[🟩]` Feito: tela com dados do estado e logout. -- `[🟥]` Pendente: edicao completa e dados vindos da API. -2. `[🟥]` Lista de enderecos. -3. `[🟥]` Criar/editar endereco. +- `[🟩]` Feito: tela refeita com layout de referencia, header no topo, itens de menu, CTA de logout e confirmacao por alert. +- `[🟥]` Pendente: edicao completa de perfil e dados 100% vindos/sincronizados da API. +2. `[🟩]` Lista de enderecos. +- `[🟩]` Feito: carregamento de enderecos, selecao, estado principal e persistencia local da selecao. +3. `[🟩]` Criar/editar/excluir endereco. +- `[🟩]` Feito: criacao, edicao reutilizando a mesma tela e exclusao por swipe com compatibilidade Android. ## Etapa 9 — Mapa e GPS - `[🟥]` Etapa geral @@ -108,7 +112,7 @@ 1. `[🟨]` Dark mode (presente em partes de auth). 2. `[🟨]` Estados vazios e erros. 3. `[🟥]` Performance e cache. -4. `[🟨]` Build iOS e Android (estrutura pronta, falta validacao final de fluxo completo). +4. `[🟥]` Build iOS e Android (ha bloqueio atual de dependencia `LCEssentials` em `StoreDetailView.swift`). 5. `[🟥]` Export APK/AAB. ## Etapa 11 — Ajustes de UI (referencias) @@ -126,3 +130,4 @@ 2. `[🟨]` Fechar fluxo auth pos-login (perfil + hidratacao de estado). 3. `[🟥]` Implementar carrinho real (itens, subtotal, edicao) para destravar checkout. 4. `[🟥]` Implementar mapa e tracking de pedidos. +5. `[🟥]` Corrigir bloqueio de build por dependencia (`LCEssentials`) para fechar validacao iOS/Android ponta a ponta.