diff --git a/API_Mobile_App.md b/API_Mobile_App.md index 3150af2..007a3e5 100644 --- a/API_Mobile_App.md +++ b/API_Mobile_App.md @@ -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. - Objetivo: evitar discrepâncias grandes entre endereço e coordenada salva no perfil. +### 5. List Favorite Stores +**GET** `/api/customer/favorites` + +**Headers:** + +- `Authorization: Bearer ` +- `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 ` +- `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 ` +- `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 @@ -538,19 +638,29 @@ Use este canal para acompanhar mudança de status do pedido em tempo real (sem d ``` **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 { - "id": "ord_987...", - "shortId": "1234", - "storeId": "store_123...", - "userId": "cust_uuid...", - "status": "CONFIRMED", - "paymentStatus": "CONFIRMED", - "updatedAt": "2026-02-15T20:10:00.000Z" + "event": "order_cancelled", + "emittedAt": "2026-03-05T18:00:00.000Z", + "orderId": "ffdc47b4-6a55-4713-aea9-45169e4cf0d7", + "shortId": "3778", + "storeId": "store_1772117366848_wmqw4", + "userId": "cust_uuid_001", + "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` - 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):** - `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. - 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. **Endpoint:** @@ -749,7 +895,7 @@ Exemplo inválido (entrega positiva com tags negativas): 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: - `GET /api/store/:storeId/reviews` diff --git a/pedi-foods/Android/app/build.gradle.kts b/pedi-foods/Android/app/build.gradle.kts index 4752bf9..8a591a5 100644 --- a/pedi-foods/Android/app/build.gradle.kts +++ b/pedi-foods/Android/app/build.gradle.kts @@ -41,10 +41,6 @@ android { // versionName = MARKETING_VERSION } - buildFeatures { - buildConfig = true - } - lint { disable.add("Instantiatable") disable.add("MissingPermission") diff --git a/pedi-foods/Darwin/PediFoods.xcodeproj/project.pbxproj b/pedi-foods/Darwin/PediFoods.xcodeproj/project.pbxproj index 3edd5e9..57006fe 100644 --- a/pedi-foods/Darwin/PediFoods.xcodeproj/project.pbxproj +++ b/pedi-foods/Darwin/PediFoods.xcodeproj/project.pbxproj @@ -300,7 +300,7 @@ repositoryURL = "https://git.loverde.com.br/Loverde-Company-LTDA/LCEssentials.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 1.0.5; + minimumVersion = 1.0.7; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/pedi-foods/Package.swift b/pedi-foods/Package.swift index 3d1ebe1..888bcd1 100644 --- a/pedi-foods/Package.swift +++ b/pedi-foods/Package.swift @@ -10,8 +10,8 @@ let package = Package( .library(name: "PediFoods", type: .dynamic, targets: ["PediFoods"]), ], dependencies: [ - .package(url: "https://source.skip.tools/skip.git", from: "1.7.0"), - .package(url: "https://source.skip.tools/skip-fuse-ui.git", from: "1.0.0"), + .package(url: "https://source.skip.tools/skip.git", from: "1.8.4"), + .package(path: "Vendor/skip-fuse-ui"), .package(url: "https://git.loverde.com.br/Loverde-Company-LTDA/LCEssentials.git", from: "1.0.5") ], targets: [ diff --git a/pedi-foods/Sources/PediFoods/Components/StoreCard.swift b/pedi-foods/Sources/PediFoods/Components/StoreCard.swift index 9f7c7ad..26de91f 100644 --- a/pedi-foods/Sources/PediFoods/Components/StoreCard.swift +++ b/pedi-foods/Sources/PediFoods/Components/StoreCard.swift @@ -2,6 +2,7 @@ import SwiftUI struct FeaturedStoreCard: View { let store: FeaturedStoreCardModel + var onFavoriteToggle: (() -> Void)? = nil var body: some View { VStack(alignment: .leading, spacing: 12) { @@ -21,7 +22,9 @@ struct FeaturedStoreCard: View { HStack { Spacer() - Button(action: {}) { + Button { + onFavoriteToggle?() + } label: { Image(systemName: store.isFavorite ? "heart.fill" : "heart") .foregroundStyle(store.isFavorite ? Color.red : AppColors.textMuted) .padding(8) @@ -29,6 +32,8 @@ struct FeaturedStoreCard: View { .clipShape(Circle()) .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) } } diff --git a/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings b/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings index 2a72535..b6d87f9 100644 --- a/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings +++ b/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings @@ -153,6 +153,9 @@ "Adicionar fotos" : { "comment" : "A label for adding photos in the order review section.", "isCommentAutoGenerated" : true + }, + "Adicionar loja aos favoritos" : { + }, "Adicionar novo endereço" : { "comment" : "A button label that translates to \"Add new address\" in English.", @@ -932,6 +935,9 @@ "Remover" : { "comment" : "A button that deletes the user's profile picture.", "isCommentAutoGenerated" : true + }, + "Remover loja dos favoritos" : { + }, "Resposta da loja" : { "comment" : "A heading for the reply from the store in an order review.", diff --git a/pedi-foods/Sources/PediFoods/Services/ApiFavoriteModels.swift b/pedi-foods/Sources/PediFoods/Services/ApiFavoriteModels.swift new file mode 100644 index 0000000..317be1d --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Services/ApiFavoriteModels.swift @@ -0,0 +1,6 @@ +import Foundation + +struct CustomerFavoritesMutationResult: Decodable { + let favorites: [String] + let store: StoreSummary? +} diff --git a/pedi-foods/Sources/PediFoods/Services/ApiModels.swift b/pedi-foods/Sources/PediFoods/Services/ApiModels.swift index 8b54b96..4e207c3 100644 --- a/pedi-foods/Sources/PediFoods/Services/ApiModels.swift +++ b/pedi-foods/Sources/PediFoods/Services/ApiModels.swift @@ -21,6 +21,7 @@ struct CustomerProfile: Decodable { let email: String let phoneNumber: String? let profilePicture: String? + let favorites: [String]? let addressBook: [CustomerAddress]? enum CodingKeys: String, CodingKey { @@ -29,6 +30,7 @@ struct CustomerProfile: Decodable { case email case phoneNumber case profilePicture + case favorites case addressBook = "address_book" } } diff --git a/pedi-foods/Sources/PediFoods/Services/ApiService.swift b/pedi-foods/Sources/PediFoods/Services/ApiService.swift index da0e965..ee295f4 100644 --- a/pedi-foods/Sources/PediFoods/Services/ApiService.swift +++ b/pedi-foods/Sources/PediFoods/Services/ApiService.swift @@ -23,6 +23,7 @@ final class ApiService { private var tokenStore: TokenStore private let profileCachePrefix = "api:profile:" private let ordersCachePrefix = "api:orders:" + private let favoritesCachePrefix = "api:favorites:" private let publicCategoriesCacheKey = "api:public-categories" init(client: ApiClient = ApiClient(), tokenStore: TokenStore = DefaultTokenStore()) { @@ -68,10 +69,16 @@ final class ApiService { tokenStore.clear() AppContentCache.shared.invalidate(prefix: profileCachePrefix) AppContentCache.shared.invalidate(prefix: ordersCachePrefix) + AppContentCache.shared.invalidate(prefix: favoritesCachePrefix) AppContentCache.shared.invalidate(prefix: publicCategoriesCacheKey) NotificationCenter.default.post(name: .sessionExpired, object: message) } + private func invalidateFavoritesCache() { + AppContentCache.shared.invalidate(prefix: profileCachePrefix) + AppContentCache.shared.invalidate(prefix: favoritesCachePrefix) + } + private func scopedCacheSuffix() -> String { let jwt = tokenStore.jwt ?? "anonymous" if jwt.count <= 16 { return jwt } @@ -168,7 +175,7 @@ final class ApiService { if envelope.error == false, envelope.result != nil { AppContentCache.shared.set(envelope, for: scopedProfileKey, ttl: AppCacheTTL.twoHours) } else { - AppContentCache.shared.invalidate(prefix: profileCachePrefix) + invalidateFavoritesCache() } return envelope } @@ -241,11 +248,51 @@ final class ApiService { if envelope.error == false, envelope.result != nil { AppContentCache.shared.set(envelope, for: scopedProfileKey, ttl: AppCacheTTL.twoHours) } else { - AppContentCache.shared.invalidate(prefix: profileCachePrefix) + invalidateFavoritesCache() } 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 { + let req = ApiRequest(path: "/api/customer/favorites/\(storeId)", method: "POST", module: .customer, requiresAuth: true) + let envelope: ApiEnvelope = try await sendEnvelope(req) + if envelope.error == false { + invalidateFavoritesCache() + } + return envelope + } + + func removeStoreFromFavorites(storeId: String) async throws -> ApiEnvelope { + let req = ApiRequest(path: "/api/customer/favorites/\(storeId)", method: "DELETE", module: .customer, requiresAuth: true) + let envelope: ApiEnvelope = try await sendEnvelope(req) + if envelope.error == false { + invalidateFavoritesCache() + } + return envelope + } + + func setStoreFavorite(storeId: String, isFavorite: Bool) async throws -> ApiEnvelope { + if isFavorite { + return try await addStoreToFavorites(storeId: storeId) + } + return try await removeStoreFromFavorites(storeId: storeId) + } + private func matchesAddress(_ lhs: CustomerAddress, _ rhs: CustomerAddress) -> Bool { lhs.label == rhs.label && lhs.address == rhs.address && diff --git a/pedi-foods/Sources/PediFoods/Services/StoreCatalogNormalizer.swift b/pedi-foods/Sources/PediFoods/Services/StoreCatalogNormalizer.swift new file mode 100644 index 0000000..563ee6e --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Services/StoreCatalogNormalizer.swift @@ -0,0 +1,280 @@ +import Foundation + +enum StoreCatalogNormalizer { + static func sanitize(categories: [StoreCatalogCategory], storeId: String) -> [StoreCatalogCategory] { + var seenCategoryIds: Set = [] + + 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 = [] + 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 = [] + 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 = [] + 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 = [] + 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 + ) -> StoreCatalogProduct { + let productId = makeUniqueId( + rawValue: product.id, + fallback: "\(categoryId)-product-\(productIndex)", + seenIds: &seenProductIds + ) + + var seenGroupIds: Set = [] + var seenAddonItemIds: Set = [] + 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 { + 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 + } +} diff --git a/pedi-foods/Sources/PediFoods/Views/Auth/OtpView.swift b/pedi-foods/Sources/PediFoods/Views/Auth/OtpView.swift index 93c4a93..25c8e6c 100644 --- a/pedi-foods/Sources/PediFoods/Views/Auth/OtpView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Auth/OtpView.swift @@ -285,6 +285,7 @@ struct OtpView: View { appState.profile.email = customer.email appState.profile.phone = customer.phoneNumber ?? "" appState.profile.profilePicture = customer.profilePicture ?? "" + appState.favorites.storeIds = Set(customer.favorites ?? []) SessionStateStore.setActiveUserKey( SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email) ) @@ -315,6 +316,7 @@ struct OtpView: View { } appState.profile.email = email + appState.favorites = FavoritesState() SessionStateStore.setActiveUserKey( SessionStateStore.makeUserKey(profileId: nil, email: email) ) diff --git a/pedi-foods/Sources/PediFoods/Views/Main/HomeScrollOffsetObserver.swift b/pedi-foods/Sources/PediFoods/Views/Main/HomeScrollOffsetObserver.swift index 4489f2b..c5eef3d 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/HomeScrollOffsetObserver.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/HomeScrollOffsetObserver.swift @@ -1,76 +1,35 @@ import SwiftUI -#if canImport(UIKit) -import UIKit -#endif + +enum HomeScrollCoordinateSpace { + static let name = "home-scroll" +} #if os(iOS) -@MainActor -struct ScrollOffsetObserver: UIViewRepresentable { - let onOffsetChange: (CGFloat) -> Void +struct HomeScrollOffsetPreferenceKey: PreferenceKey { + static let defaultValue: CGFloat = 0 - func makeUIView(context: Context) -> ScrollOffsetProbeView { - let view = ScrollOffsetProbeView() - view.onOffsetChange = onOffsetChange - return view - } - - func updateUIView(_ uiView: ScrollOffsetProbeView, context: Context) { - uiView.onOffsetChange = onOffsetChange - uiView.attachIfNeeded() + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = nextValue() } } -@MainActor -final class ScrollOffsetProbeView: UIView { - var onOffsetChange: (CGFloat) -> Void = { _ in } +struct ScrollOffsetObserver: View { + let onOffsetChange: (CGFloat) -> Void - 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 + var body: some View { + Color.clear + .frame(height: 0) + .background( + GeometryReader { geometry in + Color.clear.preference( + key: HomeScrollOffsetPreferenceKey.self, + value: geometry.frame(in: .named(HomeScrollCoordinateSpace.name)).minY + ) + } + ) + .onPreferenceChange(HomeScrollOffsetPreferenceKey.self) { minY in + onOffsetChange(-minY) } - view = current.superview - } - return nil } } #endif diff --git a/pedi-foods/Sources/PediFoods/Views/Main/HomeView+Favorites.swift b/pedi-foods/Sources/PediFoods/Views/Main/HomeView+Favorites.swift new file mode 100644 index 0000000..b5fcc40 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Main/HomeView+Favorites.swift @@ -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) + } + } +} diff --git a/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift b/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift index f2d28fd..2474a1d 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift @@ -19,6 +19,7 @@ struct HomeView: View { @State var stores: [StoreSummary] = [] @State var productSearchIndexByStoreId: [String: [String]] = [:] @State var searchDebounceToken = 0 + @State var favoriteRequestStoreIds: Set = [] private let specials: [SpecialOfferCardModel] = [ // .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 ) } - .background(scrollOffsetObserver) + .coordinateSpace(name: HomeScrollCoordinateSpace.name) header(collapseProgress: collapseProgress, height: headerHeight) .frame(maxWidth: .infinity, alignment: .top) @@ -84,6 +85,7 @@ struct HomeView: View { private var contentStack: some View { VStack(spacing: 24) { + scrollOffsetObserver categoriesSection section(title: "Featured") { @@ -102,7 +104,14 @@ struct HomeView: View { appState: $appState ) } label: { - FeaturedStoreCard(store: store) + FeaturedStoreCard( + store: store, + onFavoriteToggle: { + Task { + await toggleFavoriteStore(storeId: store.id, storeName: store.name) + } + } + ) .frame(width: 190) } .buttonStyle(.plain) @@ -171,7 +180,14 @@ struct HomeView: View { appState: $appState ) } label: { - FeaturedStoreCard(store: store) + FeaturedStoreCard( + store: store, + onFavoriteToggle: { + Task { + await toggleFavoriteStore(storeId: store.id, storeName: store.name) + } + } + ) } .buttonStyle(.plain) } diff --git a/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailSupport.swift b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailSupport.swift index ba3b30c..1ba5e28 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailSupport.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailSupport.swift @@ -1,7 +1,4 @@ import SwiftUI -#if canImport(UIKit) -import UIKit -#endif struct CategoryHeaderOffsetPreferenceKey: PreferenceKey { 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 { @Binding var offsetY: CGFloat var body: some View { -#if canImport(UIKit) - ScrollOffsetReaderRepresentable(offsetY: $offsetY) -#else 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 + let normalizedOffset = max(0, -minY) + if abs(offsetY - normalizedOffset) > 0.5 { + offsetY = normalizedOffset + } + } } } -#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? diff --git a/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView+Logic.swift b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView+Logic.swift index f510296..b0dba94 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView+Logic.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView+Logic.swift @@ -1,11 +1,15 @@ import SwiftUI 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) { Image(systemName: icon) .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(Color.white) + .foregroundStyle(foregroundStyle) .frame(width: 32, height: 32) .background(Color.white.opacity(0.24)) .clipShape(Circle()) @@ -65,6 +69,49 @@ extension StoreDetailView { 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 { summaryCardBaseHeight + (isStoreOpen ? 0 : closedBannerHeight) } @@ -97,9 +144,13 @@ extension StoreDetailView { if forceRefresh == false, let cachedInfo: StoreInfoResult = AppContentCache.shared.value(for: infoCacheKey, as: StoreInfoResult.self), let cachedCatalog: [StoreCatalogCategory] = AppContentCache.shared.value(for: catalogCacheKey, as: [StoreCatalogCategory].self) { + let normalizedCatalog = StoreCatalogNormalizer.sanitize(categories: cachedCatalog, storeId: storeId) info = cachedInfo - categories = cachedCatalog - selectedCategoryId = cachedCatalog.first?.id + categories = normalizedCatalog + selectedCategoryId = StoreCatalogNormalizer.preferredCategoryId( + from: normalizedCatalog, + preferredId: selectedCategoryId + ) isLoading = false return } @@ -110,9 +161,9 @@ extension StoreDetailView { } do { - async let infoRequest = ApiService().storeInfo(storeId: storeId) - async let catalogRequest = ApiService().storeCatalog(storeId: storeId) - let (infoResponse, catalogResponse) = try await (infoRequest, catalogRequest) + let apiService = ApiService() + let infoResponse = try await apiService.storeInfo(storeId: storeId) + let catalogResponse = try await apiService.storeCatalog(storeId: storeId) if infoResponse.error { errorMessage = infoResponse.message ?? "Não foi possível carregar a loja." @@ -125,13 +176,21 @@ extension StoreDetailView { return } + let normalizedCatalog = StoreCatalogNormalizer.sanitize( + categories: catalogResponse.result ?? [], + storeId: storeId + ) + info = infoResponse.result - categories = catalogResponse.result ?? [] - selectedCategoryId = categories.first?.id + categories = normalizedCatalog + selectedCategoryId = StoreCatalogNormalizer.preferredCategoryId( + from: normalizedCatalog, + preferredId: selectedCategoryId + ) if let info = infoResponse.result { 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 } catch { isLoading = false diff --git a/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView.swift b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView.swift index 4834975..df42bbb 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView.swift @@ -34,6 +34,7 @@ struct StoreDetailView: View { @State var categoryHeaderOffsets: [String: CGFloat] = [:] @State var isProgrammaticCategoryScroll = false @State var scrollOffsetY: CGFloat = 0 + @State var isFavoriteRequestInFlight = false let cardTopInset: CGFloat = 168 let summaryCardBaseHeight: CGFloat = 170 @@ -52,6 +53,7 @@ struct StoreDetailView: View { ScrollView(showsIndicators: false) { #if os(Android) LazyVStack(spacing: 0) { + ScrollOffsetReader(offsetY: $scrollOffsetY) topSection categoryTabs(proxy: proxy, safeTop: safeTop, isSticky: false) @@ -60,6 +62,7 @@ struct StoreDetailView: View { } #else LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { + ScrollOffsetReader(offsetY: $scrollOffsetY) topSection categoryTabs(proxy: proxy, safeTop: safeTop, isSticky: false) @@ -71,8 +74,8 @@ struct StoreDetailView: View { .refreshable { await loadStoreData(forceRefresh: true) } + .coordinateSpace(name: StoreDetailScrollCoordinateSpace.name) .ignoresSafeArea(edges: .top) - .background(ScrollOffsetReader(offsetY: $scrollOffsetY)) } .ignoresSafeArea(edges: .top) .overlay(alignment: .top) { @@ -218,7 +221,14 @@ struct StoreDetailView: View { } Spacer() 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(.top, UIDevice.topNotch) diff --git a/pedi-foods/Tests/PediFoodsTests/FavoritesDecodingTests.swift b/pedi-foods/Tests/PediFoodsTests/FavoritesDecodingTests.swift new file mode 100644 index 0000000..d91e743 --- /dev/null +++ b/pedi-foods/Tests/PediFoodsTests/FavoritesDecodingTests.swift @@ -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.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.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") +} diff --git a/pedi-foods/Tests/PediFoodsTests/StoreCatalogNormalizerTests.swift b/pedi-foods/Tests/PediFoodsTests/StoreCatalogNormalizerTests.swift new file mode 100644 index 0000000..cc85638 --- /dev/null +++ b/pedi-foods/Tests/PediFoodsTests/StoreCatalogNormalizerTests.swift @@ -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) +} diff --git a/sim-home.png b/sim-home.png new file mode 100644 index 0000000..742d33f Binary files /dev/null and b/sim-home.png differ