migration
This commit is contained in:
483
PediFoods/Views/Main/StoreDetailView+Logic.swift
Normal file
483
PediFoods/Views/Main/StoreDetailView+Logic.swift
Normal file
@@ -0,0 +1,483 @@
|
||||
import SwiftUI
|
||||
|
||||
extension StoreDetailView {
|
||||
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(foregroundStyle)
|
||||
.frame(width: 32, height: 32)
|
||||
.background(Color.white.opacity(0.24))
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
func statItem(title: String, value: String) -> some View {
|
||||
VStack(spacing: 4) {
|
||||
Text(title)
|
||||
.font(AppTypography.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
Text(value)
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
var ratingChip: some View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "star.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color(hex: "#F5B335"))
|
||||
Text(String(format: "%.1f", storeRating ?? 0))
|
||||
.font(AppTypography.caption)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(AppColors.brandSoft)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
|
||||
var storeSubtitle: String {
|
||||
let category = (storeCategory ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if category.isEmpty { return "Restaurant" }
|
||||
return category
|
||||
}
|
||||
|
||||
var deliveryValueLabel: String {
|
||||
if let minOrder = info?.minOrder {
|
||||
return formatCurrency(minOrder)
|
||||
}
|
||||
return "R$ --"
|
||||
}
|
||||
|
||||
var distanceValueLabel: String {
|
||||
let raw = (storeDistance ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if raw.isEmpty {
|
||||
return "--"
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
var isStoreOpen: Bool {
|
||||
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)
|
||||
}
|
||||
|
||||
var closedStoreBannerText: String {
|
||||
let label = info?.statusLabel?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if label.isEmpty {
|
||||
return "Loja fechada • Consulte o horário de abertura"
|
||||
}
|
||||
let normalized = label.lowercased()
|
||||
if normalized.hasPrefix("fechado") {
|
||||
let cleaned = label.replacingOccurrences(of: "Fechado", with: "")
|
||||
.replacingOccurrences(of: "fechado", with: "")
|
||||
.trimmingCharacters(in: CharacterSet(charactersIn: " -:•"))
|
||||
if cleaned.isEmpty == false {
|
||||
return "Loja fechada • \(cleaned)"
|
||||
}
|
||||
}
|
||||
return "Loja fechada • \(label)"
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func loadStoreData(forceRefresh: Bool = false) async {
|
||||
// A refresh (pull-to-refresh) that fails must never wipe content the
|
||||
// user is already looking at — only a first load with nothing yet
|
||||
// loaded is allowed to show a blocking error state.
|
||||
let hadExistingContent = categories.isEmpty == false
|
||||
isLoading = true
|
||||
if hadExistingContent == false {
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
let infoCacheKey = "store-info:\(storeId)"
|
||||
let catalogCacheKey = "store-catalog:\(storeId)"
|
||||
|
||||
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 = normalizedCatalog
|
||||
selectedCategoryId = StoreCatalogNormalizer.preferredCategoryId(
|
||||
from: normalizedCatalog,
|
||||
preferredId: selectedCategoryId
|
||||
)
|
||||
isLoading = false
|
||||
return
|
||||
}
|
||||
|
||||
if forceRefresh {
|
||||
AppContentCache.shared.invalidate(prefix: "store-info:\(storeId)")
|
||||
AppContentCache.shared.invalidate(prefix: "store-catalog:\(storeId)")
|
||||
}
|
||||
|
||||
do {
|
||||
if appState.session.isAuthenticated {
|
||||
let apiService = ApiService()
|
||||
let infoResponse = try await apiService.storeInfo(storeId: storeId)
|
||||
let catalogResponse = try await apiService.storeCatalog(storeId: storeId)
|
||||
|
||||
if infoResponse.error {
|
||||
isLoading = false
|
||||
reportStoreLoadFailure(infoResponse.message ?? "Não foi possível carregar a loja.", hadExistingContent: hadExistingContent)
|
||||
return
|
||||
}
|
||||
if catalogResponse.error {
|
||||
isLoading = false
|
||||
reportStoreLoadFailure(catalogResponse.message ?? "Não foi possível carregar o catálogo.", hadExistingContent: hadExistingContent)
|
||||
return
|
||||
}
|
||||
|
||||
let normalizedCatalog = StoreCatalogNormalizer.sanitize(
|
||||
categories: catalogResponse.result ?? [],
|
||||
storeId: storeId
|
||||
)
|
||||
|
||||
info = infoResponse.result
|
||||
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(normalizedCatalog, for: catalogCacheKey, ttl: AppCacheTTL.twoHours)
|
||||
} else {
|
||||
// Anonymous browsing — public/no-login store detail + catalog
|
||||
// via pedifoods.com.br, mapped onto the same StoreInfoResult /
|
||||
// StoreCatalogCategory models the authenticated path uses
|
||||
// above, so the rest of this view doesn't need to know which
|
||||
// source the data came from.
|
||||
async let publicDetail = PublicLocationService.shared.fetchStoreDetail(identifier: storeId)
|
||||
async let publicProducts = PublicLocationService.shared.fetchStoreProducts(storeId: storeId)
|
||||
let (detail, products) = try await (publicDetail, publicProducts)
|
||||
|
||||
let normalizedCatalog = StoreCatalogNormalizer.sanitize(categories: products, storeId: storeId)
|
||||
let publicInfo = StoreInfoResult(publicDetail: detail)
|
||||
|
||||
info = publicInfo
|
||||
categories = normalizedCatalog
|
||||
selectedCategoryId = StoreCatalogNormalizer.preferredCategoryId(
|
||||
from: normalizedCatalog,
|
||||
preferredId: selectedCategoryId
|
||||
)
|
||||
AppContentCache.shared.set(publicInfo, for: infoCacheKey, ttl: AppCacheTTL.twoHours)
|
||||
AppContentCache.shared.set(normalizedCatalog, for: catalogCacheKey, ttl: AppCacheTTL.twoHours)
|
||||
}
|
||||
errorMessage = nil
|
||||
isLoading = false
|
||||
} catch {
|
||||
isLoading = false
|
||||
// A cancelled request (e.g. the .refreshable task torn down by a
|
||||
// re-render, or superseded by a newer pull) is not a failure —
|
||||
// it never got a response either way, so there is nothing to
|
||||
// report and no content to touch.
|
||||
if isCancelledRequest(error) {
|
||||
return
|
||||
}
|
||||
let message: String
|
||||
if let network = error as? NetworkError {
|
||||
message = network.errorDescription ?? "Erro ao carregar loja."
|
||||
} else if let service = error as? ApiServiceError {
|
||||
message = service.errorDescription ?? "Erro ao carregar loja."
|
||||
} else {
|
||||
message = "Erro ao carregar loja."
|
||||
}
|
||||
reportStoreLoadFailure(message, hadExistingContent: hadExistingContent)
|
||||
}
|
||||
}
|
||||
|
||||
private func isCancelledRequest(_ error: Error) -> Bool {
|
||||
if error is CancellationError {
|
||||
return true
|
||||
}
|
||||
|
||||
if let networkError = error as? NetworkError {
|
||||
switch networkError {
|
||||
case .cancelled:
|
||||
return true
|
||||
case .transportError(let message):
|
||||
let normalized = message.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return normalized.contains("cancel")
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return error.localizedDescription.lowercased().contains("cancel")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func reportStoreLoadFailure(_ message: String, hadExistingContent: Bool) {
|
||||
if hadExistingContent {
|
||||
SnackbarCenter.shared.show(title: message, style: .warning, icon: "exclamationmark.triangle.fill", duration: 3.0)
|
||||
} else {
|
||||
errorMessage = message
|
||||
}
|
||||
}
|
||||
|
||||
func resolvedURL(_ raw: String?) -> String? {
|
||||
ImageSourceResolver.resolve(raw)
|
||||
}
|
||||
|
||||
func formatCurrency(_ value: Double?) -> String {
|
||||
guard let value else { return "R$ --" }
|
||||
return String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",")
|
||||
}
|
||||
|
||||
func listPriceLabel(for product: StoreCatalogProduct, in category: StoreCatalogCategory) -> String {
|
||||
guard product.type?.lowercased() == "pizza", product.pizzaPrices.isEmpty == false else {
|
||||
return formatCurrency(product.price)
|
||||
}
|
||||
|
||||
if let firstSizeId = category.pizzaConfig?.sizes.first?.id,
|
||||
let firstSizePrice = product.pizzaPrices[firstSizeId] {
|
||||
return "A partir de \(formatCurrency(firstSizePrice))"
|
||||
}
|
||||
|
||||
if let fallback = product.pizzaPrices.sorted(by: { $0.key < $1.key }).first?.value {
|
||||
return "A partir de \(formatCurrency(fallback))"
|
||||
}
|
||||
|
||||
return formatCurrency(product.price)
|
||||
}
|
||||
|
||||
var topSectionHeight: CGFloat {
|
||||
cardTopInset + summaryCardHeight
|
||||
}
|
||||
|
||||
func sectionAnchorId(for categoryId: String) -> String {
|
||||
"category-section-\(categoryId)"
|
||||
}
|
||||
|
||||
func syncCategoryWithScroll() {
|
||||
guard isLoading == false else { return }
|
||||
guard isProgrammaticCategoryScroll == false else { return }
|
||||
guard categoryHeaderOffsets.isEmpty == false else { return }
|
||||
|
||||
// Section whose header is nearest to the top content area wins.
|
||||
let topThreshold: CGFloat = 180
|
||||
let sorted = categoryHeaderOffsets.sorted { $0.value < $1.value }
|
||||
|
||||
if let current = sorted.last(where: { $0.value <= topThreshold })?.key {
|
||||
selectedCategoryId = current
|
||||
return
|
||||
}
|
||||
|
||||
if let firstVisible = sorted.first?.key {
|
||||
selectedCategoryId = firstVisible
|
||||
}
|
||||
}
|
||||
|
||||
func quantityInCart(for productId: String) -> Int {
|
||||
appState.cart.items
|
||||
.filter { $0.storeId == storeId && $0.productId == productId }
|
||||
.reduce(0) { $0 + $1.quantity }
|
||||
}
|
||||
|
||||
func quantityInCart(for item: StoreCatalogListItem) -> Int {
|
||||
if item.isPizzaSummary {
|
||||
let ids = Set(item.pizzaProductIds)
|
||||
return appState.cart.items
|
||||
.filter { $0.storeId == storeId && ids.contains($0.productId) }
|
||||
.reduce(0) { $0 + $1.quantity }
|
||||
}
|
||||
return quantityInCart(for: item.product.id)
|
||||
}
|
||||
|
||||
func listItems(for category: StoreCatalogCategory) -> [StoreCatalogListItem] {
|
||||
if category.isPizzaCategory {
|
||||
guard let first = category.products.first else { return [] }
|
||||
let representativeImage = category.products
|
||||
.compactMap(\.image)
|
||||
.first { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false }
|
||||
|
||||
return [
|
||||
StoreCatalogListItem(
|
||||
id: "\(category.id)::pizza-summary",
|
||||
product: first,
|
||||
title: "Escolha seu sabor",
|
||||
description: "Escolha o tamanho da sua fome",
|
||||
imageURL: representativeImage ?? first.image,
|
||||
isPizzaSummary: true,
|
||||
pizzaCategoryId: category.id,
|
||||
pizzaProductIds: category.products.map(\.id)
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
return category.products.map { product in
|
||||
StoreCatalogListItem(
|
||||
id: product.id,
|
||||
product: product,
|
||||
title: product.name,
|
||||
description: product.description,
|
||||
imageURL: product.image
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func requestAddToCart(_ item: CartItemState) {
|
||||
guard isStoreOpen else {
|
||||
SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0)
|
||||
return
|
||||
}
|
||||
pendingCartAction = .add
|
||||
if shouldAskForStoreSwitch(for: storeId) {
|
||||
pendingCartItem = item
|
||||
pendingProductSheet = nil
|
||||
showSwitchStoreAlert = true
|
||||
return
|
||||
}
|
||||
applyAddToCart(item)
|
||||
}
|
||||
|
||||
func requestSetCartItem(_ item: CartItemState) {
|
||||
guard isStoreOpen || item.quantity <= 0 else {
|
||||
SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0)
|
||||
return
|
||||
}
|
||||
pendingCartAction = .set
|
||||
if shouldAskForStoreSwitch(for: storeId) && item.quantity > 0 {
|
||||
pendingCartItem = item
|
||||
pendingProductSheet = nil
|
||||
showSwitchStoreAlert = true
|
||||
return
|
||||
}
|
||||
applySetCartItem(item)
|
||||
}
|
||||
|
||||
func requestOpenProductSheet(_ product: StoreCatalogProduct) {
|
||||
guard isStoreOpen else {
|
||||
SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0)
|
||||
return
|
||||
}
|
||||
pendingCartAction = .openProductSheet
|
||||
if shouldAskForStoreSwitch(for: storeId) {
|
||||
pendingCartItem = nil
|
||||
pendingProductSheet = product
|
||||
showSwitchStoreAlert = true
|
||||
return
|
||||
}
|
||||
selectedProduct = product
|
||||
}
|
||||
|
||||
func requestOpenPizzaSheet(categoryId: String) {
|
||||
guard isStoreOpen else {
|
||||
SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0)
|
||||
return
|
||||
}
|
||||
pendingCartAction = .openPizzaSheet
|
||||
if shouldAskForStoreSwitch(for: storeId) {
|
||||
pendingCartItem = nil
|
||||
pendingProductSheet = nil
|
||||
pendingPizzaCategoryId = categoryId
|
||||
showSwitchStoreAlert = true
|
||||
return
|
||||
}
|
||||
selectedPizzaCategoryId = categoryId
|
||||
}
|
||||
|
||||
func applyAddToCart(_ item: CartItemState) {
|
||||
if appState.cart.storeId == nil {
|
||||
appState.cart.storeId = storeId
|
||||
appState.cart.storeName = storeName
|
||||
}
|
||||
appState.cart.add(item: item)
|
||||
SnackbarCenter.shared.show(title: "Produto adicionado ao carrinho.", style: .success, icon: "checkmark.seal.fill", duration: 2.0)
|
||||
}
|
||||
|
||||
func applySetCartItem(_ item: CartItemState) {
|
||||
if item.quantity > 0, appState.cart.storeId == nil {
|
||||
appState.cart.storeId = storeId
|
||||
appState.cart.storeName = storeName
|
||||
}
|
||||
appState.cart.set(item: item)
|
||||
if item.quantity > 0 {
|
||||
SnackbarCenter.shared.show(title: "Carrinho atualizado.", style: .success, icon: "checkmark.seal.fill", duration: 1.8)
|
||||
} else {
|
||||
SnackbarCenter.shared.show(title: "Produto removido do carrinho.", style: .success, icon: "checkmark.seal.fill", duration: 1.8)
|
||||
}
|
||||
}
|
||||
|
||||
func currentQuantity(forCartItemId itemId: String) -> Int {
|
||||
appState.cart.items.first(where: { $0.id == itemId })?.quantity ?? 0
|
||||
}
|
||||
|
||||
func shouldAskForStoreSwitch(for targetStoreId: String) -> Bool {
|
||||
guard appState.cart.items.isEmpty == false else { return false }
|
||||
guard let currentStoreId = currentCartStoreId(),
|
||||
currentStoreId.isEmpty == false else { return false }
|
||||
return currentStoreId != targetStoreId
|
||||
}
|
||||
|
||||
func currentCartStoreId() -> String? {
|
||||
if let storeId = appState.cart.storeId, storeId.isEmpty == false {
|
||||
return storeId
|
||||
}
|
||||
return appState.cart.items.first?.storeId
|
||||
}
|
||||
}
|
||||
|
||||
enum CartAction {
|
||||
case add
|
||||
case set
|
||||
case openProductSheet
|
||||
case openPizzaSheet
|
||||
}
|
||||
Reference in New Issue
Block a user