master merged
This commit is contained in:
@@ -32,13 +32,8 @@ struct AddAddressFormView: View {
|
||||
}
|
||||
|
||||
@ViewBuilder private var logoImage: some View {
|
||||
#if os(Android)
|
||||
SwiftUI.Image("pedifoods")
|
||||
.resizable()
|
||||
#else
|
||||
SwiftUI.Image("pedifoods")
|
||||
.resizable()
|
||||
#endif
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -47,6 +42,13 @@ struct AddAddressFormView: View {
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
screenHeader(
|
||||
title: existingAddress == nil ? "Novo endereço" : "Editar endereço",
|
||||
onBack: { dismiss() }
|
||||
)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 12)
|
||||
|
||||
logoImage
|
||||
.scaledToFit()
|
||||
.frame(width: 120, height: 120)
|
||||
@@ -63,8 +65,8 @@ struct AddAddressFormView: View {
|
||||
.padding(.bottom, 20)
|
||||
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
LoginField(icon: "tag", placeholder: "Ex: Casa, Trabalho", text: $label)
|
||||
LoginField(icon: "mail", placeholder: "CEP", text: $zipCode)
|
||||
LoginField(icon: "tag", placeholder: "Ex: Casa, Trabalho", keyboardType: .default, text: $label)
|
||||
LoginField(icon: "mail", placeholder: "CEP", keyboardType: .numberPad, text: $zipCode)
|
||||
.onChange(of: zipCode) { _, newValue in
|
||||
let masked = formatZipCodeBR(newValue)
|
||||
if masked != newValue {
|
||||
@@ -92,12 +94,12 @@ struct AddAddressFormView: View {
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
.padding(.horizontal, 6)
|
||||
}
|
||||
LoginField(icon: "mappin.and.ellipse", placeholder: "Rua / Avenida", text: $address)
|
||||
LoginField(icon: "number", placeholder: "Número", text: $number)
|
||||
LoginField(icon: "plus.app", placeholder: "Complemento (opcional)", text: $complement)
|
||||
LoginField(icon: "square.grid.2x2", placeholder: "Bairro", text: $neighborhood)
|
||||
LoginField(icon: "building.2", placeholder: "Cidade", text: $city)
|
||||
LoginField(icon: "map", placeholder: "Estado (UF)", text: $state)
|
||||
LoginField(icon: "mappin.and.ellipse", placeholder: "Rua / Avenida", keyboardType: .default, text: $address)
|
||||
LoginField(icon: "number", placeholder: "Número", keyboardType: .default, text: $number)
|
||||
LoginField(icon: "plus.app", placeholder: "Complemento (opcional)", keyboardType: .default, text: $complement)
|
||||
LoginField(icon: "square.grid.2x2", placeholder: "Bairro", keyboardType: .default, text: $neighborhood)
|
||||
LoginField(icon: "building.2", placeholder: "Cidade", keyboardType: .default, text: $city)
|
||||
LoginField(icon: "map", placeholder: "Estado (UF)", keyboardType: .default, text: $state)
|
||||
.onChange(of: state) { _, newValue in
|
||||
let normalized = String(newValue.uppercased().prefix(2))
|
||||
if normalized != newValue {
|
||||
@@ -134,6 +136,28 @@ struct AddAddressFormView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func screenHeader(title: String, onBack: @escaping () -> Void) -> some View {
|
||||
ZStack {
|
||||
Text(title)
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(colorScheme == .dark ? Color.white : AppColors.textPrimary)
|
||||
|
||||
HStack {
|
||||
Button(action: onBack) {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: 24, weight: .semibold))
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.frame(width: 52, height: 52)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(Circle())
|
||||
.shadow(color: .black.opacity(0.06), radius: 8, y: 2)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func saveAddress() {
|
||||
guard !isLoading else { return }
|
||||
let latLong: [Double]? = {
|
||||
|
||||
@@ -22,7 +22,7 @@ struct AddressesView: View {
|
||||
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: 20) {
|
||||
header
|
||||
screenHeader(title: "Meus Endereços", onBack: { dismiss() })
|
||||
|
||||
if let message {
|
||||
Text(message)
|
||||
@@ -131,28 +131,6 @@ struct AddressesView: View {
|
||||
}
|
||||
}
|
||||
|
||||
var header: some View {
|
||||
ZStack {
|
||||
Text("Meus Endereços")
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
|
||||
HStack {
|
||||
Button(action: { dismiss() }) {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: 24, weight: .semibold))
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.frame(width: 52, height: 52)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(Circle())
|
||||
.shadow(color: .black.opacity(0.06), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var bottomOverlay: some View {
|
||||
ZStack(alignment: .bottom) {
|
||||
Rectangle()
|
||||
@@ -181,6 +159,28 @@ struct AddressesView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func screenHeader(title: String, onBack: @escaping () -> Void) -> some View {
|
||||
ZStack {
|
||||
Text(title)
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
|
||||
HStack {
|
||||
Button(action: onBack) {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: 24, weight: .semibold))
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.frame(width: 52, height: 52)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(Circle())
|
||||
.shadow(color: .black.opacity(0.06), radius: 8, y: 2)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func selectAddress(_ address: CustomerAddress) {
|
||||
appState.address.selectedId = address.id
|
||||
let label = (address.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
@@ -692,12 +692,7 @@ struct PaymentPixView: View {
|
||||
}
|
||||
|
||||
private func copyToClipboard(_ value: String) {
|
||||
#if canImport(UIKit)
|
||||
UIPasteboard.general.string = value
|
||||
#elseif canImport(AppKit)
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(value, forType: .string)
|
||||
#endif
|
||||
appWriteClipboardText(value)
|
||||
}
|
||||
|
||||
private var parsedExpirationDate: Date? {
|
||||
|
||||
@@ -4,7 +4,6 @@ enum HomeScrollCoordinateSpace {
|
||||
static let name = "home-scroll"
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
struct HomeScrollOffsetPreferenceKey: PreferenceKey {
|
||||
static let defaultValue: CGFloat = 0
|
||||
|
||||
@@ -32,4 +31,3 @@ struct ScrollOffsetObserver: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -61,6 +61,7 @@ extension HomeView {
|
||||
rating: store.rating ?? 0,
|
||||
reviews: String(store.positiveReviews ?? store.reviewsCount ?? 0),
|
||||
distance: formatDistance(store.distance),
|
||||
deliveryFee: store.deliveryFee,
|
||||
category: store.category ?? "Loja",
|
||||
promoText: nil,
|
||||
isFavorite: appState.favorites.storeIds.contains(store.id),
|
||||
|
||||
@@ -48,7 +48,7 @@ struct HomeView: View {
|
||||
refreshCategories: true
|
||||
)
|
||||
}
|
||||
.coordinateSpace(name: HomeScrollCoordinateSpace.name)
|
||||
.appNamedCoordinateSpace(HomeScrollCoordinateSpace.name)
|
||||
|
||||
header(collapseProgress: collapseProgress, height: headerHeight)
|
||||
.frame(maxWidth: .infinity, alignment: .top)
|
||||
@@ -101,6 +101,7 @@ struct HomeView: View {
|
||||
storeCategory: store.category,
|
||||
storeRating: store.rating,
|
||||
storeDistance: store.distance,
|
||||
storeDeliveryFee: store.deliveryFee,
|
||||
appState: $appState
|
||||
)
|
||||
} label: {
|
||||
@@ -121,15 +122,17 @@ struct HomeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
section(title: "#PediPromo") {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 16) {
|
||||
ForEach(specials) { item in
|
||||
SpecialOfferCard(model: item)
|
||||
.frame(width: 260, height: 120)
|
||||
if appState.featureFlags.isEnabled("at.promo") {
|
||||
section(title: "#PediPromo") {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 16) {
|
||||
ForEach(specials) { item in
|
||||
SpecialOfferCard(model: item)
|
||||
.frame(width: 260, height: 120)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,6 +159,7 @@ struct HomeView: View {
|
||||
)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.primary)
|
||||
}
|
||||
@@ -177,6 +181,7 @@ struct HomeView: View {
|
||||
storeCategory: store.category,
|
||||
storeRating: store.rating,
|
||||
storeDistance: store.distance,
|
||||
storeDeliveryFee: store.deliveryFee,
|
||||
appState: $appState
|
||||
)
|
||||
} label: {
|
||||
@@ -204,7 +209,7 @@ struct HomeView: View {
|
||||
|
||||
return ZStack(alignment: .top) {
|
||||
RoundedRectangle(cornerRadius: 32, style: .continuous)
|
||||
.fill(AppColors.brandDark)
|
||||
.fill(AppColors.primary)
|
||||
.frame(height: height)
|
||||
.overlay(headerRings.opacity(1 - collapseProgress), alignment: .topTrailing)
|
||||
|
||||
@@ -449,16 +454,12 @@ struct HomeView: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var scrollOffsetObserver: some View {
|
||||
#if os(iOS)
|
||||
ScrollOffsetObserver { y in
|
||||
// Use only upward displacement for collapse and ignore top bounce.
|
||||
let normalized = max(0, y)
|
||||
scrollOffset = normalized
|
||||
}
|
||||
.frame(width: 0, height: 0)
|
||||
#else
|
||||
EmptyView()
|
||||
#endif
|
||||
}
|
||||
|
||||
private var addressCacheScope: String {
|
||||
|
||||
@@ -28,7 +28,7 @@ struct CategoryChip: View {
|
||||
.foregroundStyle(isActive ? AppColors.textInverse : AppColors.textPrimary)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
.background(isActive ? AppColors.brandDark : AppColors.surface)
|
||||
.background(isActive ? AppColors.primary : AppColors.surface)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,7 @@ struct SearchBar: View {
|
||||
Image(systemName: "slider.horizontal.3")
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.frame(height: 52)
|
||||
|
||||
@@ -72,12 +72,12 @@ struct MainTabView: View {
|
||||
.font(AppTypography.heading3)
|
||||
}
|
||||
}
|
||||
.foregroundStyle(isActive ? AppColors.primary : AppColors.textPrimary)
|
||||
.foregroundStyle(isActive ? AppColors.textInverse : AppColors.textPrimary)
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(isActive ? AppColors.brandSoft : Color.clear)
|
||||
.fill(isActive ? AppColors.primary : Color.clear)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import SwiftUI
|
||||
|
||||
struct OrdersView: View {
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@State var isLoading = false
|
||||
@State var errorMessage: String? = nil
|
||||
@State var orders: [AppOrderSummary] = []
|
||||
@@ -12,6 +13,8 @@ struct OrdersView: View {
|
||||
var body: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
screenHeader(title: "Meus Pedidos", onBack: { dismiss() })
|
||||
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
@@ -35,11 +38,12 @@ struct OrdersView: View {
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 18)
|
||||
.padding(.bottom, UIDevice.bottomNotch + 18)
|
||||
}
|
||||
.background(AppColors.backgroundLight)
|
||||
.navigationTitle("Meus Pedidos")
|
||||
.appInlineNavigationTitle()
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.appHiddenNavigationBar()
|
||||
.task {
|
||||
await loadOrdersIfNeeded()
|
||||
await refreshStoreRatings()
|
||||
@@ -59,6 +63,28 @@ struct OrdersView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func screenHeader(title: String, onBack: @escaping () -> Void) -> some View {
|
||||
ZStack {
|
||||
Text(title)
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
|
||||
HStack {
|
||||
Button(action: onBack) {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: 24, weight: .semibold))
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.frame(width: 52, height: 52)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(Circle())
|
||||
.shadow(color: .black.opacity(0.06), radius: 8, y: 2)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func orderCard(_ order: AppOrderSummary) -> some View {
|
||||
let status = orderVisualStatus(for: order)
|
||||
let detailsRoute = OrderRouteContext(
|
||||
@@ -81,7 +107,6 @@ struct OrdersView: View {
|
||||
AsyncStoreImage(imageURL: resolvedMediaURL(order.storeLogoURL))
|
||||
.frame(width: 80, height: 80)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
|
||||
.background(AppColors.brandSoft, in: RoundedRectangle(cornerRadius: 20, style: .continuous))
|
||||
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
|
||||
@@ -663,58 +688,57 @@ struct OrderEntryDestinationView: View {
|
||||
func resolveRoute() async {
|
||||
defer { isResolvingRoute = false }
|
||||
|
||||
if routeIntent == .tracking {
|
||||
return
|
||||
}
|
||||
|
||||
let order = await fetchOrderForRouting()
|
||||
guard let order else { return }
|
||||
|
||||
if routeIntent == .details {
|
||||
orderDetails = order
|
||||
return
|
||||
}
|
||||
if shouldOpenOrderDetails(for: order) {
|
||||
|
||||
// For both .tracking and .auto: show payment screen if payment is still pending.
|
||||
// Timeline only shows once payment is confirmed or method is off-app.
|
||||
if shouldOpenPaymentScreen(for: order) {
|
||||
let normalizedMethod = normalizePaymentMethod(order)
|
||||
if normalizedMethod.contains("PIX") {
|
||||
let pixFromPayment = order.payment?.pix
|
||||
let pixFromPayload = order.paymentPayload
|
||||
let copyPaste = (pixFromPayment?.copyPaste?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
|
||||
? pixFromPayment?.copyPaste
|
||||
: pixFromPayload?.copyPaste
|
||||
let qrCodeImage = (pixFromPayment?.qrCodeImage?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
|
||||
? pixFromPayment?.qrCodeImage
|
||||
: pixFromPayload?.qrCodeImage
|
||||
let expirationDate = (pixFromPayment?.expirationDate?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
|
||||
? pixFromPayment?.expirationDate
|
||||
: pixFromPayload?.expirationDate
|
||||
pixContext = PixPaymentContext(
|
||||
id: order.id,
|
||||
orderId: order.id,
|
||||
shortId: order.shortId ?? initialShortId,
|
||||
copyPaste: copyPaste?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
|
||||
? (copyPaste ?? "")
|
||||
: "Código PIX indisponível no momento. Aguarde e tente novamente.",
|
||||
qrCodeImageBase64: qrCodeImage,
|
||||
expirationDate: expirationDate
|
||||
)
|
||||
return
|
||||
}
|
||||
if normalizedMethod == "CREDIT_CARD" || normalizedMethod == "DEBIT_CARD" {
|
||||
cardContext = CardPaymentContext(
|
||||
orderId: order.id,
|
||||
shortId: order.shortId ?? initialShortId,
|
||||
total: order.total ?? fallbackTotal ?? 0
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// For .auto only: route terminal/canceled orders to details instead of timeline.
|
||||
if routeIntent == .auto && shouldOpenOrderDetails(for: order) {
|
||||
orderDetails = order
|
||||
return
|
||||
}
|
||||
guard shouldOpenPaymentScreen(for: order) else { return }
|
||||
|
||||
let normalizedMethod = normalizePaymentMethod(order)
|
||||
if normalizedMethod.contains("PIX") {
|
||||
let pixFromPayment = order.payment?.pix
|
||||
let pixFromPayload = order.paymentPayload
|
||||
let copyPaste = (pixFromPayment?.copyPaste?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
|
||||
? pixFromPayment?.copyPaste
|
||||
: pixFromPayload?.copyPaste
|
||||
|
||||
let qrCodeImage = (pixFromPayment?.qrCodeImage?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
|
||||
? pixFromPayment?.qrCodeImage
|
||||
: pixFromPayload?.qrCodeImage
|
||||
let expirationDate = (pixFromPayment?.expirationDate?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
|
||||
? pixFromPayment?.expirationDate
|
||||
: pixFromPayload?.expirationDate
|
||||
|
||||
pixContext = PixPaymentContext(
|
||||
id: order.id,
|
||||
orderId: order.id,
|
||||
shortId: order.shortId ?? initialShortId,
|
||||
copyPaste: copyPaste?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
|
||||
? (copyPaste ?? "")
|
||||
: "Código PIX indisponível no momento. Aguarde e tente novamente.",
|
||||
qrCodeImageBase64: qrCodeImage,
|
||||
expirationDate: expirationDate
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if normalizedMethod == "CREDIT_CARD" || normalizedMethod == "DEBIT_CARD" {
|
||||
cardContext = CardPaymentContext(
|
||||
orderId: order.id,
|
||||
shortId: order.shortId ?? initialShortId,
|
||||
total: order.total ?? fallbackTotal ?? 0
|
||||
)
|
||||
return
|
||||
}
|
||||
// .tracking (and .auto fallthrough) → nil states → body renders OrderTrackingView
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
||||
@@ -81,6 +81,7 @@ struct PizzaFlavorAddonsSheet: View {
|
||||
.appInlineNavigationTitle()
|
||||
.appTopBarTrailingToolbar {
|
||||
Button("Concluir") { dismiss() }
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(AppColors.primary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ struct ScrollOffsetPreferenceKey: PreferenceKey {
|
||||
|
||||
struct ScrollOffsetReader: View {
|
||||
@Binding var offsetY: CGFloat
|
||||
@State private var baseline: CGFloat? = nil
|
||||
|
||||
var body: some View {
|
||||
Color.clear
|
||||
@@ -35,9 +36,10 @@ struct ScrollOffsetReader: View {
|
||||
}
|
||||
)
|
||||
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { minY in
|
||||
let normalizedOffset = max(0, -minY)
|
||||
if abs(offsetY - normalizedOffset) > 0.5 {
|
||||
offsetY = normalizedOffset
|
||||
if baseline == nil { baseline = minY }
|
||||
let offset = max(0, (baseline ?? 0) - minY)
|
||||
if abs(offsetY - offset) > 0.5 {
|
||||
offsetY = offset
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,3 +63,6 @@ struct AsyncStoreImage: View {
|
||||
.scaledToFill()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
import SwiftUI
|
||||
|
||||
extension StoreDetailView {
|
||||
|
||||
var topSection: some View {
|
||||
ZStack(alignment: .top) {
|
||||
heroSection
|
||||
.frame(height: topSectionHeight)
|
||||
|
||||
// Hard cut: cover cannot appear below this line.
|
||||
Rectangle()
|
||||
.fill(AppColors.backgroundLight)
|
||||
.frame(height: max(0, topSectionHeight - coverVisibleUntilY))
|
||||
.offset(y: coverVisibleUntilY)
|
||||
|
||||
summaryCard
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, cardTopInset)
|
||||
|
||||
storeLogoBadge
|
||||
.padding(.top, cardTopInset - (storeLogoSize / 2))
|
||||
}
|
||||
.frame(height: topSectionHeight)
|
||||
}
|
||||
|
||||
var heroSection: some View {
|
||||
ZStack(alignment: .top) {
|
||||
AsyncStoreImage(imageURL: resolvedURL(storeCoverURL))
|
||||
.frame(height: topSectionHeight)
|
||||
.overlay(
|
||||
LinearGradient(
|
||||
colors: [Color.black.opacity(0.32), Color.black.opacity(0.05)],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
.ignoresSafeArea(.container, edges: .top)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
heroIconButton(icon: "chevron.left") {
|
||||
dismiss()
|
||||
}
|
||||
Spacer()
|
||||
heroIconButton(icon: "magnifyingglass") {}
|
||||
heroIconButton(
|
||||
icon: isFavoriteStore ? "heart.fill" : "heart",
|
||||
foregroundStyle: isFavoriteStore ? Color.red : Color.white
|
||||
) {
|
||||
Task {
|
||||
await toggleFavoriteStore()
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.top, UIDevice.topNotch)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("RESTAURANT")
|
||||
.font(AppTypography.overline)
|
||||
.tracking(1.8)
|
||||
.foregroundStyle(Color.white.opacity(0.92))
|
||||
.padding(.bottom, 14)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var storeLogoBadge: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(AppColors.surface)
|
||||
.frame(width: storeLogoSize, height: storeLogoSize)
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(Color.white, lineWidth: 0.1)
|
||||
)
|
||||
|
||||
AsyncStoreImage(imageURL: resolvedURL(storeLogoURL))
|
||||
.frame(width: storeLogoSize - 10, height: storeLogoSize - 10)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.shadow(color: Color.black.opacity(0.10), radius: 8, y: 3)
|
||||
}
|
||||
|
||||
var summaryCard: some View {
|
||||
VStack(spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack(alignment: .top) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(storeName)
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.lineLimit(2)
|
||||
.padding(.top, 30)
|
||||
|
||||
Text(storeSubtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
ratingChip
|
||||
}
|
||||
|
||||
HStack(spacing: 0) {
|
||||
statItem(title: "DISTÂNCIA", value: distanceValueLabel)
|
||||
Divider().frame(height: 34)
|
||||
if let deliveryTime = info?.deliveryTime {
|
||||
statItem(title: "TEMPO MIN.", value: deliveryTime+" min.")
|
||||
} else {
|
||||
statItem(title: "TEMPO", value: "--")
|
||||
}
|
||||
Divider().frame(height: 34)
|
||||
statItem(title: "PED. MIN.", value: deliveryValueLabel)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
.padding(16)
|
||||
|
||||
if isStoreOpen == false {
|
||||
Text(closedStoreBannerText)
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(Color.white)
|
||||
.frame(maxWidth: .infinity, minHeight: closedBannerHeight)
|
||||
.background(AppColors.brandDark)
|
||||
}
|
||||
}
|
||||
.frame(height: summaryCardHeight)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous))
|
||||
.shadow(color: AppShadow.soft.color, radius: AppShadow.soft.radius, y: AppShadow.soft.y)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var sectionedProducts: some View {
|
||||
if isLoading {
|
||||
VStack(spacing: 10) {
|
||||
ProgressView()
|
||||
Text("Carregando cardápio...")
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 280, alignment: .center)
|
||||
} else if let errorMessage {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(errorMessage)
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
Button("Tentar novamente") {
|
||||
Task { await loadStoreData() }
|
||||
}
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.primary)
|
||||
}
|
||||
.padding(.top, 16)
|
||||
} else if categories.isEmpty {
|
||||
Text("Cardápio indisponível no momento.")
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
.padding(.top, 16)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 120)
|
||||
} else {
|
||||
ForEach(categories, id: \.id) { category in
|
||||
Text(category.name)
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.id(sectionAnchorId(for: category.id))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 8)
|
||||
.background(AppColors.backgroundLight)
|
||||
.background(
|
||||
GeometryReader { geometry in
|
||||
Color.clear.preference(
|
||||
key: CategoryHeaderOffsetPreferenceKey.self,
|
||||
value: [category.id: geometry.frame(in: .global).minY]
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
VStack(spacing: 12) {
|
||||
ForEach(listItems(for: category)) { item in
|
||||
productCard(item, in: category)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
Color.clear.frame(height: 140)
|
||||
}
|
||||
}
|
||||
|
||||
func categoryTabs(proxy: ScrollViewProxy, isPinned: Bool = false) -> some View {
|
||||
let safeTop: CGFloat = {
|
||||
guard isPinned else { return 0 }
|
||||
#if canImport(UIKit)
|
||||
return UIDevice.appSafeAreaTop
|
||||
#else
|
||||
return 0
|
||||
#endif
|
||||
}()
|
||||
|
||||
return ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 10) {
|
||||
ForEach(categories, id: \.id) { category in
|
||||
let active = selectedCategoryId == category.id
|
||||
Button {
|
||||
selectedCategoryId = category.id
|
||||
isProgrammaticCategoryScroll = true
|
||||
withAnimation(.easeInOut(duration: 0.25)) {
|
||||
proxy.scrollTo(sectionAnchorId(for: category.id), anchor: .top)
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
|
||||
isProgrammaticCategoryScroll = false
|
||||
}
|
||||
} label: {
|
||||
Text(category.name)
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(active ? AppColors.textInverse : AppColors.textMuted)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 9)
|
||||
.background(active ? AppColors.primary : AppColors.surface)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
.padding(.top, safeTop)
|
||||
.background(AppColors.backgroundLight)
|
||||
.animation(.easeInOut(duration: 0.15), value: isPinned)
|
||||
}
|
||||
|
||||
func productCard(_ item: StoreCatalogListItem, in category: StoreCatalogCategory) -> some View {
|
||||
let product = item.product
|
||||
let hasSelectableAddons = product.addonGroups.contains { $0.items.isEmpty == false }
|
||||
|
||||
return HStack(spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(item.title)
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
|
||||
if let description = item.description, description.isEmpty == false {
|
||||
Text(description)
|
||||
.font(.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
|
||||
Text(listPriceLabel(for: product, in: category))
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
AsyncStoreImage(imageURL: resolvedURL(item.imageURL))
|
||||
.frame(width: 92, height: 92)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||
|
||||
Button {
|
||||
guard isStoreOpen else {
|
||||
SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0)
|
||||
return
|
||||
}
|
||||
if item.isPizzaSummary, let pizzaCategoryId = item.pizzaCategoryId {
|
||||
requestOpenPizzaSheet(categoryId: pizzaCategoryId)
|
||||
return
|
||||
}
|
||||
if hasSelectableAddons == false {
|
||||
let basePrice = product.price ?? 0
|
||||
let item = CartItemState(
|
||||
id: "\(storeId)::\(product.id)::base",
|
||||
productId: product.id,
|
||||
storeId: storeId,
|
||||
name: product.name,
|
||||
imageURL: resolvedURL(product.image),
|
||||
quantity: 1,
|
||||
unitPrice: basePrice
|
||||
)
|
||||
requestAddToCart(item)
|
||||
} else {
|
||||
requestOpenProductSheet(product)
|
||||
}
|
||||
} label: {
|
||||
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: item)
|
||||
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)
|
||||
.appContentShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.frame(width: 30, height: 30)
|
||||
.disabled(isStoreOpen == false)
|
||||
.opacity(isStoreOpen ? 1 : 0.65)
|
||||
.offset(x: 7, y: 7)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||
.appContentShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||
.onTapGesture {
|
||||
guard isStoreOpen else {
|
||||
SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0)
|
||||
return
|
||||
}
|
||||
if item.isPizzaSummary, let pizzaCategoryId = item.pizzaCategoryId {
|
||||
requestOpenPizzaSheet(categoryId: pizzaCategoryId)
|
||||
return
|
||||
}
|
||||
guard hasSelectableAddons else { return }
|
||||
requestOpenProductSheet(product)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -51,8 +51,8 @@ extension StoreDetailView {
|
||||
}
|
||||
|
||||
var deliveryValueLabel: String {
|
||||
if let minOrder = info?.minOrder {
|
||||
return formatCurrency(minOrder)
|
||||
if let fee = storeDeliveryFee {
|
||||
return formatCurrency(fee)
|
||||
}
|
||||
return "R$ --"
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ struct StoreDetailView: View {
|
||||
let storeCategory: String?
|
||||
let storeRating: Double?
|
||||
let storeDistance: String?
|
||||
let storeDeliveryFee: Double?
|
||||
@Binding var appState: AppState
|
||||
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@@ -33,60 +34,37 @@ struct StoreDetailView: View {
|
||||
@State var didLoad = false
|
||||
@State var categoryHeaderOffsets: [String: CGFloat] = [:]
|
||||
@State var isProgrammaticCategoryScroll = false
|
||||
@State var scrollOffsetY: CGFloat = 0
|
||||
@State var isFavoriteRequestInFlight = false
|
||||
@State var scrollOffset: CGFloat = 0
|
||||
|
||||
let cardTopInset: CGFloat = 168
|
||||
let summaryCardBaseHeight: CGFloat = 170
|
||||
var isCategoryTabsPinned: Bool { scrollOffset >= topSectionHeight }
|
||||
|
||||
let cardTopInset: CGFloat = 180
|
||||
let summaryCardBaseHeight: CGFloat = 212
|
||||
let closedBannerHeight: CGFloat = 44
|
||||
let coverVisibleUntilY: CGFloat = 253
|
||||
let storeLogoSize: CGFloat = 84
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
let safeTop = geometry.safeAreaInsets.top
|
||||
ScrollViewReader { proxy in
|
||||
|
||||
ZStack(alignment: .top) {
|
||||
AppColors.backgroundLight.ignoresSafeArea()
|
||||
|
||||
ScrollView(showsIndicators: false) {
|
||||
#if os(Android)
|
||||
LazyVStack(spacing: 0) {
|
||||
ScrollOffsetReader(offsetY: $scrollOffsetY)
|
||||
topSection
|
||||
|
||||
categoryTabs(proxy: proxy, safeTop: safeTop, isSticky: false)
|
||||
|
||||
sectionedProducts
|
||||
}
|
||||
#else
|
||||
LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
|
||||
ScrollOffsetReader(offsetY: $scrollOffsetY)
|
||||
topSection
|
||||
|
||||
categoryTabs(proxy: proxy, safeTop: safeTop, isSticky: false)
|
||||
|
||||
sectionedProducts
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.refreshable {
|
||||
await loadStoreData(forceRefresh: true)
|
||||
}
|
||||
.coordinateSpace(name: StoreDetailScrollCoordinateSpace.name)
|
||||
.ignoresSafeArea(edges: .top)
|
||||
}
|
||||
.ignoresSafeArea(edges: .top)
|
||||
.overlay(alignment: .top) {
|
||||
if isCategoryTabsPinned(safeTop: safeTop) {
|
||||
categoryTabs(proxy: proxy, safeTop: safeTop, isSticky: true)
|
||||
.transition(.opacity)
|
||||
.zIndex(20)
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView(showsIndicators: false) {
|
||||
ScrollOffsetReader(offsetY: $scrollOffset)
|
||||
LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
|
||||
topSection
|
||||
Section {
|
||||
sectionedProducts
|
||||
} header: {
|
||||
categoryTabs(proxy: proxy, isPinned: isCategoryTabsPinned)
|
||||
}
|
||||
}
|
||||
.saturation(isStoreOpen ? 1 : 0)
|
||||
}
|
||||
.coordinateSpace(name: StoreDetailScrollCoordinateSpace.name)
|
||||
.refreshable {
|
||||
await loadStoreData(forceRefresh: true)
|
||||
}
|
||||
.ignoresSafeArea(edges: .top)
|
||||
.background(AppColors.backgroundLight)
|
||||
.saturation(isStoreOpen ? 1 : 0)
|
||||
}
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.appHiddenNavigationBar()
|
||||
@@ -180,343 +158,4 @@ struct StoreDetailView: View {
|
||||
syncCategoryWithScroll()
|
||||
}
|
||||
}
|
||||
|
||||
private var topSection: some View {
|
||||
ZStack(alignment: .top) {
|
||||
heroSection
|
||||
.frame(height: topSectionHeight)
|
||||
|
||||
// Hard cut: cover cannot appear below this line.
|
||||
Rectangle()
|
||||
.fill(AppColors.backgroundLight)
|
||||
.frame(height: max(0, topSectionHeight - coverVisibleUntilY))
|
||||
.offset(y: coverVisibleUntilY)
|
||||
|
||||
summaryCard
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, cardTopInset)
|
||||
|
||||
storeLogoBadge
|
||||
.padding(.top, cardTopInset - (storeLogoSize / 2))
|
||||
}
|
||||
.frame(height: topSectionHeight)
|
||||
}
|
||||
|
||||
private var heroSection: some View {
|
||||
ZStack(alignment: .top) {
|
||||
AsyncStoreImage(imageURL: resolvedURL(storeCoverURL))
|
||||
.frame(height: topSectionHeight)
|
||||
.overlay(
|
||||
LinearGradient(
|
||||
colors: [Color.black.opacity(0.32), Color.black.opacity(0.05)],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
heroIconButton(icon: "chevron.left") {
|
||||
dismiss()
|
||||
}
|
||||
Spacer()
|
||||
heroIconButton(icon: "magnifyingglass") {}
|
||||
heroIconButton(
|
||||
icon: isFavoriteStore ? "heart.fill" : "heart",
|
||||
foregroundStyle: isFavoriteStore ? Color.red : Color.white
|
||||
) {
|
||||
Task {
|
||||
await toggleFavoriteStore()
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.top, UIDevice.topNotch)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("RESTAURANT")
|
||||
.font(AppTypography.overline)
|
||||
.tracking(1.8)
|
||||
.foregroundStyle(Color.white.opacity(0.92))
|
||||
.padding(.bottom, 14)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var storeLogoBadge: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(AppColors.surface)
|
||||
.frame(width: storeLogoSize, height: storeLogoSize)
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(Color.white, lineWidth: 0.1)
|
||||
)
|
||||
|
||||
AsyncStoreImage(imageURL: resolvedURL(storeLogoURL))
|
||||
.frame(width: storeLogoSize - 10, height: storeLogoSize - 10)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.shadow(color: Color.black.opacity(0.10), radius: 8, y: 3)
|
||||
}
|
||||
|
||||
private var summaryCard: some View {
|
||||
VStack(spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack(alignment: .top) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(storeName)
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.lineLimit(2)
|
||||
.padding(.top, 30)
|
||||
|
||||
Text(storeSubtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
ratingChip
|
||||
}
|
||||
|
||||
HStack(spacing: 0) {
|
||||
statItem(title: "DISTÂNCIA", value: distanceValueLabel)
|
||||
Divider().frame(height: 34)
|
||||
statItem(title: "TEMPO", value: info?.deliveryTime ?? "25-35 min")
|
||||
Divider().frame(height: 34)
|
||||
statItem(title: "ENTREGA", value: deliveryValueLabel)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
.padding(16)
|
||||
|
||||
if isStoreOpen == false {
|
||||
Text(closedStoreBannerText)
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(Color.white)
|
||||
.frame(maxWidth: .infinity, minHeight: closedBannerHeight)
|
||||
.background(AppColors.brandDark)
|
||||
}
|
||||
}
|
||||
.frame(height: summaryCardHeight)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous))
|
||||
.shadow(color: AppShadow.soft.color, radius: AppShadow.soft.radius, y: AppShadow.soft.y)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var sectionedProducts: some View {
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.top, 26)
|
||||
} else if let errorMessage {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(errorMessage)
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
Button("Tentar novamente") {
|
||||
Task { await loadStoreData() }
|
||||
}
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.primary)
|
||||
}
|
||||
.padding(.top, 16)
|
||||
} else if categories.isEmpty {
|
||||
Text("Cardápio indisponível no momento.")
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
.padding(.top, 16)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 120)
|
||||
} else {
|
||||
ForEach(categories, id: \.id) { category in
|
||||
Section {
|
||||
VStack(spacing: 12) {
|
||||
ForEach(listItems(for: category)) { item in
|
||||
productCard(item, in: category)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 8)
|
||||
} header: {
|
||||
Text(category.name)
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.id(sectionAnchorId(for: category.id))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 8)
|
||||
.background(AppColors.backgroundLight)
|
||||
.background(
|
||||
GeometryReader { geometry in
|
||||
Color.clear.preference(
|
||||
key: CategoryHeaderOffsetPreferenceKey.self,
|
||||
value: [category.id: geometry.frame(in: .global).minY]
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
Color.clear.frame(height: 120)
|
||||
}
|
||||
}
|
||||
|
||||
private func categoryTabs(proxy: ScrollViewProxy, safeTop: CGFloat, isSticky: Bool) -> some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 10) {
|
||||
ForEach(categories, id: \.id) { category in
|
||||
let active = selectedCategoryId == category.id
|
||||
Button {
|
||||
selectedCategoryId = category.id
|
||||
isProgrammaticCategoryScroll = true
|
||||
withAnimation(.easeInOut(duration: 0.25)) {
|
||||
proxy.scrollTo(sectionAnchorId(for: category.id), anchor: .top)
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
|
||||
isProgrammaticCategoryScroll = false
|
||||
}
|
||||
} label: {
|
||||
Text(category.name)
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(active ? AppColors.textInverse : AppColors.textMuted)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 9)
|
||||
.background(active ? AppColors.primary : AppColors.surface)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 10)
|
||||
.padding(.top, isSticky ? categoryTabsPinnedInset(safeTop: safeTop) : 10)
|
||||
}
|
||||
.background(AppColors.backgroundLight)
|
||||
}
|
||||
|
||||
private func isCategoryTabsPinned(safeTop: CGFloat) -> Bool {
|
||||
scrollOffsetY >= categoryTabsPinThreshold(safeTop: safeTop)
|
||||
}
|
||||
|
||||
private func categoryTabsPinThreshold(safeTop: CGFloat) -> CGFloat {
|
||||
max(0, topSectionHeight + 10 - safeTop)
|
||||
}
|
||||
|
||||
private func categoryTabsPinnedInset(safeTop: CGFloat) -> CGFloat {
|
||||
max(8, safeTop - 44)
|
||||
}
|
||||
|
||||
private func productCard(_ item: StoreCatalogListItem, in category: StoreCatalogCategory) -> some View {
|
||||
let product = item.product
|
||||
let hasSelectableAddons = product.addonGroups.contains { $0.items.isEmpty == false }
|
||||
|
||||
return HStack(spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(item.title)
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
|
||||
if let description = item.description, description.isEmpty == false {
|
||||
Text(description)
|
||||
.font(.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
|
||||
Text(listPriceLabel(for: product, in: category))
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
AsyncStoreImage(imageURL: resolvedURL(item.imageURL))
|
||||
.frame(width: 92, height: 92)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||
|
||||
Button {
|
||||
guard isStoreOpen else {
|
||||
SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0)
|
||||
return
|
||||
}
|
||||
if item.isPizzaSummary, let pizzaCategoryId = item.pizzaCategoryId {
|
||||
requestOpenPizzaSheet(categoryId: pizzaCategoryId)
|
||||
return
|
||||
}
|
||||
if hasSelectableAddons == false {
|
||||
let basePrice = product.price ?? 0
|
||||
let item = CartItemState(
|
||||
id: "\(storeId)::\(product.id)::base",
|
||||
productId: product.id,
|
||||
storeId: storeId,
|
||||
name: product.name,
|
||||
imageURL: resolvedURL(product.image),
|
||||
quantity: 1,
|
||||
unitPrice: basePrice
|
||||
)
|
||||
requestAddToCart(item)
|
||||
} else {
|
||||
requestOpenProductSheet(product)
|
||||
}
|
||||
} label: {
|
||||
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: item)
|
||||
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)
|
||||
.appContentShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.frame(width: 30, height: 30)
|
||||
.disabled(isStoreOpen == false)
|
||||
.opacity(isStoreOpen ? 1 : 0.65)
|
||||
.offset(x: 7, y: 7)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||
.appContentShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||
.onTapGesture {
|
||||
guard isStoreOpen else {
|
||||
SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0)
|
||||
return
|
||||
}
|
||||
if item.isPizzaSummary, let pizzaCategoryId = item.pizzaCategoryId {
|
||||
requestOpenPizzaSheet(categoryId: pizzaCategoryId)
|
||||
return
|
||||
}
|
||||
guard hasSelectableAddons else { return }
|
||||
requestOpenProductSheet(product)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,205 +0,0 @@
|
||||
#if os(Android)
|
||||
import SwiftUI
|
||||
|
||||
struct UserProfileView: View {
|
||||
@Binding var appState: AppState
|
||||
|
||||
@State var name: String = ""
|
||||
@State var email: String = ""
|
||||
@State var phone: String = ""
|
||||
@State var profilePicture: String = ""
|
||||
@State var isSaving = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: 22) {
|
||||
avatarSection
|
||||
formSection
|
||||
saveButton
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 18)
|
||||
.padding(.bottom, UIDevice.bottomNotch + 24)
|
||||
}
|
||||
.background(AppColors.backgroundLight)
|
||||
.navigationTitle("Meu Perfil")
|
||||
.appInlineNavigationTitle()
|
||||
.onAppear {
|
||||
hydrateFromAppState()
|
||||
}
|
||||
}
|
||||
|
||||
private var avatarSection: some View {
|
||||
VStack(spacing: 12) {
|
||||
Circle()
|
||||
.fill(AppColors.brandSoft)
|
||||
.frame(width: 110, height: 110)
|
||||
.overlay {
|
||||
Text(initials)
|
||||
.font(.system(size: 32, weight: .bold))
|
||||
.foregroundStyle(AppColors.primary)
|
||||
}
|
||||
|
||||
Button("Remover") {
|
||||
profilePicture = ""
|
||||
}
|
||||
.font(AppTypography.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
.buttonStyle(.plain)
|
||||
.disabled(profilePicture.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
private var formSection: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
textFieldSection(title: "Nome", placeholder: "Seu nome completo", text: $name)
|
||||
|
||||
textFieldSection(title: "E-mail", placeholder: "seu@email.com", text: $email)
|
||||
.appNoAutoCap()
|
||||
|
||||
textFieldSection(title: "Telefone", placeholder: "(00) 00000-0000", text: $phone)
|
||||
.onChange(of: phone) { _, newValue in
|
||||
let masked = formatPhoneBR(displayPhoneDigits(newValue))
|
||||
if masked != newValue {
|
||||
phone = masked
|
||||
}
|
||||
}
|
||||
.appNoAutoCap()
|
||||
}
|
||||
.padding(16)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||
}
|
||||
|
||||
private var saveButton: some View {
|
||||
Button(isSaving ? "Salvando..." : "Salvar Alterações") {
|
||||
Task { await saveProfile() }
|
||||
}
|
||||
.font(AppTypography.button)
|
||||
.foregroundStyle(AppColors.textInverse)
|
||||
.frame(maxWidth: .infinity, minHeight: 56)
|
||||
.background(AppColors.primary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isSaving || canSave == false)
|
||||
.opacity((isSaving || canSave == false) ? 0.6 : 1.0)
|
||||
}
|
||||
|
||||
private var initials: String {
|
||||
let parts = name
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.split(separator: " ")
|
||||
.prefix(2)
|
||||
let letters = parts.compactMap { $0.first }.map(String.init).joined()
|
||||
return letters.isEmpty ? "PF" : letters.uppercased()
|
||||
}
|
||||
|
||||
private var canSave: Bool {
|
||||
let cleanName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let cleanEmail = email.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let normalizedPhone = normalizePhoneNumberForAPI(phone)
|
||||
return cleanName.isEmpty == false
|
||||
&& cleanEmail.isEmpty == false
|
||||
&& cleanEmail.contains("@")
|
||||
&& normalizedPhone.isEmpty == false
|
||||
}
|
||||
|
||||
private func hydrateFromAppState() {
|
||||
name = appState.profile.name
|
||||
email = appState.profile.email
|
||||
phone = formatPhoneForDisplay(appState.profile.phone)
|
||||
profilePicture = appState.profile.profilePicture
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func saveProfile() async {
|
||||
guard canSave else { return }
|
||||
|
||||
let cleanName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let cleanEmail = email.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let normalizedPhone = normalizePhoneNumberForAPI(phone)
|
||||
let cleanPhoto = profilePicture.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
isSaving = true
|
||||
defer { isSaving = false }
|
||||
|
||||
do {
|
||||
let response = try await ApiService().updateCustomerProfile(
|
||||
name: cleanName,
|
||||
email: cleanEmail,
|
||||
phoneNumber: normalizedPhone,
|
||||
profilePicture: cleanPhoto.isEmpty ? nil : cleanPhoto
|
||||
)
|
||||
|
||||
if response.error {
|
||||
SnackbarCenter.shared.show(
|
||||
title: response.message ?? "Não foi possível atualizar seu perfil.",
|
||||
style: .error,
|
||||
icon: "xmark.octagon.fill",
|
||||
duration: 3.0
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let customer = response.result
|
||||
appState.profile.id = customer?.id ?? appState.profile.id
|
||||
appState.profile.name = customer?.name ?? cleanName
|
||||
appState.profile.email = customer?.email ?? cleanEmail
|
||||
appState.profile.phone = customer?.phoneNumber ?? normalizedPhone
|
||||
appState.profile.profilePicture = customer?.profilePicture ?? cleanPhoto
|
||||
SessionStateStore.setActiveUserKey(
|
||||
SessionStateStore.makeUserKey(profileId: appState.profile.id, email: appState.profile.email)
|
||||
)
|
||||
|
||||
SnackbarCenter.shared.show(
|
||||
title: "Perfil atualizado com sucesso.",
|
||||
style: .success,
|
||||
icon: "checkmark.circle.fill",
|
||||
duration: 2.0
|
||||
)
|
||||
} catch {
|
||||
SnackbarCenter.shared.show(
|
||||
title: "Não foi possível atualizar seu perfil.",
|
||||
style: .error,
|
||||
icon: "xmark.octagon.fill",
|
||||
duration: 3.0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func formatPhoneForDisplay(_ raw: String) -> String {
|
||||
let digits = displayPhoneDigits(raw)
|
||||
if digits.isEmpty { return "" }
|
||||
return formatPhoneBR(digits)
|
||||
}
|
||||
|
||||
private func displayPhoneDigits(_ raw: String) -> String {
|
||||
var digits = raw.filter(\.isNumber)
|
||||
if digits.hasPrefix("55"), digits.count > 11 {
|
||||
digits = String(digits.dropFirst(2))
|
||||
}
|
||||
return String(digits.prefix(11))
|
||||
}
|
||||
|
||||
private func textFieldSection(title: String, placeholder: String, text: Binding<String>) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(title)
|
||||
.font(AppTypography.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
|
||||
TextField(placeholder, text: text)
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.padding(.horizontal, 12)
|
||||
.frame(height: 50)
|
||||
.background(AppColors.backgroundLight)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.stroke(AppColors.secondary.opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,9 +1,6 @@
|
||||
#if !os(Android)
|
||||
import SwiftUI
|
||||
#if canImport(PhotosUI) && os(iOS)
|
||||
#if os(iOS)
|
||||
import PhotosUI
|
||||
#endif
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
@@ -17,8 +14,8 @@ struct UserProfileView: View {
|
||||
@State var profilePicture: String = ""
|
||||
@State var isSaving = false
|
||||
|
||||
#if canImport(PhotosUI) && os(iOS)
|
||||
@State var selectedPhotoItem: PhotosPickerItem?
|
||||
#if os(iOS)
|
||||
@State private var selectedPhotoItem: PhotosPickerItem?
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
@@ -38,7 +35,7 @@ struct UserProfileView: View {
|
||||
.onAppear {
|
||||
hydrateFromAppState()
|
||||
}
|
||||
#if canImport(PhotosUI) && os(iOS)
|
||||
#if os(iOS)
|
||||
.onChange(of: selectedPhotoItem) { _, newItem in
|
||||
Task { await applySelectedPhoto(newItem) }
|
||||
}
|
||||
@@ -63,7 +60,7 @@ struct UserProfileView: View {
|
||||
}
|
||||
|
||||
HStack(spacing: 10) {
|
||||
#if canImport(PhotosUI) && os(iOS)
|
||||
#if os(iOS)
|
||||
PhotosPicker(selection: $selectedPhotoItem, matching: .images, photoLibrary: .shared()) {
|
||||
Text("Trocar Foto")
|
||||
.font(AppTypography.caption)
|
||||
@@ -244,19 +241,15 @@ struct UserProfileView: View {
|
||||
}
|
||||
}
|
||||
|
||||
#if canImport(PhotosUI) && os(iOS)
|
||||
#if os(iOS)
|
||||
@MainActor
|
||||
private func applySelectedPhoto(_ item: PhotosPickerItem?) async {
|
||||
guard let item else { return }
|
||||
do {
|
||||
guard let data = try await item.loadTransferable(type: Data.self) else { return }
|
||||
#if canImport(UIKit)
|
||||
guard let image = UIImage(data: data),
|
||||
let jpegData = image.jpegData(compressionQuality: 0.82) else { return }
|
||||
profilePicture = "data:image/jpeg;base64,\(jpegData.base64EncodedString())"
|
||||
#else
|
||||
profilePicture = "data:image/jpeg;base64,\(data.base64EncodedString())"
|
||||
#endif
|
||||
} catch {
|
||||
SnackbarCenter.shared.show(
|
||||
title: "Não foi possível carregar a foto selecionada.",
|
||||
@@ -268,4 +261,3 @@ struct UserProfileView: View {
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
Reference in New Issue
Block a user