updates
This commit is contained in:
@@ -41,10 +41,6 @@ android {
|
||||
// versionName = MARKETING_VERSION
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
lint {
|
||||
disable.add("Instantiatable")
|
||||
disable.add("MissingPermission")
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
struct CustomerFavoritesMutationResult: Decodable {
|
||||
let favorites: [String]
|
||||
let store: StoreSummary?
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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 {
|
||||
lhs.label == rhs.label &&
|
||||
lhs.address == rhs.address &&
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ struct HomeView: View {
|
||||
@State var stores: [StoreSummary] = []
|
||||
@State var productSearchIndexByStoreId: [String: [String]] = [:]
|
||||
@State var searchDebounceToken = 0
|
||||
@State var favoriteRequestStoreIds: Set<String> = []
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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?
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
49
pedi-foods/Tests/PediFoodsTests/FavoritesDecodingTests.swift
Normal file
49
pedi-foods/Tests/PediFoodsTests/FavoritesDecodingTests.swift
Normal 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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user