segregate files
This commit is contained in:
@@ -12,6 +12,7 @@ struct StoreDetailView: View {
|
||||
let storeCategory: String?
|
||||
let storeRating: Double?
|
||||
let storeDistance: String?
|
||||
@Binding var appState: AppState
|
||||
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
@@ -21,6 +22,10 @@ struct StoreDetailView: View {
|
||||
@State var categories: [StoreCatalogCategory] = []
|
||||
@State var selectedCategoryId: String? = nil
|
||||
@State var selectedProduct: StoreCatalogProduct? = nil
|
||||
@State var showSwitchStoreAlert = false
|
||||
@State var pendingCartItem: CartItemState? = nil
|
||||
@State var pendingProductSheet: StoreCatalogProduct? = nil
|
||||
@State var pendingCartAction: CartAction = .add
|
||||
@State var didLoad = false
|
||||
@State var categoryHeaderOffsets: [String: CGFloat] = [:]
|
||||
@State var isProgrammaticCategoryScroll = false
|
||||
@@ -71,9 +76,43 @@ struct StoreDetailView: View {
|
||||
}
|
||||
.sheet(item: $selectedProduct) { product in
|
||||
NavigationStack {
|
||||
ProductDetailSheet(product: product, imageURL: resolvedURL(product.image))
|
||||
ProductDetailSheet(
|
||||
product: product,
|
||||
imageURL: resolvedURL(product.image),
|
||||
storeId: storeId,
|
||||
currentQuantityForItemId: { itemId in
|
||||
currentQuantity(forCartItemId: itemId)
|
||||
},
|
||||
onAdd: { item in
|
||||
requestSetCartItem(item)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.alert("Trocar de loja?", isPresented: $showSwitchStoreAlert) {
|
||||
Button("Cancelar", role: .cancel) {
|
||||
pendingCartItem = nil
|
||||
pendingProductSheet = nil
|
||||
}
|
||||
Button("Limpar carrinho e adicionar", role: .destructive) {
|
||||
appState.cart.clear()
|
||||
switch pendingCartAction {
|
||||
case .add:
|
||||
guard let pendingCartItem else { return }
|
||||
applyAddToCart(pendingCartItem)
|
||||
case .set:
|
||||
guard let pendingCartItem else { return }
|
||||
applySetCartItem(pendingCartItem)
|
||||
case .openProductSheet:
|
||||
guard let pendingProductSheet else { return }
|
||||
selectedProduct = pendingProductSheet
|
||||
}
|
||||
self.pendingCartItem = nil
|
||||
self.pendingProductSheet = nil
|
||||
}
|
||||
} message: {
|
||||
Text("Seu carrinho tem itens de outra loja. Deseja limpar o carrinho atual para adicionar este produto?")
|
||||
}
|
||||
.onPreferenceChange(CategoryHeaderOffsetPreferenceKey.self) { offsets in
|
||||
categoryHeaderOffsets = offsets
|
||||
syncCategoryWithScroll()
|
||||
@@ -322,14 +361,44 @@ struct StoreDetailView: View {
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||
|
||||
Button {
|
||||
selectedProduct = product
|
||||
if product.addonGroups.isEmpty {
|
||||
let basePrice = product.price ?? 0
|
||||
let item = CartItemState(
|
||||
id: "\(storeId)::\(product.id)::base",
|
||||
productId: product.id,
|
||||
storeId: storeId,
|
||||
name: product.name,
|
||||
quantity: 1,
|
||||
unitPrice: basePrice
|
||||
)
|
||||
requestAddToCart(item)
|
||||
} else {
|
||||
requestOpenProductSheet(product)
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
.font(.system(size: 16, weight: .bold))
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.frame(width: 30, height: 30)
|
||||
.background(AppColors.tertiary)
|
||||
.clipShape(Circle())
|
||||
ZStack(alignment: .leading) {
|
||||
Image(systemName: "plus")
|
||||
.font(.system(size: 16, weight: .bold))
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.frame(width: 30, height: 30)
|
||||
.background(AppColors.tertiary)
|
||||
.clipShape(Circle())
|
||||
|
||||
let qty = quantityInCart(for: product.id)
|
||||
if qty > 0 {
|
||||
Text("\(qty)")
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.foregroundStyle(Color.white)
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.red)
|
||||
.clipShape(Capsule())
|
||||
.offset(x: -6, y: -10)
|
||||
.zIndex(1)
|
||||
}
|
||||
}
|
||||
.offset(x: 3, y: -3)
|
||||
.frame(width: 30, height: 30)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.offset(x: 7, y: 7)
|
||||
@@ -340,391 +409,4 @@ struct StoreDetailView: View {
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||
}
|
||||
|
||||
private func heroIconButton(icon: String, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(Color.white)
|
||||
.frame(width: 32, height: 32)
|
||||
.background(Color.white.opacity(0.24))
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private 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)
|
||||
}
|
||||
|
||||
private 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())
|
||||
}
|
||||
|
||||
private var storeSubtitle: String {
|
||||
let category = (storeCategory ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if category.isEmpty { return "Restaurant" }
|
||||
return category
|
||||
}
|
||||
|
||||
private var deliveryValueLabel: String {
|
||||
if let minOrder = info?.minOrder {
|
||||
return formatCurrency(minOrder)
|
||||
}
|
||||
if let storeDistance, storeDistance.isEmpty == false {
|
||||
return storeDistance
|
||||
}
|
||||
return "R$ --"
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func loadStoreData() async {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
async let infoRequest = ApiService().storeInfo(storeId: storeId)
|
||||
async let catalogRequest = ApiService().storeCatalog(storeId: storeId)
|
||||
let (infoResponse, catalogResponse) = try await (infoRequest, catalogRequest)
|
||||
|
||||
if infoResponse.error {
|
||||
errorMessage = infoResponse.message ?? "Não foi possível carregar a loja."
|
||||
isLoading = false
|
||||
return
|
||||
}
|
||||
if catalogResponse.error {
|
||||
errorMessage = catalogResponse.message ?? "Não foi possível carregar o catálogo."
|
||||
isLoading = false
|
||||
return
|
||||
}
|
||||
|
||||
info = infoResponse.result
|
||||
categories = catalogResponse.result ?? []
|
||||
selectedCategoryId = categories.first?.id
|
||||
isLoading = false
|
||||
} catch {
|
||||
isLoading = false
|
||||
if let network = error as? NetworkError {
|
||||
errorMessage = network.errorDescription ?? "Erro ao carregar loja."
|
||||
return
|
||||
}
|
||||
if let service = error as? ApiServiceError {
|
||||
errorMessage = service.errorDescription ?? "Erro ao carregar loja."
|
||||
return
|
||||
}
|
||||
errorMessage = "Erro ao carregar loja."
|
||||
}
|
||||
}
|
||||
|
||||
private func resolvedURL(_ raw: String?) -> String? {
|
||||
guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
raw.isEmpty == false else { return nil }
|
||||
|
||||
let lower = raw.lowercased()
|
||||
if lower.hasPrefix("http://") || lower.hasPrefix("https://") {
|
||||
return raw
|
||||
}
|
||||
let base = ApiConfig.baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
let path = raw.hasPrefix("/") ? raw : "/\(raw)"
|
||||
return "\(base)\(path)"
|
||||
}
|
||||
|
||||
private func formatCurrency(_ value: Double?) -> String {
|
||||
guard let value else { return "R$ --" }
|
||||
return String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",")
|
||||
}
|
||||
|
||||
private var topSectionHeight: CGFloat {
|
||||
cardTopInset + summaryCardHeight
|
||||
}
|
||||
|
||||
private func sectionAnchorId(for categoryId: String) -> String {
|
||||
"category-section-\(categoryId)"
|
||||
}
|
||||
|
||||
private 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CategoryHeaderOffsetPreferenceKey: PreferenceKey {
|
||||
static let defaultValue: [String: CGFloat] = [:]
|
||||
|
||||
static func reduce(value: inout [String: CGFloat], nextValue: () -> [String: CGFloat]) {
|
||||
value.merge(nextValue(), uniquingKeysWith: { _, new in new })
|
||||
}
|
||||
}
|
||||
|
||||
struct ScrollOffsetReader: View {
|
||||
@Binding var offsetY: CGFloat
|
||||
|
||||
var body: some View {
|
||||
#if canImport(UIKit)
|
||||
ScrollOffsetReaderRepresentable(offsetY: $offsetY)
|
||||
#else
|
||||
Color.clear
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
#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?
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let imageURL,
|
||||
let url = URL(string: imageURL) {
|
||||
AsyncImage(url: url) { phase in
|
||||
switch phase {
|
||||
case .success(let image):
|
||||
image
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
default:
|
||||
fallback
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fallback
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(AppColors.brandSoft)
|
||||
.clipped()
|
||||
}
|
||||
|
||||
private var fallback: some View {
|
||||
Image("placeholder-product")
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
}
|
||||
}
|
||||
|
||||
struct ProductDetailSheet: View {
|
||||
let product: StoreCatalogProduct
|
||||
let imageURL: String?
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
var body: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
AsyncStoreImage(imageURL: imageURL)
|
||||
.frame(height: 220)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous))
|
||||
|
||||
Text(product.name)
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
|
||||
if let description = product.description, description.isEmpty == false {
|
||||
Text(description)
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
}
|
||||
|
||||
Text(String(format: "R$ %.2f", product.price ?? 0).replacingOccurrences(of: ".", with: ","))
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.primary)
|
||||
|
||||
if product.addonGroups.isEmpty == false {
|
||||
Text("Adicionais")
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
|
||||
ForEach(product.addonGroups) { group in
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(group.name)
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
|
||||
ForEach(group.items) { item in
|
||||
HStack {
|
||||
Text(item.name)
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
Spacer()
|
||||
Text(String(format: "+ R$ %.2f", item.price ?? 0).replacingOccurrences(of: ".", with: ","))
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.padding(.bottom, 80)
|
||||
}
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
HStack(spacing: 12) {
|
||||
Button(action: {}) {
|
||||
Image(systemName: "bag")
|
||||
.font(.system(size: 16, weight: .bold))
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.frame(width: 48, height: 48)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
PrimaryButton(title: "Comprar agora", action: {})
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 12)
|
||||
.background(.ultraThinMaterial)
|
||||
}
|
||||
.background(AppColors.backgroundLight.ignoresSafeArea())
|
||||
.navigationTitle("Detalhes")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Fechar") { dismiss() }
|
||||
.foregroundStyle(AppColors.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user