migration

This commit is contained in:
Daniel Arantes Loverde
2026-08-11 13:22:02 -03:00
parent c4d6998595
commit 7702836fe7
231 changed files with 4329 additions and 1958 deletions

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