master merged

This commit is contained in:
Daniel Arantes Loverde
2026-06-03 14:52:27 -03:00
parent 980a106661
commit f51319b486
112 changed files with 1051 additions and 1139 deletions

View File

@@ -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]? = {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -81,6 +81,7 @@ struct PizzaFlavorAddonsSheet: View {
.appInlineNavigationTitle()
.appTopBarTrailingToolbar {
Button("Concluir") { dismiss() }
.buttonStyle(.plain)
.foregroundStyle(AppColors.primary)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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