segregate files
This commit is contained in:
30
pedi-foods/PROFILE_PLAN.md
Normal file
30
pedi-foods/PROFILE_PLAN.md
Normal file
@@ -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`)
|
||||||
|
|
||||||
@@ -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.",
|
"comment" : "A text element showing the number of reviews for a store. The content inside the parentheses is the number of reviews.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"%lld" : {
|
||||||
|
"comment" : "A label displaying the quantity of a particular item in the user's cart.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"%lld Items" : {
|
"%lld Items" : {
|
||||||
"comment" : "Header title for a list that contains the number of items",
|
"comment" : "Header title for a list that contains the number of items",
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
@@ -83,6 +87,10 @@
|
|||||||
"comment" : "A button label that translates to \"Add new address\" in English.",
|
"comment" : "A button label that translates to \"Add new address\" in English.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Adicione produtos para continuar." : {
|
||||||
|
"comment" : "A message displayed when the cart is empty, encouraging the user to add products.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Ajuda" : {
|
"Ajuda" : {
|
||||||
"comment" : "A link to the help section of the app.",
|
"comment" : "A link to the help section of the app.",
|
||||||
"isCommentAutoGenerated" : true
|
"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." : {
|
"Cardápio indisponível no momento." : {
|
||||||
"comment" : "A message displayed when a store's menu is unavailable.",
|
"comment" : "A message displayed when a store's menu is unavailable.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@@ -203,14 +215,6 @@
|
|||||||
"comment" : "A link that directs users to create an account.",
|
"comment" : "A link that directs users to create an account.",
|
||||||
"isCommentAutoGenerated" : true
|
"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" : {
|
"Dark" : {
|
||||||
"comment" : "Menu item indicating that the appearance should be in dark mode",
|
"comment" : "Menu item indicating that the appearance should be in dark mode",
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
@@ -283,16 +287,8 @@
|
|||||||
"comment" : "The title of the navigation bar at the top of the product detail sheet.",
|
"comment" : "The title of the navigation bar at the top of the product detail sheet.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"DL" : {
|
|
||||||
"comment" : "An abbreviation for \"Delivery Lady\" used in the user's profile picture circle.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
|
||||||
"Editar endereço" : {
|
"Editar endereço" : {
|
||||||
|
|
||||||
},
|
|
||||||
"Enderecos" : {
|
|
||||||
"comment" : "A link to the user's address list.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
},
|
||||||
"Entrar" : {
|
"Entrar" : {
|
||||||
"comment" : "A link that directs the user to the login screen.",
|
"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" : {
|
"Insira o código de 8 dígitos enviado" : {
|
||||||
"comment" : "A description below the text field where the user inputs their OTP code.",
|
"comment" : "A description below the text field where the user inputs their OTP code.",
|
||||||
"isCommentAutoGenerated" : true
|
"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" : {
|
"Meus Endereços" : {
|
||||||
"comment" : "A label displayed at the top of the view.",
|
"comment" : "A label displayed at the top of the view.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Minhas Avaliações" : {
|
||||||
|
"comment" : "A button that links to their reviews.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Name" : {
|
"Name" : {
|
||||||
"comment" : "Placeholder title for the Name field in a form",
|
"comment" : "Placeholder title for the Name field in a form",
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
@@ -619,6 +627,16 @@
|
|||||||
},
|
},
|
||||||
"RESTAURANT" : {
|
"RESTAURANT" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Sair" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Sair da Conta" : {
|
||||||
|
"comment" : "A button label that translates to \"Log out\" in English.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Sair da conta?" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Save" : {
|
"Save" : {
|
||||||
"comment" : "Button title indicating that the current contents should be saved",
|
"comment" : "Button title indicating that the current contents should be saved",
|
||||||
@@ -680,8 +698,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Seu carrinho esta vazio" : {
|
"Seu carrinho está vazio" : {
|
||||||
"comment" : "A description displayed when a user's cart is empty.",
|
"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
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"System" : {
|
"System" : {
|
||||||
@@ -713,6 +735,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Tem certeza que deseja sair da sua conta?" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Tentar novamente" : {
|
"Tentar novamente" : {
|
||||||
"comment" : "A button label that attempts to reload data when an error occurs.",
|
"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" : {
|
"Verificação" : {
|
||||||
"comment" : "A title for the OTP verification screen.",
|
"comment" : "A title for the OTP verification screen.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Versão 4.20.1" : {
|
||||||
|
"comment" : "The version number of the app.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Version %@ (%@)" : {
|
"Version %@ (%@)" : {
|
||||||
"comment" : "Settings label showing the current version of the app",
|
"comment" : "Settings label showing the current version of the app",
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
|
|||||||
412
pedi-foods/Sources/PediFoods/Services/ApiModels.swift
Normal file
412
pedi-foods/Sources/PediFoods/Services/ApiModels.swift
Normal file
@@ -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<K: CodingKey>(from container: KeyedDecodingContainer<K>, 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<K: CodingKey>(from container: KeyedDecodingContainer<K>, 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -236,419 +236,7 @@ final class ApiService {
|
|||||||
return try await sendEnvelope(req)
|
return try await sendEnvelope(req)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
extension ApiService {
|
||||||
// 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<K: CodingKey>(from container: KeyedDecodingContainer<K>, 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<K: CodingKey>(from container: KeyedDecodingContainer<K>, 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 {
|
|
||||||
static func decodeFlexibleDouble<K: CodingKey>(from container: KeyedDecodingContainer<K>, keys: [K]) -> Double? {
|
static func decodeFlexibleDouble<K: CodingKey>(from container: KeyedDecodingContainer<K>, keys: [K]) -> Double? {
|
||||||
for key in keys {
|
for key in keys {
|
||||||
if let value = try? container.decode(Double.self, forKey: key) {
|
if let value = try? container.decode(Double.self, forKey: key) {
|
||||||
|
|||||||
@@ -41,13 +41,81 @@ struct FavoritesState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct CartState {
|
struct CartState {
|
||||||
|
var storeId: String? = nil
|
||||||
|
var storeName: String? = nil
|
||||||
var items: [CartItemState] = []
|
var items: [CartItemState] = []
|
||||||
var total: Double = 0
|
var total: Double = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
struct CartItemState: Identifiable {
|
struct CartItemState: Identifiable {
|
||||||
let id: String
|
let id: String
|
||||||
|
var productId: String
|
||||||
|
var storeId: String
|
||||||
var name: String
|
var name: String
|
||||||
|
var details: String? = nil
|
||||||
var quantity: Int
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
251
pedi-foods/Sources/PediFoods/Views/Main/AddAddressFormView.swift
Normal file
251
pedi-foods/Sources/PediFoods/Views/Main/AddAddressFormView.swift
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
216
pedi-foods/Sources/PediFoods/Views/Main/AddressComponents.swift
Normal file
216
pedi-foods/Sources/PediFoods/Views/Main/AddressComponents.swift
Normal file
@@ -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<Content: View>: 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
339
pedi-foods/Sources/PediFoods/Views/Main/AddressesView.swift
Normal file
339
pedi-foods/Sources/PediFoods/Views/Main/AddressesView.swift
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,127 @@
|
|||||||
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct CartView: View {
|
struct CartView: View {
|
||||||
|
@Binding var appState: AppState
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 14) {
|
||||||
Text("Carrinho")
|
Text("Carrinho")
|
||||||
.font(AppTypography.heading1)
|
.font(AppTypography.heading1)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.top, 20)
|
||||||
|
|
||||||
Text("Seu carrinho esta vazio")
|
if appState.cart.items.isEmpty {
|
||||||
.foregroundStyle(AppColors.secondary)
|
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)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.background(AppColors.backgroundLight)
|
.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: ",")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
126
pedi-foods/Sources/PediFoods/Views/Main/HomeView+Data.swift
Normal file
126
pedi-foods/Sources/PediFoods/Views/Main/HomeView+Data.swift
Normal file
@@ -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<String>()
|
||||||
|
|
||||||
|
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<String>()
|
||||||
|
|
||||||
|
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."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -91,7 +91,8 @@ struct HomeView: View {
|
|||||||
storeLogoURL: store.logoURL,
|
storeLogoURL: store.logoURL,
|
||||||
storeCategory: store.category,
|
storeCategory: store.category,
|
||||||
storeRating: store.rating,
|
storeRating: store.rating,
|
||||||
storeDistance: store.distance
|
storeDistance: store.distance,
|
||||||
|
appState: $appState
|
||||||
)
|
)
|
||||||
} label: {
|
} label: {
|
||||||
FeaturedStoreCard(store: store)
|
FeaturedStoreCard(store: store)
|
||||||
@@ -159,7 +160,8 @@ struct HomeView: View {
|
|||||||
storeLogoURL: store.logoURL,
|
storeLogoURL: store.logoURL,
|
||||||
storeCategory: store.category,
|
storeCategory: store.category,
|
||||||
storeRating: store.rating,
|
storeRating: store.rating,
|
||||||
storeDistance: store.distance
|
storeDistance: store.distance,
|
||||||
|
appState: $appState
|
||||||
)
|
)
|
||||||
} label: {
|
} label: {
|
||||||
FeaturedStoreCard(store: store)
|
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<String>()
|
|
||||||
|
|
||||||
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<String>()
|
|
||||||
|
|
||||||
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
|
@ViewBuilder
|
||||||
private var scrollOffsetObserver: some View {
|
private var scrollOffsetObserver: some View {
|
||||||
@@ -547,136 +428,3 @@ struct HomeView: View {
|
|||||||
#endif
|
#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
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ struct MainTabView: View {
|
|||||||
}
|
}
|
||||||
case .cart:
|
case .cart:
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
CartView()
|
CartView(appState: $appState)
|
||||||
}
|
}
|
||||||
case .profile:
|
case .profile:
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
|
|||||||
196
pedi-foods/Sources/PediFoods/Views/Main/ProductDetailSheet.swift
Normal file
196
pedi-foods/Sources/PediFoods/Views/Main/ProductDetailSheet.swift
Normal file
@@ -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<String> = []
|
||||||
|
@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: ",")
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
164
pedi-foods/Sources/PediFoods/Views/Main/StoreDetailSupport.swift
Normal file
164
pedi-foods/Sources/PediFoods/Views/Main/StoreDetailSupport.swift
Normal file
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ struct StoreDetailView: View {
|
|||||||
let storeCategory: String?
|
let storeCategory: String?
|
||||||
let storeRating: Double?
|
let storeRating: Double?
|
||||||
let storeDistance: String?
|
let storeDistance: String?
|
||||||
|
@Binding var appState: AppState
|
||||||
|
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
|
|
||||||
@@ -21,6 +22,10 @@ struct StoreDetailView: View {
|
|||||||
@State var categories: [StoreCatalogCategory] = []
|
@State var categories: [StoreCatalogCategory] = []
|
||||||
@State var selectedCategoryId: String? = nil
|
@State var selectedCategoryId: String? = nil
|
||||||
@State var selectedProduct: StoreCatalogProduct? = 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 didLoad = false
|
||||||
@State var categoryHeaderOffsets: [String: CGFloat] = [:]
|
@State var categoryHeaderOffsets: [String: CGFloat] = [:]
|
||||||
@State var isProgrammaticCategoryScroll = false
|
@State var isProgrammaticCategoryScroll = false
|
||||||
@@ -71,8 +76,42 @@ struct StoreDetailView: View {
|
|||||||
}
|
}
|
||||||
.sheet(item: $selectedProduct) { product in
|
.sheet(item: $selectedProduct) { product in
|
||||||
NavigationStack {
|
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
|
.onPreferenceChange(CategoryHeaderOffsetPreferenceKey.self) { offsets in
|
||||||
categoryHeaderOffsets = offsets
|
categoryHeaderOffsets = offsets
|
||||||
@@ -322,14 +361,44 @@ struct StoreDetailView: View {
|
|||||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||||
|
|
||||||
Button {
|
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: {
|
} label: {
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
Image(systemName: "plus")
|
Image(systemName: "plus")
|
||||||
.font(.system(size: 16, weight: .bold))
|
.font(.system(size: 16, weight: .bold))
|
||||||
.foregroundStyle(AppColors.textPrimary)
|
.foregroundStyle(AppColors.textPrimary)
|
||||||
.frame(width: 30, height: 30)
|
.frame(width: 30, height: 30)
|
||||||
.background(AppColors.tertiary)
|
.background(AppColors.tertiary)
|
||||||
.clipShape(Circle())
|
.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)
|
.buttonStyle(.plain)
|
||||||
.offset(x: 7, y: 7)
|
.offset(x: 7, y: 7)
|
||||||
@@ -340,391 +409,4 @@ struct StoreDetailView: View {
|
|||||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
.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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
- `[🟨]` Home refeita com layout de referencia (header verde, categorias, cards, specials) e efeito de colapso em iOS.
|
- `[🟨]` 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.
|
- `[🟩]` 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.
|
- `[🟩]` 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
|
## Premissas de design e navegacao
|
||||||
- A tabbar e o centro de navegacao. Toda navegacao que nao for modal/over-context acontece dentro da tabbar.
|
- 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.
|
3. `[🟥]` Tracking de status.
|
||||||
|
|
||||||
## Etapa 8 — Perfil e enderecos
|
## Etapa 8 — Perfil e enderecos
|
||||||
- `[🟥]` Etapa geral
|
- `[🟨]` Etapa geral
|
||||||
1. `[🟨]` Perfil do usuario.
|
1. `[🟨]` Perfil do usuario.
|
||||||
- `[🟩]` Feito: tela com dados do estado e logout.
|
- `[🟩]` Feito: tela refeita com layout de referencia, header no topo, itens de menu, CTA de logout e confirmacao por alert.
|
||||||
- `[🟥]` Pendente: edicao completa e dados vindos da API.
|
- `[🟥]` Pendente: edicao completa de perfil e dados 100% vindos/sincronizados da API.
|
||||||
2. `[🟥]` Lista de enderecos.
|
2. `[🟩]` Lista de enderecos.
|
||||||
3. `[🟥]` Criar/editar endereco.
|
- `[🟩]` 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 9 — Mapa e GPS
|
||||||
- `[🟥]` Etapa geral
|
- `[🟥]` Etapa geral
|
||||||
@@ -108,7 +112,7 @@
|
|||||||
1. `[🟨]` Dark mode (presente em partes de auth).
|
1. `[🟨]` Dark mode (presente em partes de auth).
|
||||||
2. `[🟨]` Estados vazios e erros.
|
2. `[🟨]` Estados vazios e erros.
|
||||||
3. `[🟥]` Performance e cache.
|
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.
|
5. `[🟥]` Export APK/AAB.
|
||||||
|
|
||||||
## Etapa 11 — Ajustes de UI (referencias)
|
## Etapa 11 — Ajustes de UI (referencias)
|
||||||
@@ -126,3 +130,4 @@
|
|||||||
2. `[🟨]` Fechar fluxo auth pos-login (perfil + hidratacao de estado).
|
2. `[🟨]` Fechar fluxo auth pos-login (perfil + hidratacao de estado).
|
||||||
3. `[🟥]` Implementar carrinho real (itens, subtotal, edicao) para destravar checkout.
|
3. `[🟥]` Implementar carrinho real (itens, subtotal, edicao) para destravar checkout.
|
||||||
4. `[🟥]` Implementar mapa e tracking de pedidos.
|
4. `[🟥]` Implementar mapa e tracking de pedidos.
|
||||||
|
5. `[🟥]` Corrigir bloqueio de build por dependencia (`LCEssentials`) para fechar validacao iOS/Android ponta a ponta.
|
||||||
|
|||||||
Reference in New Issue
Block a user