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

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

View File

@@ -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 */

View File

@@ -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: [

View File

@@ -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)
}
}

View File

@@ -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.",

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 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"
}
}

View File

@@ -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 &&

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.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)
)

View File

@@ -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

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 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)
}

View File

@@ -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?

View File

@@ -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

View File

@@ -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)

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)
}