This commit is contained in:
Daniel Arantes Loverde
2026-04-16 14:50:07 -03:00
parent 7f7d414e6c
commit 0ebb854213
20 changed files with 871 additions and 216 deletions

View File

@@ -167,6 +167,106 @@ Used when interacting with a specific store (Catalog, Checkout).
- Se a distância entre `lat_long` enviado e o ponto do CEP for maior que `1.5 km`, a API substitui `lat_long` pelo ponto do CEP cacheado. - Se a distância entre `lat_long` enviado e o ponto do CEP for maior que `1.5 km`, a API substitui `lat_long` pelo ponto do CEP cacheado.
- Objetivo: evitar discrepâncias grandes entre endereço e coordenada salva no perfil. - Objetivo: evitar discrepâncias grandes entre endereço e coordenada salva no perfil.
### 5. List Favorite Stores
**GET** `/api/customer/favorites`
**Headers:**
- `Authorization: Bearer <JWT_Token>`
- `Atomenta-Token: 550e8400-e29b-41d4-a716-44665544000a`
**Notes:**
- Retorna as lojas favoritas do customer autenticado.
- Apenas lojas públicas/visíveis são retornadas.
- A ordem segue a ordem salva em `favorites`.
**Response:**
```json
{
"error": false,
"code": "CUSTOMER_FAVORITES_RETRIEVED",
"result": [
{
"id": "store_1775510226293_yn5ys",
"name": "CPS Drinks",
"logo": null,
"cover": null,
"category": "Doces & Bolos",
"rating": 4.8,
"totalReviews": 12,
"isOpen": true,
"statusLabel": "Aberto",
"nextOpenLabel": null
}
]
}
```
### 6. Add Store to Favorites
**POST** `/api/customer/favorites/:storeId`
**Headers:**
- `Authorization: Bearer <JWT_Token>`
- `Atomenta-Token: 550e8400-e29b-41d4-a716-44665544000a`
**Notes:**
- Salva a loja nos favoritos do customer autenticado.
- Se a loja já estiver favoritada, a operação continua idempotente.
- A loja precisa existir e estar publicamente visível.
**Response:**
```json
{
"error": false,
"code": "CUSTOMER_FAVORITE_SAVED",
"result": {
"favorites": ["store_1775510226293_yn5ys"],
"store": {
"id": "store_1775510226293_yn5ys",
"name": "CPS Drinks",
"logo": null,
"cover": null,
"category": "Doces & Bolos",
"rating": 4.8,
"totalReviews": 12,
"isOpen": true,
"statusLabel": "Aberto",
"nextOpenLabel": null
}
}
}
```
### 7. Remove Store from Favorites
**DELETE** `/api/customer/favorites/:storeId`
**Headers:**
- `Authorization: Bearer <JWT_Token>`
- `Atomenta-Token: 550e8400-e29b-41d4-a716-44665544000a`
**Notes:**
- Remove a loja dos favoritos do customer autenticado.
- A operação é idempotente: se a loja não estiver favoritada, o array volta sem ela.
**Response:**
```json
{
"error": false,
"code": "CUSTOMER_FAVORITE_REMOVED",
"result": {
"favorites": []
}
}
```
--- ---
## 🏠 App Home Screen ## 🏠 App Home Screen
@@ -538,19 +638,29 @@ Use este canal para acompanhar mudança de status do pedido em tempo real (sem d
``` ```
**Eventos recebidos pelo app:** **Eventos recebidos pelo app:**
- `order_update`: enviado quando o pedido muda (pagamento aprovado, aceito pela loja, saiu para entrega, concluído, cancelado). - `order_update`: alteração geral de status do pedido.
- `order_cancelled`: cancelamento confirmado, com motivo e metadados de estorno.
- `delivery_order_cancelled`: evento dedicado para telas de OTP/entrega interromperem o fluxo imediatamente.
**Payload típico de `order_update`:** **Payload típico de `order_cancelled`:**
```json ```json
{ {
"id": "ord_987...", "event": "order_cancelled",
"shortId": "1234", "emittedAt": "2026-03-05T18:00:00.000Z",
"storeId": "store_123...", "orderId": "ffdc47b4-6a55-4713-aea9-45169e4cf0d7",
"userId": "cust_uuid...", "shortId": "3778",
"status": "CONFIRMED", "storeId": "store_1772117366848_wmqw4",
"paymentStatus": "CONFIRMED", "userId": "cust_uuid_001",
"updatedAt": "2026-02-15T20:10:00.000Z" "status": "CANCELED",
"cancelledBy": "store",
"cancelledAt": "2026-03-05T18:00:00.000Z",
"cancelReasonCode": "STORE_UNAVAILABLE",
"cancelReasonDetail": "Falta de insumo crítico para finalizar o pedido",
"refundStatus": "failed",
"refundIdempotencyKey": "refund:ffdc47b4-...",
"refundProviderRef": null,
"refundError": "Falha ao solicitar estorno automático"
} }
``` ```
@@ -566,9 +676,45 @@ Se o socket cair, o app deve continuar consultando o status até estado final.
- depois: a cada `10s` - depois: a cada `10s`
- parar em status final (`COMPLETED`, `CANCELED`, `REFUNDED`) ou ao sair da tela. - parar em status final (`COMPLETED`, `CANCELED`, `REFUNDED`) ou ao sair da tela.
### 8. Catálogo Oficial de Tags de Review ### 8. Cancelamento de Pedido (Loja/Customer)
### 8.1 Matriz Completa de Endpoints (Reviews + Tags) **Cancelamento pela loja (Store API):**
- `POST /api/store/:storeId/orders/:orderId/cancel`
**Cancelamento pelo customer:**
- `POST /api/customer/orders/:orderId/cancel`
**Request body (ambos):**
```json
{
"reasonCode": "CUSTOMER_REQUEST",
"reasonDetail": "Cliente solicitou cancelamento antes da entrega"
}
```
**Motivos aceitos (`reasonCode`):**
- `STORE_UNAVAILABLE`
- `CUSTOMER_REQUEST`
- `DELIVERY_ISSUE`
**Regras atuais de cancelamento:**
- Permitido em: `PAYMENT_PENDING`, `PENDING`, `ACCEPTED`, `PREPARING`, `READY`, `DELIVERING`.
- Bloqueado em: `COMPLETED` e `CANCELED`.
- Motoboy não cancela pedido.
- Ao cancelar, o backend invalida `otp`, `confirmOtp` e `customerOtp`.
**Concorrência e idempotência (ponta a ponta):**
- Cancelamento é serializado por `orderId` (lock por pedido).
- Estorno automático usa lock + chave idempotente de estorno para evitar duplicidade.
- Em segunda tentativa após cancelamento efetivado, a API retorna `409 ORDER_ALREADY_CANCELED`.
**Campos relevantes no retorno:**
- `cancelledBy`, `cancelledAt`, `cancelReasonCode`, `cancelReasonDetail`
- `refundStatus`, `refundIdempotencyKey`, `refundProviderRef`
### 9. Catálogo Oficial de Tags de Review
### 9.1 Matriz Completa de Endpoints (Reviews + Tags)
**Público/App (Customer):** **Público/App (Customer):**
- `GET /api/public/reviews/tags` → catálogo oficial de tags e regras de validação. - `GET /api/public/reviews/tags` → catálogo oficial de tags e regras de validação.
@@ -630,7 +776,7 @@ O app deve buscar este endpoint para renderizar as tags válidas e enviar soment
- Não hardcodear tags no app; usar o catálogo do backend. - Não hardcodear tags no app; usar o catálogo do backend.
- Enviar no POST de review apenas os `id` retornados em cada bloco. - Enviar no POST de review apenas os `id` retornados em cada bloco.
### 9. Enviar Avaliação do Pedido ### 10. Enviar Avaliação do Pedido
Permite o customer enviar review da loja a partir de um pedido finalizado. Permite o customer enviar review da loja a partir de um pedido finalizado.
**Endpoint:** **Endpoint:**
@@ -749,7 +895,7 @@ Exemplo inválido (entrega positiva com tags negativas):
Retorno esperado: `400 INVALID_DELIVERY_SENTIMENT_TAGS`. Retorno esperado: `400 INVALID_DELIVERY_SENTIMENT_TAGS`.
### 10. Analytics de Reviews (Store/API) ### 11. Analytics de Reviews (Store/API)
Para dashboard da loja e análise de produto, usar: Para dashboard da loja e análise de produto, usar:
- `GET /api/store/:storeId/reviews` - `GET /api/store/:storeId/reviews`

View File

@@ -41,10 +41,6 @@ android {
// versionName = MARKETING_VERSION // versionName = MARKETING_VERSION
} }
buildFeatures {
buildConfig = true
}
lint { lint {
disable.add("Instantiatable") disable.add("Instantiatable")
disable.add("MissingPermission") disable.add("MissingPermission")

View File

@@ -300,7 +300,7 @@
repositoryURL = "https://git.loverde.com.br/Loverde-Company-LTDA/LCEssentials.git"; repositoryURL = "https://git.loverde.com.br/Loverde-Company-LTDA/LCEssentials.git";
requirement = { requirement = {
kind = upToNextMajorVersion; kind = upToNextMajorVersion;
minimumVersion = 1.0.5; minimumVersion = 1.0.7;
}; };
}; };
/* End XCRemoteSwiftPackageReference section */ /* End XCRemoteSwiftPackageReference section */

View File

@@ -10,8 +10,8 @@ let package = Package(
.library(name: "PediFoods", type: .dynamic, targets: ["PediFoods"]), .library(name: "PediFoods", type: .dynamic, targets: ["PediFoods"]),
], ],
dependencies: [ dependencies: [
.package(url: "https://source.skip.tools/skip.git", from: "1.7.0"), .package(url: "https://source.skip.tools/skip.git", from: "1.8.4"),
.package(url: "https://source.skip.tools/skip-fuse-ui.git", from: "1.0.0"), .package(path: "Vendor/skip-fuse-ui"),
.package(url: "https://git.loverde.com.br/Loverde-Company-LTDA/LCEssentials.git", from: "1.0.5") .package(url: "https://git.loverde.com.br/Loverde-Company-LTDA/LCEssentials.git", from: "1.0.5")
], ],
targets: [ targets: [

View File

@@ -2,6 +2,7 @@ import SwiftUI
struct FeaturedStoreCard: View { struct FeaturedStoreCard: View {
let store: FeaturedStoreCardModel let store: FeaturedStoreCardModel
var onFavoriteToggle: (() -> Void)? = nil
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
@@ -21,7 +22,9 @@ struct FeaturedStoreCard: View {
HStack { HStack {
Spacer() Spacer()
Button(action: {}) { Button {
onFavoriteToggle?()
} label: {
Image(systemName: store.isFavorite ? "heart.fill" : "heart") Image(systemName: store.isFavorite ? "heart.fill" : "heart")
.foregroundStyle(store.isFavorite ? Color.red : AppColors.textMuted) .foregroundStyle(store.isFavorite ? Color.red : AppColors.textMuted)
.padding(8) .padding(8)
@@ -29,6 +32,8 @@ struct FeaturedStoreCard: View {
.clipShape(Circle()) .clipShape(Circle())
.shadow(color: AppShadow.soft.color, radius: AppShadow.soft.radius, y: AppShadow.soft.y) .shadow(color: AppShadow.soft.color, radius: AppShadow.soft.radius, y: AppShadow.soft.y)
} }
.buttonStyle(.borderless)
.accessibilityLabel(store.isFavorite ? "Remover loja dos favoritos" : "Adicionar loja aos favoritos")
.padding(10) .padding(10)
} }
} }

View File

@@ -153,6 +153,9 @@
"Adicionar fotos" : { "Adicionar fotos" : {
"comment" : "A label for adding photos in the order review section.", "comment" : "A label for adding photos in the order review section.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
},
"Adicionar loja aos favoritos" : {
}, },
"Adicionar novo endereço" : { "Adicionar novo endereço" : {
"comment" : "A button label that translates to \"Add new address\" in English.", "comment" : "A button label that translates to \"Add new address\" in English.",
@@ -932,6 +935,9 @@
"Remover" : { "Remover" : {
"comment" : "A button that deletes the user's profile picture.", "comment" : "A button that deletes the user's profile picture.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
},
"Remover loja dos favoritos" : {
}, },
"Resposta da loja" : { "Resposta da loja" : {
"comment" : "A heading for the reply from the store in an order review.", "comment" : "A heading for the reply from the store in an order review.",

View File

@@ -0,0 +1,6 @@
import Foundation
struct CustomerFavoritesMutationResult: Decodable {
let favorites: [String]
let store: StoreSummary?
}

View File

@@ -21,6 +21,7 @@ struct CustomerProfile: Decodable {
let email: String let email: String
let phoneNumber: String? let phoneNumber: String?
let profilePicture: String? let profilePicture: String?
let favorites: [String]?
let addressBook: [CustomerAddress]? let addressBook: [CustomerAddress]?
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
@@ -29,6 +30,7 @@ struct CustomerProfile: Decodable {
case email case email
case phoneNumber case phoneNumber
case profilePicture case profilePicture
case favorites
case addressBook = "address_book" case addressBook = "address_book"
} }
} }

View File

@@ -23,6 +23,7 @@ final class ApiService {
private var tokenStore: TokenStore private var tokenStore: TokenStore
private let profileCachePrefix = "api:profile:" private let profileCachePrefix = "api:profile:"
private let ordersCachePrefix = "api:orders:" private let ordersCachePrefix = "api:orders:"
private let favoritesCachePrefix = "api:favorites:"
private let publicCategoriesCacheKey = "api:public-categories" private let publicCategoriesCacheKey = "api:public-categories"
init(client: ApiClient = ApiClient(), tokenStore: TokenStore = DefaultTokenStore()) { init(client: ApiClient = ApiClient(), tokenStore: TokenStore = DefaultTokenStore()) {
@@ -68,10 +69,16 @@ final class ApiService {
tokenStore.clear() tokenStore.clear()
AppContentCache.shared.invalidate(prefix: profileCachePrefix) AppContentCache.shared.invalidate(prefix: profileCachePrefix)
AppContentCache.shared.invalidate(prefix: ordersCachePrefix) AppContentCache.shared.invalidate(prefix: ordersCachePrefix)
AppContentCache.shared.invalidate(prefix: favoritesCachePrefix)
AppContentCache.shared.invalidate(prefix: publicCategoriesCacheKey) AppContentCache.shared.invalidate(prefix: publicCategoriesCacheKey)
NotificationCenter.default.post(name: .sessionExpired, object: message) NotificationCenter.default.post(name: .sessionExpired, object: message)
} }
private func invalidateFavoritesCache() {
AppContentCache.shared.invalidate(prefix: profileCachePrefix)
AppContentCache.shared.invalidate(prefix: favoritesCachePrefix)
}
private func scopedCacheSuffix() -> String { private func scopedCacheSuffix() -> String {
let jwt = tokenStore.jwt ?? "anonymous" let jwt = tokenStore.jwt ?? "anonymous"
if jwt.count <= 16 { return jwt } if jwt.count <= 16 { return jwt }
@@ -168,7 +175,7 @@ final class ApiService {
if envelope.error == false, envelope.result != nil { if envelope.error == false, envelope.result != nil {
AppContentCache.shared.set(envelope, for: scopedProfileKey, ttl: AppCacheTTL.twoHours) AppContentCache.shared.set(envelope, for: scopedProfileKey, ttl: AppCacheTTL.twoHours)
} else { } else {
AppContentCache.shared.invalidate(prefix: profileCachePrefix) invalidateFavoritesCache()
} }
return envelope return envelope
} }
@@ -241,11 +248,51 @@ final class ApiService {
if envelope.error == false, envelope.result != nil { if envelope.error == false, envelope.result != nil {
AppContentCache.shared.set(envelope, for: scopedProfileKey, ttl: AppCacheTTL.twoHours) AppContentCache.shared.set(envelope, for: scopedProfileKey, ttl: AppCacheTTL.twoHours)
} else { } else {
AppContentCache.shared.invalidate(prefix: profileCachePrefix) invalidateFavoritesCache()
} }
return envelope return envelope
} }
func listFavoriteStores(forceRefresh: Bool = false) async throws -> ApiEnvelope<[StoreSummary]> {
let cacheKey = "\(favoritesCachePrefix)\(scopedCacheSuffix())"
if forceRefresh == false,
let cached: ApiEnvelope<[StoreSummary]> = AppContentCache.shared.value(for: cacheKey, as: ApiEnvelope<[StoreSummary]>.self) {
return cached
}
let req = ApiRequest(path: "/api/customer/favorites", method: "GET", module: .customer, requiresAuth: true)
let envelope: ApiEnvelope<[StoreSummary]> = try await sendEnvelope(req)
if envelope.error == false, envelope.result != nil {
AppContentCache.shared.set(envelope, for: cacheKey, ttl: AppCacheTTL.twoHours)
}
return envelope
}
func addStoreToFavorites(storeId: String) async throws -> ApiEnvelope<CustomerFavoritesMutationResult> {
let req = ApiRequest(path: "/api/customer/favorites/\(storeId)", method: "POST", module: .customer, requiresAuth: true)
let envelope: ApiEnvelope<CustomerFavoritesMutationResult> = try await sendEnvelope(req)
if envelope.error == false {
invalidateFavoritesCache()
}
return envelope
}
func removeStoreFromFavorites(storeId: String) async throws -> ApiEnvelope<CustomerFavoritesMutationResult> {
let req = ApiRequest(path: "/api/customer/favorites/\(storeId)", method: "DELETE", module: .customer, requiresAuth: true)
let envelope: ApiEnvelope<CustomerFavoritesMutationResult> = try await sendEnvelope(req)
if envelope.error == false {
invalidateFavoritesCache()
}
return envelope
}
func setStoreFavorite(storeId: String, isFavorite: Bool) async throws -> ApiEnvelope<CustomerFavoritesMutationResult> {
if isFavorite {
return try await addStoreToFavorites(storeId: storeId)
}
return try await removeStoreFromFavorites(storeId: storeId)
}
private func matchesAddress(_ lhs: CustomerAddress, _ rhs: CustomerAddress) -> Bool { private func matchesAddress(_ lhs: CustomerAddress, _ rhs: CustomerAddress) -> Bool {
lhs.label == rhs.label && lhs.label == rhs.label &&
lhs.address == rhs.address && lhs.address == rhs.address &&

View File

@@ -0,0 +1,280 @@
import Foundation
enum StoreCatalogNormalizer {
static func sanitize(categories: [StoreCatalogCategory], storeId: String) -> [StoreCatalogCategory] {
var seenCategoryIds: Set<String> = []
return categories.enumerated().map { categoryIndex, category in
let categoryId = makeUniqueId(
rawValue: category.id,
fallback: "\(storeId)-category-\(categoryIndex)",
seenIds: &seenCategoryIds
)
let normalizedPizzaConfig = sanitize(
pizzaConfig: category.pizzaConfig,
categoryId: categoryId
)
var seenProductIds: Set<String> = []
let normalizedProducts = category.products.enumerated().map { productIndex, product in
sanitize(
product: product,
categoryId: categoryId,
productIndex: productIndex,
seenProductIds: &seenProductIds
)
}
return StoreCatalogCategory(
id: categoryId,
name: category.name,
isPizzaCategory: category.isPizzaCategory,
pizzaConfig: normalizedPizzaConfig,
products: normalizedProducts
)
}
}
static func preferredCategoryId(
from categories: [StoreCatalogCategory],
preferredId: String?
) -> String? {
guard let preferredId, preferredId.isEmpty == false else {
return categories.first?.id
}
if categories.contains(where: { $0.id == preferredId }) {
return preferredId
}
return categories.first?.id
}
private static func sanitize(
pizzaConfig: StorePizzaConfig?,
categoryId: String
) -> StorePizzaConfig? {
guard let pizzaConfig else { return nil }
var seenSizeIds: Set<String> = []
let normalizedSizes = pizzaConfig.sizes.enumerated().map { index, size in
StorePizzaSize(
id: makeUniqueId(
rawValue: size.id,
fallback: "\(categoryId)-size-\(index)",
seenIds: &seenSizeIds
),
name: size.name,
slices: size.slices,
maxFlavors: size.maxFlavors
)
}
var seenDoughIds: Set<String> = []
let normalizedDoughs = pizzaConfig.doughs.enumerated().map { index, dough in
StorePizzaDough(
id: makeUniqueId(
rawValue: dough.id,
fallback: "\(categoryId)-dough-\(index)",
seenIds: &seenDoughIds
),
name: dough.name,
active: dough.active
)
}
var seenCrustIds: Set<String> = []
let normalizedCrusts = pizzaConfig.crusts.enumerated().map { index, crust in
StorePizzaCrust(
id: makeUniqueId(
rawValue: crust.id,
fallback: "\(categoryId)-crust-\(index)",
seenIds: &seenCrustIds
),
name: crust.name,
active: crust.active,
priceModifier: crust.priceModifier
)
}
return StorePizzaConfig(
sizes: normalizedSizes,
doughs: normalizedDoughs,
crusts: normalizedCrusts
)
}
private static func sanitize(
product: StoreCatalogProduct,
categoryId: String,
productIndex: Int,
seenProductIds: inout Set<String>
) -> StoreCatalogProduct {
let productId = makeUniqueId(
rawValue: product.id,
fallback: "\(categoryId)-product-\(productIndex)",
seenIds: &seenProductIds
)
var seenGroupIds: Set<String> = []
var seenAddonItemIds: Set<String> = []
let normalizedAddonGroups = product.addonGroups.enumerated().map { groupIndex, group in
let groupId = makeUniqueId(
rawValue: group.id,
fallback: "\(productId)-group-\(groupIndex)",
seenIds: &seenGroupIds
)
let normalizedItems = group.items.enumerated().map { itemIndex, item in
StoreAddonItem(
id: makeUniqueId(
rawValue: item.id,
fallback: "\(groupId)-item-\(itemIndex)",
seenIds: &seenAddonItemIds
),
name: item.name,
price: item.price
)
}
return StoreAddonGroup(
id: groupId,
name: group.name,
minSelectors: group.minSelectors,
maxSelectors: group.maxSelectors,
items: normalizedItems
)
}
return StoreCatalogProduct(
id: productId,
type: product.type,
name: product.name,
description: product.description,
image: product.image,
price: product.price,
originalPrice: product.originalPrice,
pizzaPrices: product.pizzaPrices,
addonGroups: normalizedAddonGroups
)
}
private static func makeUniqueId(
rawValue: String,
fallback: String,
seenIds: inout Set<String>
) -> String {
let trimmedRawValue = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
let baseId = trimmedRawValue.isEmpty ? fallback : trimmedRawValue
if seenIds.contains(baseId) == false {
seenIds.insert(baseId)
return baseId
}
var suffix = 1
while true {
let candidate = "\(baseId)-\(suffix)"
if seenIds.contains(candidate) == false {
seenIds.insert(candidate)
return candidate
}
suffix += 1
}
}
}
extension StoreCatalogCategory {
init(
id: String,
name: String,
isPizzaCategory: Bool,
pizzaConfig: StorePizzaConfig?,
products: [StoreCatalogProduct]
) {
self.id = id
self.name = name
self.isPizzaCategory = isPizzaCategory
self.pizzaConfig = pizzaConfig
self.products = products
}
}
extension StoreCatalogProduct {
init(
id: String,
type: String?,
name: String,
description: String?,
image: String?,
price: Double?,
originalPrice: Double?,
pizzaPrices: [String: Double],
addonGroups: [StoreAddonGroup]
) {
self.id = id
self.type = type
self.name = name
self.description = description
self.image = image
self.price = price
self.originalPrice = originalPrice
self.pizzaPrices = pizzaPrices
self.addonGroups = addonGroups
}
}
extension StoreAddonGroup {
init(
id: String,
name: String,
minSelectors: Int?,
maxSelectors: Int?,
items: [StoreAddonItem]
) {
self.id = id
self.name = name
self.minSelectors = minSelectors
self.maxSelectors = maxSelectors
self.items = items
}
}
extension StoreAddonItem {
init(
id: String,
name: String,
price: Double?
) {
self.id = id
self.name = name
self.price = price
}
}
extension StorePizzaConfig {
init(
sizes: [StorePizzaSize],
doughs: [StorePizzaDough],
crusts: [StorePizzaCrust]
) {
self.sizes = sizes
self.doughs = doughs
self.crusts = crusts
}
}
extension StorePizzaCrust {
init(
id: String,
name: String?,
active: Bool?,
priceModifier: Double?
) {
self.id = id
self.name = name
self.active = active
self.priceModifier = priceModifier
}
}

View File

@@ -285,6 +285,7 @@ struct OtpView: View {
appState.profile.email = customer.email appState.profile.email = customer.email
appState.profile.phone = customer.phoneNumber ?? "" appState.profile.phone = customer.phoneNumber ?? ""
appState.profile.profilePicture = customer.profilePicture ?? "" appState.profile.profilePicture = customer.profilePicture ?? ""
appState.favorites.storeIds = Set(customer.favorites ?? [])
SessionStateStore.setActiveUserKey( SessionStateStore.setActiveUserKey(
SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email) SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email)
) )
@@ -315,6 +316,7 @@ struct OtpView: View {
} }
appState.profile.email = email appState.profile.email = email
appState.favorites = FavoritesState()
SessionStateStore.setActiveUserKey( SessionStateStore.setActiveUserKey(
SessionStateStore.makeUserKey(profileId: nil, email: email) SessionStateStore.makeUserKey(profileId: nil, email: email)
) )

View File

@@ -1,76 +1,35 @@
import SwiftUI import SwiftUI
#if canImport(UIKit)
import UIKit enum HomeScrollCoordinateSpace {
#endif static let name = "home-scroll"
}
#if os(iOS) #if os(iOS)
@MainActor struct HomeScrollOffsetPreferenceKey: PreferenceKey {
struct ScrollOffsetObserver: UIViewRepresentable { static let defaultValue: CGFloat = 0
let onOffsetChange: (CGFloat) -> Void
func makeUIView(context: Context) -> ScrollOffsetProbeView { static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
let view = ScrollOffsetProbeView() value = nextValue()
view.onOffsetChange = onOffsetChange
return view
}
func updateUIView(_ uiView: ScrollOffsetProbeView, context: Context) {
uiView.onOffsetChange = onOffsetChange
uiView.attachIfNeeded()
} }
} }
@MainActor struct ScrollOffsetObserver: View {
final class ScrollOffsetProbeView: UIView { let onOffsetChange: (CGFloat) -> Void
var onOffsetChange: (CGFloat) -> Void = { _ in }
private weak var observedScrollView: UIScrollView? var body: some View {
private var observation: NSKeyValueObservation? Color.clear
.frame(height: 0)
override init(frame: CGRect) { .background(
super.init(frame: frame) GeometryReader { geometry in
backgroundColor = .clear Color.clear.preference(
isUserInteractionEnabled = false key: HomeScrollOffsetPreferenceKey.self,
value: geometry.frame(in: .named(HomeScrollCoordinateSpace.name)).minY
)
} }
)
required init?(coder: NSCoder) { .onPreferenceChange(HomeScrollOffsetPreferenceKey.self) { minY in
super.init(coder: coder) onOffsetChange(-minY)
} }
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 #endif

View File

@@ -0,0 +1,42 @@
import Foundation
extension HomeView {
@MainActor
func toggleFavoriteStore(storeId: String, storeName: String) async {
guard favoriteRequestStoreIds.contains(storeId) == false else { return }
guard appState.session.isAuthenticated else {
SnackbarCenter.shared.show(title: "Faça login para favoritar lojas.", style: .warning, icon: "person.crop.circle.badge.exclamationmark", duration: 2.5)
return
}
let isFavorite = appState.favorites.storeIds.contains(storeId)
favoriteRequestStoreIds.insert(storeId)
defer { favoriteRequestStoreIds.remove(storeId) }
do {
let response = try await ApiService().setStoreFavorite(storeId: storeId, isFavorite: isFavorite == false)
guard response.error == false, let result = response.result else {
let message = response.message ?? "Não foi possível atualizar seus favoritos."
SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 3.5)
return
}
appState.favorites.storeIds = Set(result.favorites)
let successTitle = isFavorite
? "\(storeName) removida dos favoritos."
: "\(storeName) adicionada aos favoritos."
let successIcon = isFavorite ? "heart.slash.fill" : "heart.fill"
SnackbarCenter.shared.show(title: successTitle, style: .success, icon: successIcon, duration: 2.2)
} catch {
let message: String
if let networkError = error as? NetworkError {
message = networkError.errorDescription ?? "Não foi possível atualizar seus favoritos."
} else if let serviceError = error as? ApiServiceError {
message = serviceError.errorDescription ?? "Não foi possível atualizar seus favoritos."
} else {
message = "Não foi possível atualizar seus favoritos."
}
SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 3.5)
}
}
}

View File

@@ -19,6 +19,7 @@ struct HomeView: View {
@State var stores: [StoreSummary] = [] @State var stores: [StoreSummary] = []
@State var productSearchIndexByStoreId: [String: [String]] = [:] @State var productSearchIndexByStoreId: [String: [String]] = [:]
@State var searchDebounceToken = 0 @State var searchDebounceToken = 0
@State var favoriteRequestStoreIds: Set<String> = []
private let specials: [SpecialOfferCardModel] = [ private let specials: [SpecialOfferCardModel] = [
// .init(id: "ddddd", title: "Get 50% OFF", subtitle: "For your first order", colors: [Color(hex: "#1E6B43"), Color(hex: "#58A56C")]), // .init(id: "ddddd", title: "Get 50% OFF", subtitle: "For your first order", colors: [Color(hex: "#1E6B43"), Color(hex: "#58A56C")]),
@@ -47,7 +48,7 @@ struct HomeView: View {
refreshCategories: true refreshCategories: true
) )
} }
.background(scrollOffsetObserver) .coordinateSpace(name: HomeScrollCoordinateSpace.name)
header(collapseProgress: collapseProgress, height: headerHeight) header(collapseProgress: collapseProgress, height: headerHeight)
.frame(maxWidth: .infinity, alignment: .top) .frame(maxWidth: .infinity, alignment: .top)
@@ -84,6 +85,7 @@ struct HomeView: View {
private var contentStack: some View { private var contentStack: some View {
VStack(spacing: 24) { VStack(spacing: 24) {
scrollOffsetObserver
categoriesSection categoriesSection
section(title: "Featured") { section(title: "Featured") {
@@ -102,7 +104,14 @@ struct HomeView: View {
appState: $appState appState: $appState
) )
} label: { } label: {
FeaturedStoreCard(store: store) FeaturedStoreCard(
store: store,
onFavoriteToggle: {
Task {
await toggleFavoriteStore(storeId: store.id, storeName: store.name)
}
}
)
.frame(width: 190) .frame(width: 190)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -171,7 +180,14 @@ struct HomeView: View {
appState: $appState appState: $appState
) )
} label: { } label: {
FeaturedStoreCard(store: store) FeaturedStoreCard(
store: store,
onFavoriteToggle: {
Task {
await toggleFavoriteStore(storeId: store.id, storeName: store.name)
}
}
)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }

View File

@@ -1,7 +1,4 @@
import SwiftUI import SwiftUI
#if canImport(UIKit)
import UIKit
#endif
struct CategoryHeaderOffsetPreferenceKey: PreferenceKey { struct CategoryHeaderOffsetPreferenceKey: PreferenceKey {
static let defaultValue: [String: CGFloat] = [:] static let defaultValue: [String: CGFloat] = [:]
@@ -11,125 +8,41 @@ struct CategoryHeaderOffsetPreferenceKey: PreferenceKey {
} }
} }
enum StoreDetailScrollCoordinateSpace {
static let name = "store-detail-scroll"
}
struct ScrollOffsetPreferenceKey: PreferenceKey {
static let defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
struct ScrollOffsetReader: View { struct ScrollOffsetReader: View {
@Binding var offsetY: CGFloat @Binding var offsetY: CGFloat
var body: some View { var body: some View {
#if canImport(UIKit)
ScrollOffsetReaderRepresentable(offsetY: $offsetY)
#else
Color.clear Color.clear
#endif .frame(height: 0)
.background(
GeometryReader { geometry in
Color.clear.preference(
key: ScrollOffsetPreferenceKey.self,
value: geometry.frame(in: .named(StoreDetailScrollCoordinateSpace.name)).minY
)
} }
} )
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { minY in
#if canImport(UIKit) let normalizedOffset = max(0, -minY)
struct ScrollOffsetReaderRepresentable: UIViewRepresentable { if abs(offsetY - normalizedOffset) > 0.5 {
@Binding var offsetY: CGFloat offsetY = normalizedOffset
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 { struct AsyncStoreImage: View {
let imageURL: String? let imageURL: String?

View File

@@ -1,11 +1,15 @@
import SwiftUI import SwiftUI
extension StoreDetailView { extension StoreDetailView {
func heroIconButton(icon: String, action: @escaping () -> Void) -> some View { func heroIconButton(
icon: String,
foregroundStyle: Color = .white,
action: @escaping () -> Void
) -> some View {
Button(action: action) { Button(action: action) {
Image(systemName: icon) Image(systemName: icon)
.font(.system(size: 14, weight: .semibold)) .font(.system(size: 14, weight: .semibold))
.foregroundStyle(Color.white) .foregroundStyle(foregroundStyle)
.frame(width: 32, height: 32) .frame(width: 32, height: 32)
.background(Color.white.opacity(0.24)) .background(Color.white.opacity(0.24))
.clipShape(Circle()) .clipShape(Circle())
@@ -65,6 +69,49 @@ extension StoreDetailView {
info?.isOpen ?? true info?.isOpen ?? true
} }
var isFavoriteStore: Bool {
appState.favorites.storeIds.contains(storeId)
}
@MainActor
func toggleFavoriteStore() async {
guard isFavoriteRequestInFlight == false else { return }
guard appState.session.isAuthenticated else {
SnackbarCenter.shared.show(title: "Faça login para favoritar lojas.", style: .warning, icon: "person.crop.circle.badge.exclamationmark", duration: 2.5)
return
}
let isFavorite = isFavoriteStore
isFavoriteRequestInFlight = true
defer { isFavoriteRequestInFlight = false }
do {
let response = try await ApiService().setStoreFavorite(storeId: storeId, isFavorite: isFavorite == false)
guard response.error == false, let result = response.result else {
let message = response.message ?? "Não foi possível atualizar seus favoritos."
SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 3.5)
return
}
appState.favorites.storeIds = Set(result.favorites)
let successTitle = isFavorite
? "\(storeName) removida dos favoritos."
: "\(storeName) adicionada aos favoritos."
let successIcon = isFavorite ? "heart.slash.fill" : "heart.fill"
SnackbarCenter.shared.show(title: successTitle, style: .success, icon: successIcon, duration: 2.2)
} catch {
let message: String
if let networkError = error as? NetworkError {
message = networkError.errorDescription ?? "Não foi possível atualizar seus favoritos."
} else if let serviceError = error as? ApiServiceError {
message = serviceError.errorDescription ?? "Não foi possível atualizar seus favoritos."
} else {
message = "Não foi possível atualizar seus favoritos."
}
SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 3.5)
}
}
var summaryCardHeight: CGFloat { var summaryCardHeight: CGFloat {
summaryCardBaseHeight + (isStoreOpen ? 0 : closedBannerHeight) summaryCardBaseHeight + (isStoreOpen ? 0 : closedBannerHeight)
} }
@@ -97,9 +144,13 @@ extension StoreDetailView {
if forceRefresh == false, if forceRefresh == false,
let cachedInfo: StoreInfoResult = AppContentCache.shared.value(for: infoCacheKey, as: StoreInfoResult.self), let cachedInfo: StoreInfoResult = AppContentCache.shared.value(for: infoCacheKey, as: StoreInfoResult.self),
let cachedCatalog: [StoreCatalogCategory] = AppContentCache.shared.value(for: catalogCacheKey, as: [StoreCatalogCategory].self) { let cachedCatalog: [StoreCatalogCategory] = AppContentCache.shared.value(for: catalogCacheKey, as: [StoreCatalogCategory].self) {
let normalizedCatalog = StoreCatalogNormalizer.sanitize(categories: cachedCatalog, storeId: storeId)
info = cachedInfo info = cachedInfo
categories = cachedCatalog categories = normalizedCatalog
selectedCategoryId = cachedCatalog.first?.id selectedCategoryId = StoreCatalogNormalizer.preferredCategoryId(
from: normalizedCatalog,
preferredId: selectedCategoryId
)
isLoading = false isLoading = false
return return
} }
@@ -110,9 +161,9 @@ extension StoreDetailView {
} }
do { do {
async let infoRequest = ApiService().storeInfo(storeId: storeId) let apiService = ApiService()
async let catalogRequest = ApiService().storeCatalog(storeId: storeId) let infoResponse = try await apiService.storeInfo(storeId: storeId)
let (infoResponse, catalogResponse) = try await (infoRequest, catalogRequest) let catalogResponse = try await apiService.storeCatalog(storeId: storeId)
if infoResponse.error { if infoResponse.error {
errorMessage = infoResponse.message ?? "Não foi possível carregar a loja." errorMessage = infoResponse.message ?? "Não foi possível carregar a loja."
@@ -125,13 +176,21 @@ extension StoreDetailView {
return return
} }
let normalizedCatalog = StoreCatalogNormalizer.sanitize(
categories: catalogResponse.result ?? [],
storeId: storeId
)
info = infoResponse.result info = infoResponse.result
categories = catalogResponse.result ?? [] categories = normalizedCatalog
selectedCategoryId = categories.first?.id selectedCategoryId = StoreCatalogNormalizer.preferredCategoryId(
from: normalizedCatalog,
preferredId: selectedCategoryId
)
if let info = infoResponse.result { if let info = infoResponse.result {
AppContentCache.shared.set(info, for: infoCacheKey, ttl: AppCacheTTL.twoHours) AppContentCache.shared.set(info, for: infoCacheKey, ttl: AppCacheTTL.twoHours)
} }
AppContentCache.shared.set(categories, for: catalogCacheKey, ttl: AppCacheTTL.twoHours) AppContentCache.shared.set(normalizedCatalog, for: catalogCacheKey, ttl: AppCacheTTL.twoHours)
isLoading = false isLoading = false
} catch { } catch {
isLoading = false isLoading = false

View File

@@ -34,6 +34,7 @@ struct StoreDetailView: View {
@State var categoryHeaderOffsets: [String: CGFloat] = [:] @State var categoryHeaderOffsets: [String: CGFloat] = [:]
@State var isProgrammaticCategoryScroll = false @State var isProgrammaticCategoryScroll = false
@State var scrollOffsetY: CGFloat = 0 @State var scrollOffsetY: CGFloat = 0
@State var isFavoriteRequestInFlight = false
let cardTopInset: CGFloat = 168 let cardTopInset: CGFloat = 168
let summaryCardBaseHeight: CGFloat = 170 let summaryCardBaseHeight: CGFloat = 170
@@ -52,6 +53,7 @@ struct StoreDetailView: View {
ScrollView(showsIndicators: false) { ScrollView(showsIndicators: false) {
#if os(Android) #if os(Android)
LazyVStack(spacing: 0) { LazyVStack(spacing: 0) {
ScrollOffsetReader(offsetY: $scrollOffsetY)
topSection topSection
categoryTabs(proxy: proxy, safeTop: safeTop, isSticky: false) categoryTabs(proxy: proxy, safeTop: safeTop, isSticky: false)
@@ -60,6 +62,7 @@ struct StoreDetailView: View {
} }
#else #else
LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
ScrollOffsetReader(offsetY: $scrollOffsetY)
topSection topSection
categoryTabs(proxy: proxy, safeTop: safeTop, isSticky: false) categoryTabs(proxy: proxy, safeTop: safeTop, isSticky: false)
@@ -71,8 +74,8 @@ struct StoreDetailView: View {
.refreshable { .refreshable {
await loadStoreData(forceRefresh: true) await loadStoreData(forceRefresh: true)
} }
.coordinateSpace(name: StoreDetailScrollCoordinateSpace.name)
.ignoresSafeArea(edges: .top) .ignoresSafeArea(edges: .top)
.background(ScrollOffsetReader(offsetY: $scrollOffsetY))
} }
.ignoresSafeArea(edges: .top) .ignoresSafeArea(edges: .top)
.overlay(alignment: .top) { .overlay(alignment: .top) {
@@ -218,7 +221,14 @@ struct StoreDetailView: View {
} }
Spacer() Spacer()
heroIconButton(icon: "magnifyingglass") {} heroIconButton(icon: "magnifyingglass") {}
heroIconButton(icon: "heart") {} heroIconButton(
icon: isFavoriteStore ? "heart.fill" : "heart",
foregroundStyle: isFavoriteStore ? Color.red : Color.white
) {
Task {
await toggleFavoriteStore()
}
}
} }
.padding(.horizontal, 14) .padding(.horizontal, 14)
.padding(.top, UIDevice.topNotch) .padding(.top, UIDevice.topNotch)

View File

@@ -0,0 +1,49 @@
import Foundation
import Testing
@testable import PediFoods
@Test("Customer profile decodes persisted favorite store ids")
func customerProfileDecodesFavoriteStoreIds() throws {
let json = """
{
"error": false,
"result": {
"id": "cust_1",
"name": "Daniel",
"email": "daniel@example.com",
"phoneNumber": "+5511999999999",
"favorites": ["store_a", "store_b"],
"address_book": []
}
}
"""
let envelope = try JSONDecoder().decode(ApiEnvelope<CustomerProfile>.self, from: Data(json.utf8))
#expect(envelope.result?.favorites == ["store_a", "store_b"])
}
@Test("Favorite mutation decodes updated favorites array")
func favoriteMutationDecodesUpdatedFavoritesArray() throws {
let json = """
{
"error": false,
"result": {
"favorites": ["store_a"],
"store": {
"id": "store_a",
"name": "CPS Drinks",
"category": "Doces & Bolos",
"rating": 4.8,
"totalReviews": 12,
"isOpen": true,
"statusLabel": "Aberto"
}
}
}
"""
let envelope = try JSONDecoder().decode(ApiEnvelope<CustomerFavoritesMutationResult>.self, from: Data(json.utf8))
#expect(envelope.result?.favorites == ["store_a"])
#expect(envelope.result?.store?.id == "store_a")
#expect(envelope.result?.store?.name == "CPS Drinks")
}

View File

@@ -0,0 +1,117 @@
import Testing
@testable import PediFoods
@Test("Store catalog normalizer makes IDs non-empty and unique")
func storeCatalogNormalizerMakesIdsUnique() {
let catalog = [
StoreCatalogCategory(
id: "",
name: "Pizzas",
isPizzaCategory: true,
pizzaConfig: StorePizzaConfig(
sizes: [
StorePizzaSize(id: "", name: "Grande", slices: 8, maxFlavors: 2),
StorePizzaSize(id: "", name: "Familia", slices: 12, maxFlavors: 3)
],
doughs: [
StorePizzaDough(id: "massa", name: "Tradicional", active: true),
StorePizzaDough(id: "massa", name: "Fina", active: true)
],
crusts: [
StorePizzaCrust(id: "", name: "Cheddar", active: true, priceModifier: 5),
StorePizzaCrust(id: "", name: "Catupiry", active: true, priceModifier: 6)
]
),
products: [
StoreCatalogProduct(
id: "",
type: "pizza",
name: "Calabresa",
description: nil,
image: nil,
price: 10,
originalPrice: nil,
pizzaPrices: [:],
addonGroups: [
StoreAddonGroup(
id: "",
name: "Extras",
minSelectors: nil,
maxSelectors: nil,
items: [
StoreAddonItem(id: "", name: "Bacon", price: 2),
StoreAddonItem(id: "", name: "Bacon em dobro", price: 4)
]
),
StoreAddonGroup(
id: "",
name: "Molhos",
minSelectors: nil,
maxSelectors: nil,
items: [
StoreAddonItem(id: "", name: "Alho", price: 1)
]
)
]
),
StoreCatalogProduct(
id: "",
type: "pizza",
name: "Mussarela",
description: nil,
image: nil,
price: 12,
originalPrice: nil,
pizzaPrices: [:],
addonGroups: []
)
]
),
StoreCatalogCategory(
id: "",
name: "Bebidas",
isPizzaCategory: false,
pizzaConfig: nil,
products: [
StoreCatalogProduct(
id: "",
type: nil,
name: "Refrigerante",
description: nil,
image: nil,
price: 7,
originalPrice: nil,
pizzaPrices: [:],
addonGroups: []
)
]
)
]
let normalized = StoreCatalogNormalizer.sanitize(categories: catalog, storeId: "store-1")
let categoryIds = normalized.map(\.id)
#expect(Set(categoryIds).count == categoryIds.count)
#expect(categoryIds.allSatisfy { $0.isEmpty == false })
let firstCategory = normalized[0]
let productIds = firstCategory.products.map(\.id)
#expect(Set(productIds).count == productIds.count)
#expect(productIds.allSatisfy { $0.isEmpty == false })
let addonGroupIds = firstCategory.products[0].addonGroups.map(\.id)
#expect(Set(addonGroupIds).count == addonGroupIds.count)
#expect(addonGroupIds.allSatisfy { $0.isEmpty == false })
let addonItemIds = firstCategory.products[0].addonGroups.flatMap(\.items).map(\.id)
#expect(Set(addonItemIds).count == addonItemIds.count)
#expect(addonItemIds.allSatisfy { $0.isEmpty == false })
let sizeIds = firstCategory.pizzaConfig?.sizes.map(\.id) ?? []
let doughIds = firstCategory.pizzaConfig?.doughs.map(\.id) ?? []
let crustIds = firstCategory.pizzaConfig?.crusts.map(\.id) ?? []
#expect(Set(sizeIds).count == sizeIds.count)
#expect(Set(doughIds).count == doughIds.count)
#expect(Set(crustIds).count == crustIds.count)
}

BIN
sim-home.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB