Almost done

This commit is contained in:
Daniel Arantes Loverde
2026-06-08 10:46:52 -03:00
parent aec1193944
commit 23d9752bfd
25 changed files with 476 additions and 257 deletions

View File

@@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.969",
"green" : "0.961",
"red" : "0.953"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.102",
"green" : "0.102",
"red" : "0.102"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "placeholder-pizza.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 667 KiB

View File

@@ -162,8 +162,6 @@
}; };
/* End PBXResourcesBuildPhase section */ /* End PBXResourcesBuildPhase section */
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */
499CD43A2AC5B799001AE8D8 /* Sources */ = { 499CD43A2AC5B799001AE8D8 /* Sources */ = {
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
@@ -269,7 +267,7 @@
repositoryURL = "https://git.loverde.com.br/Loverde-Company-LTDA/LCEssentials.git"; repositoryURL = "https://git.loverde.com.br/Loverde-Company-LTDA/LCEssentials.git";
requirement = { requirement = {
kind = upToNextMajorVersion; kind = upToNextMajorVersion;
minimumVersion = 1.0.7; minimumVersion = 1.0.10;
}; };
}; };
/* End XCRemoteSwiftPackageReference section */ /* End XCRemoteSwiftPackageReference section */

View File

@@ -23,7 +23,7 @@
</imageView> </imageView>
</subviews> </subviews>
<viewLayoutGuide key="safeArea" id="Bcu-3y-fUS"/> <viewLayoutGuide key="safeArea" id="Bcu-3y-fUS"/>
<color key="backgroundColor" red="0.9529411765" green="0.9607843137" blue="0.9686274510" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> <color key="backgroundColor" name="LaunchBackground"/>
<constraints> <constraints>
<constraint firstItem="W6R-s6-hHG" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="F0M-uu-fA3"/> <constraint firstItem="W6R-s6-hHG" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="F0M-uu-fA3"/>
<constraint firstItem="W6R-s6-hHG" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="M3D-C3-C0c"/> <constraint firstItem="W6R-s6-hHG" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="M3D-C3-C0c"/>

View File

@@ -81,33 +81,11 @@
}, },
"0000 0000 0000 0000" : { "0000 0000 0000 0000" : {
},
"1. Tamanho" : {
"comment" : "A heading for the size selection step in the pizza product detail sheet.",
"isCommentAutoGenerated" : true
}, },
"1km" : { "1km" : {
"comment" : "A label for the minimum distance filter option.", "comment" : "A label for the minimum distance filter option.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"2. Massa" : {
},
"3. Borda" : {
},
"4. Sabores (%lld/%lld)" : {
"comment" : "A heading that describes the step where the user selects pizza flavors. The number in the parentheses indicates the maximum number of flavors that can be selected.",
"isCommentAutoGenerated" : true,
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "4. Sabores (%1$lld/%2$lld)"
}
}
}
},
"10km" : { "10km" : {
"comment" : "A label displayed next to the far end of the distance slider in the filters modal.", "comment" : "A label displayed next to the far end of the distance slider in the filters modal.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
@@ -185,6 +163,9 @@
}, },
"Ao menos um cartão deve permanecer" : { "Ao menos um cartão deve permanecer" : {
},
"Ao menos um endereço deve permanecer" : {
}, },
"Ao se cadastrar, você concorda com nossos [Termos de Uso](app://terms) e [Política de Privacidade](app://policy)" : { "Ao se cadastrar, você concorda com nossos [Termos de Uso](app://terms) e [Política de Privacidade](app://policy)" : {
"comment" : "A text that informs the user that registering implies acceptance of the app's terms of use and privacy policy. The text is clickable and navigates to the respective views when tapped.", "comment" : "A text that informs the user that registering implies acceptance of the app's terms of use and privacy policy. The text is clickable and navigates to the respective views when tapped.",
@@ -234,10 +215,6 @@
}, },
"Atualizando status do pedido..." : { "Atualizando status do pedido..." : {
},
"Atualize os dados do endereço abaixo." : {
"comment" : "A description below the fields in the \"Editar endereço\" form, instructing the user to update their address details.",
"isCommentAutoGenerated" : true
}, },
"Avaliar Pedido" : { "Avaliar Pedido" : {
@@ -382,6 +359,9 @@
"Cupom de Desconto" : { "Cupom de Desconto" : {
"comment" : "A section header for entering and applying a discount coupon.", "comment" : "A section header for entering and applying a discount coupon.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
},
"Cupons de Desconto" : {
}, },
"Dark" : { "Dark" : {
"comment" : "Menu item indicating that the appearance should be in dark mode", "comment" : "Menu item indicating that the appearance should be in dark mode",
@@ -951,10 +931,6 @@
"comment" : "A label describing the price filter section in the filters modal.", "comment" : "A label describing the price filter section in the filters modal.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Preencha os dados abaixo para adicionar um endereço." : {
"comment" : "A description below the form to add a new address, instructing the user to fill in the required information.",
"isCommentAutoGenerated" : true
},
"Preencha os dados abaixo para começar." : { "Preencha os dados abaixo para começar." : {
"comment" : "A description below the form fields in the registration view, instructing the user to fill them out to start.", "comment" : "A description below the form fields in the registration view, instructing the user to fill them out to start.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true

Binary file not shown.

After

Width:  |  Height:  |  Size: 667 KiB

View File

@@ -82,7 +82,6 @@ final class ApiClient {
} }
func send<T: Decodable>(_ request: ApiRequest) async throws -> T { func send<T: Decodable>(_ request: ApiRequest) async throws -> T {
#if canImport(LCEssentials) && os(iOS) #if canImport(LCEssentials) && os(iOS)
return try await sendWithLCEssentials(request) return try await sendWithLCEssentials(request)
#else #else
@@ -119,7 +118,7 @@ private extension ApiClient {
do { do {
return try JSONDecoder().decode(T.self, from: data) return try JSONDecoder().decode(T.self, from: data)
} catch { } catch {
throw NetworkError.decodeError(String(data: data, encoding: .utf8)) throw NetworkError.decodeError(sanitizedBody(data))
} }
} catch { } catch {
let mapped = mapError(error) let mapped = mapError(error)
@@ -156,6 +155,7 @@ private extension ApiClient {
case "POST": return .post case "POST": return .post
case "PUT": return .put case "PUT": return .put
case "DELETE": return .delete case "DELETE": return .delete
case "PATCH": return .patch
default: return .get default: return .get
} }
} }
@@ -274,7 +274,7 @@ private extension ApiClient {
do { do {
return try JSONDecoder().decode(type, from: data) return try JSONDecoder().decode(type, from: data)
} catch { } catch {
throw NetworkError.decodeError(String(data: data, encoding: .utf8)) throw NetworkError.decodeError(sanitizedBody(data))
} }
} }
@@ -328,7 +328,7 @@ private extension ApiClient {
return msg return msg
} }
} }
return String(data: data, encoding: .utf8) return sanitizedBody(data)
} }
func serverPayload(from data: Data) -> ApiErrorDescriptor? { func serverPayload(from data: Data) -> ApiErrorDescriptor? {
@@ -364,48 +364,41 @@ private extension ApiClient {
#if canImport(LCEssentials) && os(iOS) #if canImport(LCEssentials) && os(iOS)
func serverMessage(from error: NSError) -> String? { func serverMessage(from error: NSError) -> String? {
// Tenta buscar no userInfo por chaves conhecidas
for key in [NSLocalizedFailureReasonErrorKey, "body", "responseBody", "data", "message"] { for key in [NSLocalizedFailureReasonErrorKey, "body", "responseBody", "data", "message"] {
if let value = error.userInfo[key] as? String, if let value = error.userInfo[key] as? String,
!value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
if let data = value.data(using: .utf8), if let data = value.data(using: .utf8),
let parsed = serverMessage(from: data), let parsed = serverMessage(from: data),
!parsed.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { !parsed.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
if parsed.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("{") { let t = parsed.trimmingCharacters(in: .whitespacesAndNewlines)
continue if t.hasPrefix("{") { continue }
} if let safe = sanitizedMessage(t) { return safe }
return parsed
}
if !value.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("{") {
return value
} }
let t = value.trimmingCharacters(in: .whitespacesAndNewlines)
if !t.hasPrefix("{"), let safe = sanitizedMessage(t) { return safe }
} else if let valueData = error.userInfo[key] as? Data { } else if let valueData = error.userInfo[key] as? Data {
if let parsed = serverMessage(from: valueData), if let parsed = serverMessage(from: valueData) {
!parsed.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, let t = parsed.trimmingCharacters(in: .whitespacesAndNewlines)
!parsed.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("{") { if !t.hasPrefix("{"), let safe = sanitizedMessage(t) { return safe }
return parsed
} }
} }
} }
// Tenta varrer qualquer chave do userInfo por strings que possam ser JSON ou mensagens
for (_, value) in error.userInfo { for (_, value) in error.userInfo {
if let str = value as? String, !str.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { if let str = value as? String,
if str.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("{") { str.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("{"),
if let data = str.data(using: .utf8), let data = str.data(using: .utf8),
let parsed = serverMessage(from: data), let parsed = serverMessage(from: data) {
!parsed.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, let t = parsed.trimmingCharacters(in: .whitespacesAndNewlines)
!parsed.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("{") { if !t.hasPrefix("{"), let safe = sanitizedMessage(t) { return safe }
return parsed
}
}
} }
} }
if let description = error.userInfo[NSLocalizedDescriptionKey] as? String, if let description = error.userInfo[NSLocalizedDescriptionKey] as? String,
!description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, !description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
!description.lowercased().contains("nsurlerrordomain") { !description.lowercased().contains("nsurlerrordomain"),
return description let safe = sanitizedMessage(description) {
return safe
} }
return nil return nil
@@ -435,6 +428,25 @@ private extension ApiClient {
} }
#endif #endif
func sanitizedBody(_ data: Data) -> String? {
guard let raw = String(data: data, encoding: .utf8) else { return nil }
return sanitizedMessage(raw)
}
func sanitizedMessage(_ raw: String) -> String? {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.isEmpty == false else { return nil }
// Drop data URLs (base64 images)
if trimmed.lowercased().hasPrefix("data:image") { return "Erro ao processar imagem." }
// Drop fields containing base64,
if trimmed.contains("base64,") { return "Resposta do servidor inválida." }
// Truncate long strings (raw JSON bodies, etc.)
if trimmed.count > 300 {
return String(trimmed.prefix(300)) + ""
}
return trimmed
}
func buildURL(path: String, query: [URLQueryItem]) throws -> URL { func buildURL(path: String, query: [URLQueryItem]) throws -> URL {
guard var components = URLComponents(url: ApiConfig.baseURL, resolvingAgainstBaseURL: false) else { guard var components = URLComponents(url: ApiConfig.baseURL, resolvingAgainstBaseURL: false) else {
throw NetworkError.invalidURL throw NetworkError.invalidURL

View File

@@ -53,18 +53,23 @@ struct CustomerAddress: Decodable {
let state: String? let state: String?
let zipCode: String? let zipCode: String?
let latLong: [Double]? let latLong: [Double]?
let isDefault: Bool?
init(id: String?, label: String?, address: String?, number: String?,
complement: String?, neighborhood: String?, city: String?,
state: String?, zipCode: String?, latLong: [Double]?, isDefault: Bool?) {
self.id = id; self.label = label; self.address = address
self.number = number; self.complement = complement
self.neighborhood = neighborhood; self.city = city
self.state = state; self.zipCode = zipCode
self.latLong = latLong; self.isDefault = isDefault
}
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case id case id, label, address, number, complement
case label case neighborhood, city, state, zipCode
case address
case number
case complement
case neighborhood
case city
case state
case zipCode
case latLong = "lat_long" case latLong = "lat_long"
case isDefault
} }
} }

View File

@@ -28,6 +28,7 @@ struct CreateOrderItemPayload: Codable {
let qty: Int let qty: Int
let price: Double let price: Double
let addons: [CreateOrderAddonPayload] let addons: [CreateOrderAddonPayload]
let choices: [String]?
} }
struct CreateOrderAddonPayload: Codable { struct CreateOrderAddonPayload: Codable {

View File

@@ -212,6 +212,13 @@ final class ApiService {
return try await updateCustomerAddressBook(customerId: customer.id, addressBook: updatedAddressBook) return try await updateCustomerAddressBook(customerId: customer.id, addressBook: updatedAddressBook)
} }
func setDefaultAddress(addressId: String) async throws -> ApiEnvelope<EmptyResult> {
let req = ApiRequest(path: "/api/customer/addresses/\(addressId)/default", method: "PATCH", module: .customer, requiresAuth: true)
let envelope: ApiEnvelope<EmptyResult> = try await sendEnvelope(req)
AppContentCache.shared.invalidate(prefix: profileCachePrefix)
return envelope
}
func lookupZipCode(_ zipCode: String) async throws -> ApiEnvelope<CepLookupResult> { func lookupZipCode(_ zipCode: String) async throws -> ApiEnvelope<CepLookupResult> {
let digits = zipCode.filter(\.isNumber) let digits = zipCode.filter(\.isNumber)
let normalized = String(digits.prefix(8)) let normalized = String(digits.prefix(8))

View File

@@ -258,7 +258,7 @@ final class FeatureControlService {
} }
private func featureKeys() -> [String] { private func featureKeys() -> [String] {
let raw = ProcessInfo.processInfo.environment["FEATURE_CONTROL_KEYS"] ?? "at.city.aguai,at.promo,at.ios.only,at.android.only" let raw = ProcessInfo.processInfo.environment["FEATURE_CONTROL_KEYS"] ?? "at.city.aguai,at.promo,at.ios.only,at.android.only,at.cupons"
let items = raw let items = raw
.split(separator: ",") .split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }

View File

@@ -173,6 +173,7 @@ struct CartItemState: Identifiable {
var name: String var name: String
var imageURL: String? = nil var imageURL: String? = nil
var details: String? = nil var details: String? = nil
var choices: [String]? = nil
var addons: [CartItemAddonState] = [] var addons: [CartItemAddonState] = []
var quantity: Int var quantity: Int
var unitPrice: Double var unitPrice: Double
@@ -276,7 +277,8 @@ extension CartState {
qty: $0.quantity, qty: $0.quantity,
price: $0.unitPrice price: $0.unitPrice
) )
} },
choices: item.choices?.isEmpty == false ? item.choices : nil
) )
} }
} }

View File

@@ -2,7 +2,6 @@ import SwiftUI
struct AddAddressFormView: View { struct AddAddressFormView: View {
@Environment(\.dismiss) var dismiss @Environment(\.dismiss) var dismiss
@Environment(\.colorScheme) var colorScheme
@State var label = "" @State var label = ""
@State var zipCode = "" @State var zipCode = ""
@State var address = "" @State var address = ""
@@ -31,37 +30,17 @@ struct AddAddressFormView: View {
!state.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty !state.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
} }
@ViewBuilder private var logoImage: some View {
SwiftUI.Image("pedifoods")
.resizable()
}
var body: some View { var body: some View {
ZStack { ZStack {
(colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea() AppColors.backgroundLight.ignoresSafeArea()
ScrollView { ScrollView(showsIndicators: false) {
VStack(spacing: 0) { VStack(spacing: 0) {
screenHeader( screenHeader(
title: existingAddress == nil ? "Novo endereço" : "Editar endereço", title: existingAddress == nil ? "Novo endereço" : "Editar endereço",
onBack: { dismiss() } onBack: { dismiss() }
) )
.padding(.horizontal, 24) .padding(.horizontal, 20)
.padding(.bottom, 12)
logoImage
.scaledToFit()
.frame(width: 120, height: 120)
Text(existingAddress == nil ? "Novo endereço" : "Editar endereço")
.font(AppTypography.heading1)
.foregroundStyle(colorScheme == .dark ? Color.white : AppColors.textPrimary)
Text(existingAddress == nil ? "Preencha os dados abaixo para adicionar um endereço." : "Atualize os dados do endereço abaixo.")
.font(AppTypography.body)
.foregroundStyle(colorScheme == .dark ? Color.white : AppColors.textPrimary)
.multilineTextAlignment(.center)
.padding(.top, 8)
.padding(.bottom, 20) .padding(.bottom, 20)
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
@@ -127,7 +106,7 @@ struct AddAddressFormView: View {
Spacer().frame(height: 120) Spacer().frame(height: 120)
} }
} }
.padding(.top, 0) .padding(.top, 18)
} }
.navigationBarBackButtonHidden(true) .navigationBarBackButtonHidden(true)
.appHiddenNavigationBar() .appHiddenNavigationBar()
@@ -140,8 +119,7 @@ struct AddAddressFormView: View {
ZStack { ZStack {
Text(title) Text(title)
.font(AppTypography.heading2) .font(AppTypography.heading2)
.foregroundStyle(colorScheme == .dark ? Color.white : AppColors.textPrimary) .foregroundStyle(AppColors.textPrimary)
HStack { HStack {
Button(action: onBack) { Button(action: onBack) {
Image(systemName: "chevron.left") Image(systemName: "chevron.left")
@@ -177,7 +155,8 @@ struct AddAddressFormView: View {
city: clean(city), city: clean(city),
state: clean(state), state: clean(state),
zipCode: optional(normalizeZipCodeForAPI(zipCode)), zipCode: optional(normalizeZipCodeForAPI(zipCode)),
latLong: latLong latLong: latLong,
isDefault: existingAddress?.isDefault
) )
isLoading = true isLoading = true
zipLookupMessage = nil zipLookupMessage = nil

View File

@@ -118,6 +118,9 @@ struct AddressCard: View {
let item: AddressListItem let item: AddressListItem
var onEdit: (() -> Void)? = nil var onEdit: (() -> Void)? = nil
var onDelete: (() -> Void)? = nil var onDelete: (() -> Void)? = nil
var onSetDefault: (() -> Void)? = nil
private var hasActions: Bool { onEdit != nil || onSetDefault != nil }
var body: some View { var body: some View {
HStack(spacing: 14) { HStack(spacing: 14) {
@@ -150,30 +153,30 @@ struct AddressCard: View {
Spacer(minLength: 8) Spacer(minLength: 8)
if onEdit != nil || onDelete != nil { if hasActions {
Rectangle() Rectangle()
.fill(AppColors.backgroundLight) .fill(AppColors.backgroundLight)
.frame(width: 1, height: 96) .frame(width: 1, height: 96)
} }
VStack(spacing: 24) { VStack(spacing: 20) {
if let onEdit { if let onEdit {
Button(action: onEdit) { Button(action: onEdit) {
Image(systemName: "pencil") Image(systemName: "pencil")
.font(.system(size: 22)) .font(.system(size: 20))
.foregroundStyle(AppColors.textMuted) .foregroundStyle(AppColors.textMuted)
} }
} }
if let onDelete { if let onSetDefault, item.isPrimary == false {
Button(action: onDelete) { Button(action: onSetDefault) {
Image(systemName: "trash") Image(systemName: "star")
.font(.system(size: 22)) .font(.system(size: 20))
.foregroundStyle(AppColors.textMuted) .foregroundStyle(AppColors.textMuted)
} }
} }
} }
.frame(width: onEdit != nil || onDelete != nil ? 40 : 0) .frame(width: hasActions ? 40 : 0)
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 20) .padding(.vertical, 20)

View File

@@ -12,6 +12,7 @@ struct AddressesView: View {
@State var editingAddress: CustomerAddress? = nil @State var editingAddress: CustomerAddress? = nil
@State var openSwipeRowId: String? = nil @State var openSwipeRowId: String? = nil
@State var deletingRowId: String? = nil @State var deletingRowId: String? = nil
@State var settingDefaultRowId: String? = nil
let tabBarClearance: CGFloat = 96 let tabBarClearance: CGFloat = 96
@@ -54,7 +55,9 @@ struct AddressesView: View {
} else { } else {
ForEach(Array(addresses.enumerated()), id: \.offset) { index, address in ForEach(Array(addresses.enumerated()), id: \.offset) { index, address in
let rowId = addressRowId(for: address, index: index) let rowId = addressRowId(for: address, index: index)
let isSelected: Bool = { let isPrimary: Bool = {
if address.isDefault == true { return true }
if addresses.contains(where: { $0.isDefault == true }) { return false }
if let selectedId = appState.address.selectedId { if let selectedId = appState.address.selectedId {
return address.id == selectedId return address.id == selectedId
} }
@@ -65,10 +68,10 @@ struct AddressesView: View {
Button { Button {
selectAddress(address) selectAddress(address)
} label: { } label: {
AddressCard(item: addressToListItem(address, isPrimary: isSelected)) AddressCard(item: addressToListItem(address, isPrimary: isPrimary))
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} else { } else if addresses.count > 1 {
SwipeToDeleteAddressRow( SwipeToDeleteAddressRow(
rowId: rowId, rowId: rowId,
openRowId: $openSwipeRowId, openRowId: $openSwipeRowId,
@@ -76,19 +79,30 @@ struct AddressesView: View {
onDelete: { deleteAddress(address, rowId: rowId) } onDelete: { deleteAddress(address, rowId: rowId) }
) { ) {
AddressCard( AddressCard(
item: addressToListItem(address, isPrimary: isSelected), item: addressToListItem(address, isPrimary: isPrimary),
onEdit: { beginEditing(address) } onEdit: { beginEditing(address) },
onSetDefault: { setDefaultAddress(address, rowId: rowId) }
) )
.appContentShape(Rectangle()) .appContentShape(Rectangle())
.onTapGesture { .simultaneousGesture(TapGesture().onEnded {
if openSwipeRowId == rowId { if openSwipeRowId == rowId { openSwipeRowId = nil }
openSwipeRowId = nil })
} .opacity(settingDefaultRowId == rowId ? 0.6 : 1.0)
}
} }
.id(rowId) .id(rowId)
.opacity(deletingRowId == rowId ? 0.6 : 1.0) .opacity(deletingRowId == rowId ? 0.6 : 1.0)
.disabled(deletingRowId != nil) .disabled(deletingRowId != nil || settingDefaultRowId != nil)
} else {
AddressCard(
item: addressToListItem(address, isPrimary: isPrimary),
onEdit: { beginEditing(address) }
)
.overlay(alignment: .bottom) {
Text("Ao menos um endereço deve permanecer")
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted.opacity(0.7))
.padding(.bottom, 8)
}
} }
} }
} }
@@ -230,8 +244,65 @@ struct AddressesView: View {
return "idx:\(index):\(address.label ?? ""):\(address.address ?? ""):\(address.number ?? "")" return "idx:\(index):\(address.label ?? ""):\(address.address ?? ""):\(address.number ?? "")"
} }
private func setDefaultAddress(_ address: CustomerAddress, rowId: String) {
guard settingDefaultRowId == nil else { return }
settingDefaultRowId = rowId
openSwipeRowId = nil
Task {
var resolvedId = address.id?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if resolvedId.isEmpty {
if let book = try? await ApiService().profile(forceRefresh: true).result?.addressBook {
await MainActor.run { addresses = book }
resolvedId = book.first {
$0.address == address.address &&
$0.number == address.number &&
$0.zipCode == address.zipCode
}?.id?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
}
}
guard resolvedId.isEmpty == false else {
await MainActor.run {
settingDefaultRowId = nil
SnackbarCenter.shared.show(title: "Não foi possível identificar o endereço.", style: .error, icon: "xmark.octagon.fill", duration: 3.5)
}
return
}
do {
let response = try await ApiService().setDefaultAddress(addressId: resolvedId)
await MainActor.run {
settingDefaultRowId = nil
if response.error == false {
addresses = addresses.map { addr in
let isTarget = (addr.id ?? "") == resolvedId
return CustomerAddress(
id: addr.id, label: addr.label, address: addr.address,
number: addr.number, complement: addr.complement,
neighborhood: addr.neighborhood, city: addr.city,
state: addr.state, zipCode: addr.zipCode,
latLong: addr.latLong, isDefault: isTarget
)
}
selectAddress(address)
SnackbarCenter.shared.show(title: "Endereço principal atualizado.", style: .success, icon: "star.fill", duration: 2.5)
} else {
SnackbarCenter.shared.show(title: response.message ?? "Não foi possível definir endereço principal.", style: .error, icon: "xmark.octagon.fill", duration: 3.0)
}
}
} catch {
await MainActor.run {
settingDefaultRowId = nil
SnackbarCenter.shared.show(title: "Erro ao atualizar endereço principal.", style: .error, icon: "xmark.octagon.fill", duration: 3.0)
}
}
}
}
private func deleteAddress(_ address: CustomerAddress, rowId: String) { private func deleteAddress(_ address: CustomerAddress, rowId: String) {
guard deletingRowId == nil else { return } guard addresses.count > 1, deletingRowId == nil else { return }
deletingRowId = rowId deletingRowId = rowId
openSwipeRowId = nil openSwipeRowId = nil
@@ -302,7 +373,7 @@ struct AddressesView: View {
do { do {
let service = ApiService() let service = ApiService()
let response = try await service.profile() let response = try await service.profile(forceRefresh: true)
guard response.error == false else { guard response.error == false else {
errorMessage = response.message ?? "Não foi possível carregar os endereços." errorMessage = response.message ?? "Não foi possível carregar os endereços."
isLoading = false isLoading = false

View File

@@ -333,16 +333,6 @@ extension CheckoutView {
) )
} }
@MainActor
func loadSavedCards() async {
do {
let response = try await ApiService().listCards()
if response.error == false, let cards = response.result {
savedCards = cards
}
} catch {}
}
@MainActor @MainActor
func confirmOrderWithSavedCard(cardId: String, storeId: String) async { func confirmOrderWithSavedCard(cardId: String, storeId: String) async {
let payloadResult = buildCreateOrderPayload(paymentMethod: .creditCard, savedCardId: cardId) let payloadResult = buildCreateOrderPayload(paymentMethod: .creditCard, savedCardId: cardId)

View File

@@ -28,7 +28,6 @@ struct CheckoutView: View {
@State var pixPaymentContext: PixPaymentContext? = nil @State var pixPaymentContext: PixPaymentContext? = nil
@State var cardPaymentContext: CardPaymentContext? = nil @State var cardPaymentContext: CardPaymentContext? = nil
@State var orderTrackingContext: OrderTrackingContext? = nil @State var orderTrackingContext: OrderTrackingContext? = nil
@State var savedCards: [SavedCard] = []
@State var showCardSelectionSheet = false @State var showCardSelectionSheet = false
var paymentConfig: StorePaymentMethodsInfo? { storeInfo?.paymentMethods } var paymentConfig: StorePaymentMethodsInfo? { storeInfo?.paymentMethods }
@@ -191,7 +190,6 @@ struct CheckoutView: View {
items: appState.cart.toOrderItemsPayload() items: appState.cart.toOrderItemsPayload()
) )
CardSelectionSheet( CardSelectionSheet(
savedCards: savedCards,
cardContext: cardContext, cardContext: cardContext,
appState: $appState, appState: $appState,
onSavedCardConfirmed: { cardId in onSavedCardConfirmed: { cardId in
@@ -209,11 +207,6 @@ struct CheckoutView: View {
} }
) )
} }
.task(id: paymentMethod) {
if paymentMethod == .creditCard && useInAppPayment {
await loadSavedCards()
}
}
.navigationDestination(item: $orderTrackingContext) { context in .navigationDestination(item: $orderTrackingContext) { context in
OrderTrackingView(orderId: context.orderId, initialShortId: context.shortId, postOrderBack: { OrderTrackingView(orderId: context.orderId, initialShortId: context.shortId, postOrderBack: {
appState.shouldNavigateToOrders = true appState.shouldNavigateToOrders = true
@@ -957,24 +950,23 @@ struct PaymentPixView: View {
// MARK: - CardSelectionSheet // MARK: - CardSelectionSheet
struct CardSelectionSheet: View { struct CardSelectionSheet: View {
let savedCards: [SavedCard]
let cardContext: CardPaymentContext let cardContext: CardPaymentContext
@Binding var appState: AppState @Binding var appState: AppState
let onSavedCardConfirmed: (String) -> Void let onSavedCardConfirmed: (String) -> Void
let onOrderCreated: (String, String?) -> Void let onOrderCreated: (String, String?) -> Void
@Environment(\.dismiss) var dismiss @Environment(\.dismiss) var dismiss
@State private var cards: [SavedCard] = []
@State private var isLoading = false
@State var selectedCardId: String? @State var selectedCardId: String?
@State var isSubmitting = false @State var isSubmitting = false
@State var showNewCardSheet = false @State var showNewCardSheet = false
init(savedCards: [SavedCard], cardContext: CardPaymentContext, appState: Binding<AppState>, onSavedCardConfirmed: @escaping (String) -> Void, onOrderCreated: @escaping (String, String?) -> Void) { init(cardContext: CardPaymentContext, appState: Binding<AppState>, onSavedCardConfirmed: @escaping (String) -> Void, onOrderCreated: @escaping (String, String?) -> Void) {
self.savedCards = savedCards
self.cardContext = cardContext self.cardContext = cardContext
self._appState = appState self._appState = appState
self.onSavedCardConfirmed = onSavedCardConfirmed self.onSavedCardConfirmed = onSavedCardConfirmed
self.onOrderCreated = onOrderCreated self.onOrderCreated = onOrderCreated
_selectedCardId = State(initialValue: savedCards.first(where: { $0.isDefault })?.id ?? savedCards.first?.id)
} }
var body: some View { var body: some View {
@@ -1004,11 +996,15 @@ struct CardSelectionSheet: View {
ScrollView(showsIndicators: false) { ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
if savedCards.isEmpty == false { if isLoading {
ProgressView()
.frame(maxWidth: .infinity)
.padding(.top, 32)
} else if cards.isEmpty == false {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
ForEach(savedCards) { card in ForEach(cards) { card in
savedCardRow(card) savedCardRow(card)
if card.id != savedCards.last?.id { if card.id != cards.last?.id {
Divider().padding(.horizontal, 14) Divider().padding(.horizontal, 14)
} }
} }
@@ -1066,6 +1062,17 @@ struct CardSelectionSheet: View {
SnackbarOverlay(center: SnackbarCenter.shared) SnackbarOverlay(center: SnackbarCenter.shared)
} }
.task { await fetchCards() }
}
@MainActor
private func fetchCards() async {
isLoading = true
defer { isLoading = false }
if let result = try? await ApiService().listCards(), result.error == false, let fetched = result.result {
cards = fetched
selectedCardId = fetched.first(where: { $0.isDefault })?.id ?? fetched.first?.id
}
} }
private func savedCardRow(_ card: SavedCard) -> some View { private func savedCardRow(_ card: SavedCard) -> some View {

View File

@@ -2,28 +2,39 @@ import Foundation
import SwiftUI import SwiftUI
extension PizzaProductDetailSheet { extension PizzaProductDetailSheet {
var stepSizes: some View {
let sizeItems = sizes
return VStack(alignment: .leading, spacing: 10) {
Text("1. Tamanho")
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
ForEach(0..<sizeItems.count, id: \.self) { index in // MARK: - Steps
sizeRow(sizeItems[index])
var stepSizes: some View {
accordionSection(
step: 0,
label: "Tamanho",
summary: selectedSize.map { "\($0.name ?? "") • Até \(max(1, $0.maxFlavors ?? 1)) sabor(es)" }
) {
ForEach(sizes) { size in
radioRow(
title: size.name ?? "Tamanho",
subtitle: "Até \(max(1, size.maxFlavors ?? 1)) sabor(es)",
isSelected: selectedSizeId == size.id
) {
selectedSizeId = size.id
applyAutoSelections()
withAnimation(.easeInOut(duration: 0.2)) {
expandedStep = nextStep(after: 0)
}
}
} }
} }
.padding(12)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
} }
var stepDoughs: some View { var stepDoughs: some View {
VStack(alignment: .leading, spacing: 10) { accordionSection(
Text("2. Massa") step: 1,
.font(AppTypography.heading3) label: "Massa",
.foregroundStyle(AppColors.textPrimary) summary: doughs.count <= 1
? (doughs.first?.name ?? "Tradicional")
: doughs.first(where: { $0.id == selectedDoughId })?.name
) {
if doughs.count <= 1 { if doughs.count <= 1 {
Text(doughs.first?.name ?? "Massa tradicional") Text(doughs.first?.name ?? "Massa tradicional")
.font(AppTypography.body) .font(AppTypography.body)
@@ -36,21 +47,24 @@ extension PizzaProductDetailSheet {
isSelected: selectedDoughId == dough.id isSelected: selectedDoughId == dough.id
) { ) {
selectedDoughId = dough.id selectedDoughId = dough.id
applyAutoSelections()
withAnimation(.easeInOut(duration: 0.2)) {
expandedStep = nextStep(after: 1)
}
} }
} }
} }
} }
.padding(12)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
} }
var stepCrusts: some View { var stepCrusts: some View {
VStack(alignment: .leading, spacing: 10) { accordionSection(
Text("3. Borda") step: 2,
.font(AppTypography.heading3) label: "Borda",
.foregroundStyle(AppColors.textPrimary) summary: crusts.count <= 1
? crustDescription(crusts.first)
: crusts.first(where: { $0.id == selectedCrustId }).map { crustDescription($0) }
) {
if crusts.count <= 1 { if crusts.count <= 1 {
Text(crustDescription(crusts.first)) Text(crustDescription(crusts.first))
.font(AppTypography.body) .font(AppTypography.body)
@@ -59,38 +73,40 @@ extension PizzaProductDetailSheet {
ForEach(crusts) { crust in ForEach(crusts) { crust in
radioRow( radioRow(
title: crust.name ?? "Borda", title: crust.name ?? "Borda",
subtitle: crust.priceModifier ?? 0 > 0 ? "+ \(formatCurrency(crust.priceModifier ?? 0))" : nil, subtitle: (crust.priceModifier ?? 0) > 0 ? "+ \(formatCurrency(crust.priceModifier ?? 0))" : nil,
isSelected: selectedCrustId == crust.id isSelected: selectedCrustId == crust.id
) { ) {
selectedCrustId = crust.id selectedCrustId = crust.id
withAnimation(.easeInOut(duration: 0.2)) {
expandedStep = 3
}
} }
} }
} }
} }
.padding(12)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
} }
var stepFlavors: some View { var stepFlavors: some View {
let flavorItems = flavors accordionSection(
return VStack(alignment: .leading, spacing: 10) { step: 3,
Text("4. Sabores (\(selectedFlavorIds.count)/\(maxFlavorsAllowed))") label: "Sabores (\(selectedFlavorIds.count)/\(maxFlavorsAllowed))",
.font(AppTypography.heading3) summary: selectedFlavorIds.isEmpty ? nil
.foregroundStyle(AppColors.textPrimary) : selectedFlavorProducts.map(\.name).joined(separator: ", ")
) {
Text("Toque no sabor para escolher adicionais.") Text("Toque no sabor para escolher adicionais.")
.font(.caption) .font(.caption)
.foregroundStyle(AppColors.textMuted) .foregroundStyle(AppColors.textMuted)
ForEach(0..<flavorItems.count, id: \.self) { index in ForEach(flavors) { flavor in
let flavor = flavorItems[index]
let isSelected = selectedFlavorIds.contains(flavor.id) let isSelected = selectedFlavorIds.contains(flavor.id)
let price = selectedSizeId.flatMap { flavor.pizzaPrices[$0] } ?? flavor.price let price = selectedSizeId.flatMap { flavor.pizzaPrices[$0] } ?? flavor.price
let maxReached = selectedFlavorIds.count >= maxFlavorsAllowed let maxReached = selectedFlavorIds.count >= maxFlavorsAllowed
let disableSwitch = isSelected == false && maxReached
HStack(spacing: 10) { HStack(spacing: 10) {
AsyncStoreImage(imageURL: resolveImageURL(flavor.image))
.frame(width: 52, height: 52)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(flavor.name) Text(flavor.name)
.font(AppTypography.body) .font(AppTypography.body)
@@ -101,21 +117,15 @@ extension PizzaProductDetailSheet {
.foregroundStyle(AppColors.textMuted) .foregroundStyle(AppColors.textMuted)
} }
} }
Spacer() Spacer()
Toggle("", isOn: Binding( Toggle("", isOn: Binding(
get: { isSelected }, get: { isSelected },
set: { value in set: { value in
if value { if value { addFlavor(flavor.id) } else { removeFlavor(flavor.id) }
addFlavor(flavor.id)
} else {
removeFlavor(flavor.id)
}
} }
)) ))
.labelsHidden() .labelsHidden()
.disabled(disableSwitch) .disabled(!isSelected && maxReached)
} }
.padding(.vertical, 2) .padding(.vertical, 2)
.appContentShape(Rectangle()) .appContentShape(Rectangle())
@@ -126,23 +136,90 @@ extension PizzaProductDetailSheet {
} }
} }
} }
.padding(12) }
// MARK: - Accordion container
func accordionSection(
step: Int,
label: String,
summary: String?,
@ViewBuilder content: () -> some View
) -> some View {
let isExpanded = expandedStep == step
let isDone = summary != nil
return VStack(spacing: 0) {
Button {
withAnimation(.easeInOut(duration: 0.2)) {
expandedStep = isExpanded ? -1 : step
}
} label: {
HStack(spacing: 10) {
ZStack {
Circle()
.fill(isDone || isExpanded ? AppColors.primary : AppColors.textMuted.opacity(0.25))
.frame(width: 26, height: 26)
if isDone && !isExpanded {
Image(systemName: "checkmark")
.font(.system(size: 11, weight: .bold))
.foregroundStyle(.white)
} else {
Text("\(step + 1)")
.font(.system(size: 12, weight: .bold))
.foregroundStyle(.white)
}
}
Text(label)
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
Spacer()
if let summary, !isExpanded {
Text(summary)
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted)
.lineLimit(1)
.truncationMode(.tail)
.frame(maxWidth: 140, alignment: .trailing)
}
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(AppColors.textMuted)
}
.padding(14)
}
.buttonStyle(.plain)
if isExpanded {
Divider().padding(.horizontal, 14)
VStack(alignment: .leading, spacing: 10) {
content()
}
.padding(14)
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
.background(AppColors.surface) .background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
} }
func sizeRow(_ size: StorePizzaSize) -> some View { // MARK: - Helpers
let title = size.name ?? "Tamanho"
let subtitle = "Até \(max(1, size.maxFlavors ?? 1)) sabor(es)"
let selected = selectedSizeId == size.id
return radioRow( func nextStep(after step: Int) -> Int {
title: title, if step == 0 {
subtitle: subtitle, if doughs.count > 1 { return 1 }
isSelected: selected if crusts.count > 1 { return 2 }
) { return 3
selectedSizeId = size.id
} }
if step == 1 {
if crusts.count > 1 { return 2 }
return 3
}
return 3
} }
func radioRow(title: String, subtitle: String?, isSelected: Bool, action: @escaping () -> Void) -> some View { func radioRow(title: String, subtitle: String?, isSelected: Bool, action: @escaping () -> Void) -> some View {
@@ -158,7 +235,6 @@ extension PizzaProductDetailSheet {
.frame(width: 10, height: 10) .frame(width: 10, height: 10)
} }
} }
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(title) Text(title)
.font(AppTypography.body) .font(AppTypography.body)
@@ -169,7 +245,6 @@ extension PizzaProductDetailSheet {
.foregroundStyle(AppColors.textMuted) .foregroundStyle(AppColors.textMuted)
} }
} }
Spacer() Spacer()
} }
.appContentShape(Rectangle()) .appContentShape(Rectangle())
@@ -181,40 +256,29 @@ extension PizzaProductDetailSheet {
guard let crust else { return "Sem borda especial" } guard let crust else { return "Sem borda especial" }
let name = crust.name ?? "Borda" let name = crust.name ?? "Borda"
let modifier = crust.priceModifier ?? 0 let modifier = crust.priceModifier ?? 0
if modifier > 0 { return modifier > 0 ? "\(name) (+ \(formatCurrency(modifier)))" : name
return "\(name) (+ \(formatCurrency(modifier)))"
}
return name
} }
func applyAutoSelections() { func applyAutoSelections() {
if selectedSizeId != nil { if selectedSizeId != nil {
if doughs.count == 1 { if doughs.count == 1 { selectedDoughId = doughs.first?.id }
selectedDoughId = doughs.first?.id else if doughs.isEmpty { selectedDoughId = "__none__" }
} else if doughs.isEmpty {
selectedDoughId = "__none__"
} }
}
if isDoughReady { if isDoughReady {
if crusts.count == 1 { if crusts.count == 1 { selectedCrustId = crusts.first?.id }
selectedCrustId = crusts.first?.id else if crusts.isEmpty { selectedCrustId = "__none__" }
} else if crusts.isEmpty {
selectedCrustId = "__none__"
}
} }
} }
func trimFlavorSelectionByLimit() { func trimFlavorSelectionByLimit() {
let limit = maxFlavorsAllowed let limit = maxFlavorsAllowed
guard selectedFlavorIds.count > limit else { return } guard selectedFlavorIds.count > limit else { return }
let sorted = selectedFlavorIds.sorted() selectedFlavorIds = Set(selectedFlavorIds.sorted().prefix(limit))
selectedFlavorIds = Set(sorted.prefix(limit))
} }
func addFlavor(_ flavorId: String) { func addFlavor(_ flavorId: String) {
if selectedFlavorIds.contains(flavorId) { return } guard !selectedFlavorIds.contains(flavorId),
if selectedFlavorIds.count >= maxFlavorsAllowed { return } selectedFlavorIds.count < maxFlavorsAllowed else { return }
selectedFlavorIds.insert(flavorId) selectedFlavorIds.insert(flavorId)
} }

View File

@@ -17,6 +17,7 @@ struct PizzaProductDetailSheet: View {
@State var flavorAddonQuantities: [String: [String: Int]] = [:] @State var flavorAddonQuantities: [String: [String: Int]] = [:]
@State var selectedFlavorForAddons: StoreCatalogProduct? = nil @State var selectedFlavorForAddons: StoreCatalogProduct? = nil
@State var quantity: Int = 1 @State var quantity: Int = 1
@State var expandedStep: Int = 0
var flavors: [StoreCatalogProduct] { var flavors: [StoreCatalogProduct] {
category.products category.products
@@ -45,7 +46,7 @@ struct PizzaProductDetailSheet: View {
return resolveImageURL(firstImage) return resolveImageURL(firstImage)
} }
private var selectedSize: StorePizzaSize? { var selectedSize: StorePizzaSize? {
guard let selectedSizeId else { return nil } guard let selectedSizeId else { return nil }
return sizes.first(where: { $0.id == selectedSizeId }) return sizes.first(where: { $0.id == selectedSizeId })
} }
@@ -76,7 +77,7 @@ struct PizzaProductDetailSheet: View {
isCrustReady isCrustReady
} }
private var selectedFlavorProducts: [StoreCatalogProduct] { var selectedFlavorProducts: [StoreCatalogProduct] {
flavors flavors
.filter { selectedFlavorIds.contains($0.id) } .filter { selectedFlavorIds.contains($0.id) }
.sorted { $0.name < $1.name } .sorted { $0.name < $1.name }
@@ -102,12 +103,12 @@ struct PizzaProductDetailSheet: View {
} }
private var basePizzaPrice: Double { private var basePizzaPrice: Double {
selectedFlavorProducts let prices = selectedFlavorProducts.map { flavor in
.map { flavor in
guard let selectedSizeId else { return flavor.price ?? 0 } guard let selectedSizeId else { return flavor.price ?? 0 }
return flavor.pizzaPrices[selectedSizeId] ?? flavor.price ?? 0 return flavor.pizzaPrices[selectedSizeId] ?? flavor.price ?? 0
} }
.max() ?? 0 guard prices.isEmpty == false else { return 0 }
return prices.reduce(0, +) / Double(prices.count)
} }
private var unitPrice: Double { private var unitPrice: Double {
@@ -179,6 +180,34 @@ struct PizzaProductDetailSheet: View {
return chunks.isEmpty ? nil : chunks.joined(separator: "") return chunks.isEmpty ? nil : chunks.joined(separator: "")
} }
private var pizzaChoices: [String] {
var choices: [String] = []
if let sizeName = selectedSize?.name {
let sizePrice = basePizzaPrice
if sizePrice > 0 {
choices.append("Tamanho: \(sizeName) (+\(formatCurrency(sizePrice)))")
} else {
choices.append("Tamanho: \(sizeName)")
}
}
if let crustName = selectedCrust?.name, crustName.isEmpty == false {
let mod = crustPriceModifier
if mod > 0 {
choices.append("Borda: \(crustName) (+\(formatCurrency(mod)))")
} else {
choices.append("Borda: \(crustName)")
}
}
if let doughName = selectedDoughName, doughName.isEmpty == false {
choices.append("Massa: \(doughName)")
}
let flavorCount = selectedFlavorProducts.count
for flavor in selectedFlavorProducts {
choices.append(flavorCount > 1 ? "Meio \(flavor.name)" : flavor.name)
}
return choices
}
private var addButtonTitle: String { private var addButtonTitle: String {
if canConfirm == false { if canConfirm == false {
return "Selecione as opções" return "Selecione as opções"
@@ -190,8 +219,16 @@ struct PizzaProductDetailSheet: View {
ScrollView(showsIndicators: false) { ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
screenHeader screenHeader
AsyncStoreImage(imageURL: representativeImage) Rectangle()
.fill(AppColors.brandSoft)
.frame(maxWidth: .infinity)
.frame(height: 220) .frame(height: 220)
.overlay(
Image("placeholder-pizza")
.resizable()
.scaledToFill()
.clipped()
)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous))
Text("Pizza de varios sabores") Text("Pizza de varios sabores")
@@ -207,18 +244,9 @@ struct PizzaProductDetailSheet: View {
.foregroundStyle(AppColors.primary) .foregroundStyle(AppColors.primary)
stepSizes stepSizes
if selectedSizeId != nil { stepDoughs }
if selectedSizeId != nil { if isDoughReady { stepCrusts }
stepDoughs if canShowFlavors { stepFlavors }
}
if isDoughReady {
stepCrusts
}
if canShowFlavors {
stepFlavors
}
} }
.padding(20) .padding(20)
.padding(.bottom, 90) .padding(.bottom, 90)
@@ -266,6 +294,7 @@ struct PizzaProductDetailSheet: View {
name: "Pizza de varios sabores", name: "Pizza de varios sabores",
imageURL: representativeImage, imageURL: representativeImage,
details: selectedDetailsText, details: selectedDetailsText,
choices: pizzaChoices.isEmpty ? nil : pizzaChoices,
addons: selectedAddonsPayload, addons: selectedAddonsPayload,
quantity: quantity, quantity: quantity,
unitPrice: unitPrice unitPrice: unitPrice

View File

@@ -56,12 +56,14 @@ struct ProfileView: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
// NavigationLink { if appState.featureFlags.isEnabled("at.cupons") {
// Text("Cupons de Desconto") NavigationLink {
// } label: { Text("Cupons de Desconto")
// ProfileMenuRow(icon: "ticket.fill", title: "Cupons de Desconto", badge: "NOVO") } label: {
// } ProfileMenuRow(icon: "ticket.fill", title: "Cupons de Desconto", badge: "NOVO")
// .buttonStyle(.plain) }
.buttonStyle(.plain)
}
// NavigationLink { // NavigationLink {
// Text("Ajuda") // Text("Ajuda")

View File

@@ -848,6 +848,7 @@ struct OrderReviewView: View {
.frame(height: 110) .frame(height: 110)
.padding(.horizontal, 8) .padding(.horizontal, 8)
.padding(.vertical, 8) .padding(.vertical, 8)
.foregroundStyle(AppColors.textPrimary)
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.background(AppColors.surface) .background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))

View File

@@ -47,6 +47,7 @@ struct ScrollOffsetReader: View {
struct AsyncStoreImage: View { struct AsyncStoreImage: View {
let imageURL: String? let imageURL: String?
var fallbackImageName: String = "placeholder-product"
var body: some View { var body: some View {
CachedRemoteImage(imageURL: imageURL) { CachedRemoteImage(imageURL: imageURL) {
@@ -58,7 +59,7 @@ struct AsyncStoreImage: View {
} }
private var fallback: some View { private var fallback: some View {
Image("placeholder-product") Image(fallbackImageName)
.resizable() .resizable()
.scaledToFill() .scaledToFill()
} }

View File

@@ -269,7 +269,15 @@ extension StoreDetailView {
Spacer() Spacer()
ZStack(alignment: .bottomTrailing) { ZStack(alignment: .bottomTrailing) {
Group {
if item.isPizzaSummary {
Image("placeholder-pizza")
.resizable()
.scaledToFill()
} else {
AsyncStoreImage(imageURL: resolvedURL(item.imageURL)) AsyncStoreImage(imageURL: resolvedURL(item.imageURL))
}
}
.frame(width: 92, height: 92) .frame(width: 92, height: 92)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))

View File

@@ -297,8 +297,9 @@ struct UserProfileView: View {
guard let item else { return } guard let item else { return }
do { do {
guard let data = try await item.loadTransferable(type: Data.self) else { return } guard let data = try await item.loadTransferable(type: Data.self) else { return }
guard let image = UIImage(data: data), guard let image = UIImage(data: data) else { return }
let jpegData = image.jpegData(compressionQuality: 0.82) else { return } let resized = resizedIfNeeded(image, maxSide: 600)
guard let jpegData = resized.jpegData(compressionQuality: 0.8) else { return }
profilePicture = "data:image/jpeg;base64,\(jpegData.base64EncodedString())" profilePicture = "data:image/jpeg;base64,\(jpegData.base64EncodedString())"
} catch { } catch {
SnackbarCenter.shared.show( SnackbarCenter.shared.show(
@@ -309,5 +310,17 @@ struct UserProfileView: View {
) )
} }
} }
private func resizedIfNeeded(_ image: UIImage, maxSide: CGFloat) -> UIImage {
let w = image.size.width
let h = image.size.height
guard w > maxSide || h > maxSide else { return image }
let scale = maxSide / max(w, h)
let newSize = CGSize(width: w * scale, height: h * scale)
let renderer = UIGraphicsImageRenderer(size: newSize)
return renderer.image { _ in
image.draw(in: CGRect(origin: .zero, size: newSize))
}
}
#endif #endif
} }