Almost done
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
12
pedi-foods/Darwin/Assets.xcassets/placeholder-pizza.imageset/Contents.json
vendored
Normal file
12
pedi-foods/Darwin/Assets.xcassets/placeholder-pizza.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "placeholder-pizza.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
pedi-foods/Darwin/Assets.xcassets/placeholder-pizza.imageset/placeholder-pizza.png
vendored
Normal file
BIN
pedi-foods/Darwin/Assets.xcassets/placeholder-pizza.imageset/placeholder-pizza.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 667 KiB |
@@ -162,8 +162,6 @@
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
499CD43A2AC5B799001AE8D8 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
@@ -269,7 +267,7 @@
|
||||
repositoryURL = "https://git.loverde.com.br/Loverde-Company-LTDA/LCEssentials.git";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 1.0.7;
|
||||
minimumVersion = 1.0.10;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
</imageView>
|
||||
</subviews>
|
||||
<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>
|
||||
<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"/>
|
||||
|
||||
@@ -81,33 +81,11 @@
|
||||
},
|
||||
"0000 0000 0000 0000" : {
|
||||
|
||||
},
|
||||
"1. Tamanho" : {
|
||||
"comment" : "A heading for the size selection step in the pizza product detail sheet.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"1km" : {
|
||||
"comment" : "A label for the minimum distance filter option.",
|
||||
"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" : {
|
||||
"comment" : "A label displayed next to the far end of the distance slider in the filters modal.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@@ -185,6 +163,9 @@
|
||||
},
|
||||
"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)" : {
|
||||
"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..." : {
|
||||
|
||||
},
|
||||
"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" : {
|
||||
|
||||
@@ -382,6 +359,9 @@
|
||||
"Cupom de Desconto" : {
|
||||
"comment" : "A section header for entering and applying a discount coupon.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Cupons de Desconto" : {
|
||||
|
||||
},
|
||||
"Dark" : {
|
||||
"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.",
|
||||
"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." : {
|
||||
"comment" : "A description below the form fields in the registration view, instructing the user to fill them out to start.",
|
||||
"isCommentAutoGenerated" : true
|
||||
|
||||
BIN
pedi-foods/Sources/PediFoods/Resources/placeholder-pizza.png
Normal file
BIN
pedi-foods/Sources/PediFoods/Resources/placeholder-pizza.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 667 KiB |
@@ -82,7 +82,6 @@ final class ApiClient {
|
||||
}
|
||||
|
||||
func send<T: Decodable>(_ request: ApiRequest) async throws -> T {
|
||||
|
||||
#if canImport(LCEssentials) && os(iOS)
|
||||
return try await sendWithLCEssentials(request)
|
||||
#else
|
||||
@@ -119,7 +118,7 @@ private extension ApiClient {
|
||||
do {
|
||||
return try JSONDecoder().decode(T.self, from: data)
|
||||
} catch {
|
||||
throw NetworkError.decodeError(String(data: data, encoding: .utf8))
|
||||
throw NetworkError.decodeError(sanitizedBody(data))
|
||||
}
|
||||
} catch {
|
||||
let mapped = mapError(error)
|
||||
@@ -156,6 +155,7 @@ private extension ApiClient {
|
||||
case "POST": return .post
|
||||
case "PUT": return .put
|
||||
case "DELETE": return .delete
|
||||
case "PATCH": return .patch
|
||||
default: return .get
|
||||
}
|
||||
}
|
||||
@@ -274,7 +274,7 @@ private extension ApiClient {
|
||||
do {
|
||||
return try JSONDecoder().decode(type, from: data)
|
||||
} catch {
|
||||
throw NetworkError.decodeError(String(data: data, encoding: .utf8))
|
||||
throw NetworkError.decodeError(sanitizedBody(data))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,7 +328,7 @@ private extension ApiClient {
|
||||
return msg
|
||||
}
|
||||
}
|
||||
return String(data: data, encoding: .utf8)
|
||||
return sanitizedBody(data)
|
||||
}
|
||||
|
||||
func serverPayload(from data: Data) -> ApiErrorDescriptor? {
|
||||
@@ -364,48 +364,41 @@ private extension ApiClient {
|
||||
|
||||
#if canImport(LCEssentials) && os(iOS)
|
||||
func serverMessage(from error: NSError) -> String? {
|
||||
// Tenta buscar no userInfo por chaves conhecidas
|
||||
for key in [NSLocalizedFailureReasonErrorKey, "body", "responseBody", "data", "message"] {
|
||||
if let value = error.userInfo[key] as? String,
|
||||
!value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
if let data = value.data(using: .utf8),
|
||||
let parsed = serverMessage(from: data),
|
||||
!parsed.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
if parsed.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("{") {
|
||||
continue
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
if !value.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("{") {
|
||||
return value
|
||||
let t = parsed.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if t.hasPrefix("{") { continue }
|
||||
if let safe = sanitizedMessage(t) { return safe }
|
||||
}
|
||||
let t = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !t.hasPrefix("{"), let safe = sanitizedMessage(t) { return safe }
|
||||
} else if let valueData = error.userInfo[key] as? Data {
|
||||
if let parsed = serverMessage(from: valueData),
|
||||
!parsed.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||
!parsed.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("{") {
|
||||
return parsed
|
||||
if let parsed = serverMessage(from: valueData) {
|
||||
let t = parsed.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !t.hasPrefix("{"), let safe = sanitizedMessage(t) { return safe }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tenta varrer qualquer chave do userInfo por strings que possam ser JSON ou mensagens
|
||||
for (_, value) in error.userInfo {
|
||||
if let str = value as? String, !str.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
if str.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("{") {
|
||||
if let data = str.data(using: .utf8),
|
||||
let parsed = serverMessage(from: data),
|
||||
!parsed.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||
!parsed.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("{") {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
if let str = value as? String,
|
||||
str.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("{"),
|
||||
let data = str.data(using: .utf8),
|
||||
let parsed = serverMessage(from: data) {
|
||||
let t = parsed.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !t.hasPrefix("{"), let safe = sanitizedMessage(t) { return safe }
|
||||
}
|
||||
}
|
||||
|
||||
if let description = error.userInfo[NSLocalizedDescriptionKey] as? String,
|
||||
!description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||
!description.lowercased().contains("nsurlerrordomain") {
|
||||
return description
|
||||
!description.lowercased().contains("nsurlerrordomain"),
|
||||
let safe = sanitizedMessage(description) {
|
||||
return safe
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -435,6 +428,25 @@ private extension ApiClient {
|
||||
}
|
||||
#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 {
|
||||
guard var components = URLComponents(url: ApiConfig.baseURL, resolvingAgainstBaseURL: false) else {
|
||||
throw NetworkError.invalidURL
|
||||
|
||||
@@ -53,18 +53,23 @@ struct CustomerAddress: Decodable {
|
||||
let state: String?
|
||||
let zipCode: String?
|
||||
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 {
|
||||
case id
|
||||
case label
|
||||
case address
|
||||
case number
|
||||
case complement
|
||||
case neighborhood
|
||||
case city
|
||||
case state
|
||||
case zipCode
|
||||
case id, label, address, number, complement
|
||||
case neighborhood, city, state, zipCode
|
||||
case latLong = "lat_long"
|
||||
case isDefault
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ struct CreateOrderItemPayload: Codable {
|
||||
let qty: Int
|
||||
let price: Double
|
||||
let addons: [CreateOrderAddonPayload]
|
||||
let choices: [String]?
|
||||
}
|
||||
|
||||
struct CreateOrderAddonPayload: Codable {
|
||||
|
||||
@@ -212,6 +212,13 @@ final class ApiService {
|
||||
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> {
|
||||
let digits = zipCode.filter(\.isNumber)
|
||||
let normalized = String(digits.prefix(8))
|
||||
|
||||
@@ -258,7 +258,7 @@ final class FeatureControlService {
|
||||
}
|
||||
|
||||
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
|
||||
.split(separator: ",")
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
|
||||
@@ -173,6 +173,7 @@ struct CartItemState: Identifiable {
|
||||
var name: String
|
||||
var imageURL: String? = nil
|
||||
var details: String? = nil
|
||||
var choices: [String]? = nil
|
||||
var addons: [CartItemAddonState] = []
|
||||
var quantity: Int
|
||||
var unitPrice: Double
|
||||
@@ -276,7 +277,8 @@ extension CartState {
|
||||
qty: $0.quantity,
|
||||
price: $0.unitPrice
|
||||
)
|
||||
}
|
||||
},
|
||||
choices: item.choices?.isEmpty == false ? item.choices : nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import SwiftUI
|
||||
|
||||
struct AddAddressFormView: View {
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@State var label = ""
|
||||
@State var zipCode = ""
|
||||
@State var address = ""
|
||||
@@ -31,38 +30,18 @@ struct AddAddressFormView: View {
|
||||
!state.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
@ViewBuilder private var logoImage: some View {
|
||||
SwiftUI.Image("pedifoods")
|
||||
.resizable()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
(colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea()
|
||||
AppColors.backgroundLight.ignoresSafeArea()
|
||||
|
||||
ScrollView {
|
||||
ScrollView(showsIndicators: false) {
|
||||
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)
|
||||
|
||||
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(.horizontal, 20)
|
||||
.padding(.bottom, 20)
|
||||
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
LoginField(icon: "tag", placeholder: "Ex: Casa, Trabalho", keyboardType: .default, text: $label)
|
||||
@@ -127,7 +106,7 @@ struct AddAddressFormView: View {
|
||||
Spacer().frame(height: 120)
|
||||
}
|
||||
}
|
||||
.padding(.top, 0)
|
||||
.padding(.top, 18)
|
||||
}
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.appHiddenNavigationBar()
|
||||
@@ -140,8 +119,7 @@ struct AddAddressFormView: View {
|
||||
ZStack {
|
||||
Text(title)
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(colorScheme == .dark ? Color.white : AppColors.textPrimary)
|
||||
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
HStack {
|
||||
Button(action: onBack) {
|
||||
Image(systemName: "chevron.left")
|
||||
@@ -177,7 +155,8 @@ struct AddAddressFormView: View {
|
||||
city: clean(city),
|
||||
state: clean(state),
|
||||
zipCode: optional(normalizeZipCodeForAPI(zipCode)),
|
||||
latLong: latLong
|
||||
latLong: latLong,
|
||||
isDefault: existingAddress?.isDefault
|
||||
)
|
||||
isLoading = true
|
||||
zipLookupMessage = nil
|
||||
|
||||
@@ -118,6 +118,9 @@ struct AddressCard: View {
|
||||
let item: AddressListItem
|
||||
var onEdit: (() -> Void)? = nil
|
||||
var onDelete: (() -> Void)? = nil
|
||||
var onSetDefault: (() -> Void)? = nil
|
||||
|
||||
private var hasActions: Bool { onEdit != nil || onSetDefault != nil }
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 14) {
|
||||
@@ -150,30 +153,30 @@ struct AddressCard: View {
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
if onEdit != nil || onDelete != nil {
|
||||
if hasActions {
|
||||
Rectangle()
|
||||
.fill(AppColors.backgroundLight)
|
||||
.frame(width: 1, height: 96)
|
||||
}
|
||||
|
||||
VStack(spacing: 24) {
|
||||
VStack(spacing: 20) {
|
||||
if let onEdit {
|
||||
Button(action: onEdit) {
|
||||
Image(systemName: "pencil")
|
||||
.font(.system(size: 22))
|
||||
.font(.system(size: 20))
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
}
|
||||
}
|
||||
|
||||
if let onDelete {
|
||||
Button(action: onDelete) {
|
||||
Image(systemName: "trash")
|
||||
.font(.system(size: 22))
|
||||
if let onSetDefault, item.isPrimary == false {
|
||||
Button(action: onSetDefault) {
|
||||
Image(systemName: "star")
|
||||
.font(.system(size: 20))
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: onEdit != nil || onDelete != nil ? 40 : 0)
|
||||
.frame(width: hasActions ? 40 : 0)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 20)
|
||||
|
||||
@@ -12,6 +12,7 @@ struct AddressesView: View {
|
||||
@State var editingAddress: CustomerAddress? = nil
|
||||
@State var openSwipeRowId: String? = nil
|
||||
@State var deletingRowId: String? = nil
|
||||
@State var settingDefaultRowId: String? = nil
|
||||
|
||||
let tabBarClearance: CGFloat = 96
|
||||
|
||||
@@ -54,7 +55,9 @@ struct AddressesView: View {
|
||||
} else {
|
||||
ForEach(Array(addresses.enumerated()), id: \.offset) { index, address in
|
||||
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 {
|
||||
return address.id == selectedId
|
||||
}
|
||||
@@ -65,10 +68,10 @@ struct AddressesView: View {
|
||||
Button {
|
||||
selectAddress(address)
|
||||
} label: {
|
||||
AddressCard(item: addressToListItem(address, isPrimary: isSelected))
|
||||
AddressCard(item: addressToListItem(address, isPrimary: isPrimary))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
} else if addresses.count > 1 {
|
||||
SwipeToDeleteAddressRow(
|
||||
rowId: rowId,
|
||||
openRowId: $openSwipeRowId,
|
||||
@@ -76,19 +79,30 @@ struct AddressesView: View {
|
||||
onDelete: { deleteAddress(address, rowId: rowId) }
|
||||
) {
|
||||
AddressCard(
|
||||
item: addressToListItem(address, isPrimary: isSelected),
|
||||
onEdit: { beginEditing(address) }
|
||||
item: addressToListItem(address, isPrimary: isPrimary),
|
||||
onEdit: { beginEditing(address) },
|
||||
onSetDefault: { setDefaultAddress(address, rowId: rowId) }
|
||||
)
|
||||
.appContentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
if openSwipeRowId == rowId {
|
||||
openSwipeRowId = nil
|
||||
}
|
||||
}
|
||||
.simultaneousGesture(TapGesture().onEnded {
|
||||
if openSwipeRowId == rowId { openSwipeRowId = nil }
|
||||
})
|
||||
.opacity(settingDefaultRowId == rowId ? 0.6 : 1.0)
|
||||
}
|
||||
.id(rowId)
|
||||
.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 ?? "")"
|
||||
}
|
||||
|
||||
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) {
|
||||
guard deletingRowId == nil else { return }
|
||||
guard addresses.count > 1, deletingRowId == nil else { return }
|
||||
deletingRowId = rowId
|
||||
openSwipeRowId = nil
|
||||
|
||||
@@ -302,7 +373,7 @@ struct AddressesView: View {
|
||||
|
||||
do {
|
||||
let service = ApiService()
|
||||
let response = try await service.profile()
|
||||
let response = try await service.profile(forceRefresh: true)
|
||||
guard response.error == false else {
|
||||
errorMessage = response.message ?? "Não foi possível carregar os endereços."
|
||||
isLoading = false
|
||||
|
||||
@@ -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
|
||||
func confirmOrderWithSavedCard(cardId: String, storeId: String) async {
|
||||
let payloadResult = buildCreateOrderPayload(paymentMethod: .creditCard, savedCardId: cardId)
|
||||
|
||||
@@ -28,7 +28,6 @@ struct CheckoutView: View {
|
||||
@State var pixPaymentContext: PixPaymentContext? = nil
|
||||
@State var cardPaymentContext: CardPaymentContext? = nil
|
||||
@State var orderTrackingContext: OrderTrackingContext? = nil
|
||||
@State var savedCards: [SavedCard] = []
|
||||
@State var showCardSelectionSheet = false
|
||||
|
||||
var paymentConfig: StorePaymentMethodsInfo? { storeInfo?.paymentMethods }
|
||||
@@ -191,7 +190,6 @@ struct CheckoutView: View {
|
||||
items: appState.cart.toOrderItemsPayload()
|
||||
)
|
||||
CardSelectionSheet(
|
||||
savedCards: savedCards,
|
||||
cardContext: cardContext,
|
||||
appState: $appState,
|
||||
onSavedCardConfirmed: { cardId in
|
||||
@@ -209,11 +207,6 @@ struct CheckoutView: View {
|
||||
}
|
||||
)
|
||||
}
|
||||
.task(id: paymentMethod) {
|
||||
if paymentMethod == .creditCard && useInAppPayment {
|
||||
await loadSavedCards()
|
||||
}
|
||||
}
|
||||
.navigationDestination(item: $orderTrackingContext) { context in
|
||||
OrderTrackingView(orderId: context.orderId, initialShortId: context.shortId, postOrderBack: {
|
||||
appState.shouldNavigateToOrders = true
|
||||
@@ -957,24 +950,23 @@ struct PaymentPixView: View {
|
||||
// MARK: - CardSelectionSheet
|
||||
|
||||
struct CardSelectionSheet: View {
|
||||
let savedCards: [SavedCard]
|
||||
let cardContext: CardPaymentContext
|
||||
@Binding var appState: AppState
|
||||
let onSavedCardConfirmed: (String) -> Void
|
||||
let onOrderCreated: (String, String?) -> Void
|
||||
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@State private var cards: [SavedCard] = []
|
||||
@State private var isLoading = false
|
||||
@State var selectedCardId: String?
|
||||
@State var isSubmitting = false
|
||||
@State var showNewCardSheet = false
|
||||
|
||||
init(savedCards: [SavedCard], cardContext: CardPaymentContext, appState: Binding<AppState>, onSavedCardConfirmed: @escaping (String) -> Void, onOrderCreated: @escaping (String, String?) -> Void) {
|
||||
self.savedCards = savedCards
|
||||
init(cardContext: CardPaymentContext, appState: Binding<AppState>, onSavedCardConfirmed: @escaping (String) -> Void, onOrderCreated: @escaping (String, String?) -> Void) {
|
||||
self.cardContext = cardContext
|
||||
self._appState = appState
|
||||
self.onSavedCardConfirmed = onSavedCardConfirmed
|
||||
self.onOrderCreated = onOrderCreated
|
||||
_selectedCardId = State(initialValue: savedCards.first(where: { $0.isDefault })?.id ?? savedCards.first?.id)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -1004,11 +996,15 @@ struct CardSelectionSheet: View {
|
||||
|
||||
ScrollView(showsIndicators: false) {
|
||||
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) {
|
||||
ForEach(savedCards) { card in
|
||||
ForEach(cards) { card in
|
||||
savedCardRow(card)
|
||||
if card.id != savedCards.last?.id {
|
||||
if card.id != cards.last?.id {
|
||||
Divider().padding(.horizontal, 14)
|
||||
}
|
||||
}
|
||||
@@ -1066,6 +1062,17 @@ struct CardSelectionSheet: View {
|
||||
|
||||
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 {
|
||||
|
||||
@@ -2,28 +2,39 @@ import Foundation
|
||||
import SwiftUI
|
||||
|
||||
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
|
||||
sizeRow(sizeItems[index])
|
||||
// MARK: - Steps
|
||||
|
||||
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 {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("2. Massa")
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
|
||||
accordionSection(
|
||||
step: 1,
|
||||
label: "Massa",
|
||||
summary: doughs.count <= 1
|
||||
? (doughs.first?.name ?? "Tradicional")
|
||||
: doughs.first(where: { $0.id == selectedDoughId })?.name
|
||||
) {
|
||||
if doughs.count <= 1 {
|
||||
Text(doughs.first?.name ?? "Massa tradicional")
|
||||
.font(AppTypography.body)
|
||||
@@ -36,21 +47,24 @@ extension PizzaProductDetailSheet {
|
||||
isSelected: 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 {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("3. Borda")
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
|
||||
accordionSection(
|
||||
step: 2,
|
||||
label: "Borda",
|
||||
summary: crusts.count <= 1
|
||||
? crustDescription(crusts.first)
|
||||
: crusts.first(where: { $0.id == selectedCrustId }).map { crustDescription($0) }
|
||||
) {
|
||||
if crusts.count <= 1 {
|
||||
Text(crustDescription(crusts.first))
|
||||
.font(AppTypography.body)
|
||||
@@ -59,38 +73,40 @@ extension PizzaProductDetailSheet {
|
||||
ForEach(crusts) { crust in
|
||||
radioRow(
|
||||
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
|
||||
) {
|
||||
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 {
|
||||
let flavorItems = flavors
|
||||
return VStack(alignment: .leading, spacing: 10) {
|
||||
Text("4. Sabores (\(selectedFlavorIds.count)/\(maxFlavorsAllowed))")
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
|
||||
accordionSection(
|
||||
step: 3,
|
||||
label: "Sabores (\(selectedFlavorIds.count)/\(maxFlavorsAllowed))",
|
||||
summary: selectedFlavorIds.isEmpty ? nil
|
||||
: selectedFlavorProducts.map(\.name).joined(separator: ", ")
|
||||
) {
|
||||
Text("Toque no sabor para escolher adicionais.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
|
||||
ForEach(0..<flavorItems.count, id: \.self) { index in
|
||||
let flavor = flavorItems[index]
|
||||
ForEach(flavors) { flavor in
|
||||
let isSelected = selectedFlavorIds.contains(flavor.id)
|
||||
let price = selectedSizeId.flatMap { flavor.pizzaPrices[$0] } ?? flavor.price
|
||||
let maxReached = selectedFlavorIds.count >= maxFlavorsAllowed
|
||||
let disableSwitch = isSelected == false && maxReached
|
||||
|
||||
HStack(spacing: 10) {
|
||||
AsyncStoreImage(imageURL: resolveImageURL(flavor.image))
|
||||
.frame(width: 52, height: 52)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(flavor.name)
|
||||
.font(AppTypography.body)
|
||||
@@ -101,21 +117,15 @@ extension PizzaProductDetailSheet {
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Toggle("", isOn: Binding(
|
||||
get: { isSelected },
|
||||
set: { value in
|
||||
if value {
|
||||
addFlavor(flavor.id)
|
||||
} else {
|
||||
removeFlavor(flavor.id)
|
||||
}
|
||||
if value { addFlavor(flavor.id) } else { removeFlavor(flavor.id) }
|
||||
}
|
||||
))
|
||||
.labelsHidden()
|
||||
.disabled(disableSwitch)
|
||||
.disabled(!isSelected && maxReached)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
.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)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||
}
|
||||
|
||||
func sizeRow(_ size: StorePizzaSize) -> some View {
|
||||
let title = size.name ?? "Tamanho"
|
||||
let subtitle = "Até \(max(1, size.maxFlavors ?? 1)) sabor(es)"
|
||||
let selected = selectedSizeId == size.id
|
||||
// MARK: - Helpers
|
||||
|
||||
return radioRow(
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
isSelected: selected
|
||||
) {
|
||||
selectedSizeId = size.id
|
||||
func nextStep(after step: Int) -> Int {
|
||||
if step == 0 {
|
||||
if doughs.count > 1 { return 1 }
|
||||
if crusts.count > 1 { return 2 }
|
||||
return 3
|
||||
}
|
||||
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 {
|
||||
@@ -158,7 +235,6 @@ extension PizzaProductDetailSheet {
|
||||
.frame(width: 10, height: 10)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(AppTypography.body)
|
||||
@@ -169,7 +245,6 @@ extension PizzaProductDetailSheet {
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.appContentShape(Rectangle())
|
||||
@@ -181,40 +256,29 @@ extension PizzaProductDetailSheet {
|
||||
guard let crust else { return "Sem borda especial" }
|
||||
let name = crust.name ?? "Borda"
|
||||
let modifier = crust.priceModifier ?? 0
|
||||
if modifier > 0 {
|
||||
return "\(name) (+ \(formatCurrency(modifier)))"
|
||||
}
|
||||
return name
|
||||
return modifier > 0 ? "\(name) (+ \(formatCurrency(modifier)))" : name
|
||||
}
|
||||
|
||||
func applyAutoSelections() {
|
||||
if selectedSizeId != nil {
|
||||
if doughs.count == 1 {
|
||||
selectedDoughId = doughs.first?.id
|
||||
} else if doughs.isEmpty {
|
||||
selectedDoughId = "__none__"
|
||||
}
|
||||
if doughs.count == 1 { selectedDoughId = doughs.first?.id }
|
||||
else if doughs.isEmpty { selectedDoughId = "__none__" }
|
||||
}
|
||||
|
||||
if isDoughReady {
|
||||
if crusts.count == 1 {
|
||||
selectedCrustId = crusts.first?.id
|
||||
} else if crusts.isEmpty {
|
||||
selectedCrustId = "__none__"
|
||||
}
|
||||
if crusts.count == 1 { selectedCrustId = crusts.first?.id }
|
||||
else if crusts.isEmpty { selectedCrustId = "__none__" }
|
||||
}
|
||||
}
|
||||
|
||||
func trimFlavorSelectionByLimit() {
|
||||
let limit = maxFlavorsAllowed
|
||||
guard selectedFlavorIds.count > limit else { return }
|
||||
let sorted = selectedFlavorIds.sorted()
|
||||
selectedFlavorIds = Set(sorted.prefix(limit))
|
||||
selectedFlavorIds = Set(selectedFlavorIds.sorted().prefix(limit))
|
||||
}
|
||||
|
||||
func addFlavor(_ flavorId: String) {
|
||||
if selectedFlavorIds.contains(flavorId) { return }
|
||||
if selectedFlavorIds.count >= maxFlavorsAllowed { return }
|
||||
guard !selectedFlavorIds.contains(flavorId),
|
||||
selectedFlavorIds.count < maxFlavorsAllowed else { return }
|
||||
selectedFlavorIds.insert(flavorId)
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ struct PizzaProductDetailSheet: View {
|
||||
@State var flavorAddonQuantities: [String: [String: Int]] = [:]
|
||||
@State var selectedFlavorForAddons: StoreCatalogProduct? = nil
|
||||
@State var quantity: Int = 1
|
||||
@State var expandedStep: Int = 0
|
||||
|
||||
var flavors: [StoreCatalogProduct] {
|
||||
category.products
|
||||
@@ -45,7 +46,7 @@ struct PizzaProductDetailSheet: View {
|
||||
return resolveImageURL(firstImage)
|
||||
}
|
||||
|
||||
private var selectedSize: StorePizzaSize? {
|
||||
var selectedSize: StorePizzaSize? {
|
||||
guard let selectedSizeId else { return nil }
|
||||
return sizes.first(where: { $0.id == selectedSizeId })
|
||||
}
|
||||
@@ -76,7 +77,7 @@ struct PizzaProductDetailSheet: View {
|
||||
isCrustReady
|
||||
}
|
||||
|
||||
private var selectedFlavorProducts: [StoreCatalogProduct] {
|
||||
var selectedFlavorProducts: [StoreCatalogProduct] {
|
||||
flavors
|
||||
.filter { selectedFlavorIds.contains($0.id) }
|
||||
.sorted { $0.name < $1.name }
|
||||
@@ -102,12 +103,12 @@ struct PizzaProductDetailSheet: View {
|
||||
}
|
||||
|
||||
private var basePizzaPrice: Double {
|
||||
selectedFlavorProducts
|
||||
.map { flavor in
|
||||
guard let selectedSizeId else { return flavor.price ?? 0 }
|
||||
return flavor.pizzaPrices[selectedSizeId] ?? flavor.price ?? 0
|
||||
}
|
||||
.max() ?? 0
|
||||
let prices = selectedFlavorProducts.map { flavor in
|
||||
guard let selectedSizeId else { return flavor.price ?? 0 }
|
||||
return flavor.pizzaPrices[selectedSizeId] ?? flavor.price ?? 0
|
||||
}
|
||||
guard prices.isEmpty == false else { return 0 }
|
||||
return prices.reduce(0, +) / Double(prices.count)
|
||||
}
|
||||
|
||||
private var unitPrice: Double {
|
||||
@@ -179,6 +180,34 @@ struct PizzaProductDetailSheet: View {
|
||||
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 {
|
||||
if canConfirm == false {
|
||||
return "Selecione as opções"
|
||||
@@ -190,8 +219,16 @@ struct PizzaProductDetailSheet: View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
screenHeader
|
||||
AsyncStoreImage(imageURL: representativeImage)
|
||||
Rectangle()
|
||||
.fill(AppColors.brandSoft)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 220)
|
||||
.overlay(
|
||||
Image("placeholder-pizza")
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.clipped()
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous))
|
||||
|
||||
Text("Pizza de varios sabores")
|
||||
@@ -207,18 +244,9 @@ struct PizzaProductDetailSheet: View {
|
||||
.foregroundStyle(AppColors.primary)
|
||||
|
||||
stepSizes
|
||||
|
||||
if selectedSizeId != nil {
|
||||
stepDoughs
|
||||
}
|
||||
|
||||
if isDoughReady {
|
||||
stepCrusts
|
||||
}
|
||||
|
||||
if canShowFlavors {
|
||||
stepFlavors
|
||||
}
|
||||
if selectedSizeId != nil { stepDoughs }
|
||||
if isDoughReady { stepCrusts }
|
||||
if canShowFlavors { stepFlavors }
|
||||
}
|
||||
.padding(20)
|
||||
.padding(.bottom, 90)
|
||||
@@ -266,6 +294,7 @@ struct PizzaProductDetailSheet: View {
|
||||
name: "Pizza de varios sabores",
|
||||
imageURL: representativeImage,
|
||||
details: selectedDetailsText,
|
||||
choices: pizzaChoices.isEmpty ? nil : pizzaChoices,
|
||||
addons: selectedAddonsPayload,
|
||||
quantity: quantity,
|
||||
unitPrice: unitPrice
|
||||
|
||||
@@ -56,12 +56,14 @@ struct ProfileView: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
// NavigationLink {
|
||||
// Text("Cupons de Desconto")
|
||||
// } label: {
|
||||
// ProfileMenuRow(icon: "ticket.fill", title: "Cupons de Desconto", badge: "NOVO")
|
||||
// }
|
||||
// .buttonStyle(.plain)
|
||||
if appState.featureFlags.isEnabled("at.cupons") {
|
||||
NavigationLink {
|
||||
Text("Cupons de Desconto")
|
||||
} label: {
|
||||
ProfileMenuRow(icon: "ticket.fill", title: "Cupons de Desconto", badge: "NOVO")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
// NavigationLink {
|
||||
// Text("Ajuda")
|
||||
|
||||
@@ -848,6 +848,7 @@ struct OrderReviewView: View {
|
||||
.frame(height: 110)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 8)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
|
||||
|
||||
@@ -47,6 +47,7 @@ struct ScrollOffsetReader: View {
|
||||
|
||||
struct AsyncStoreImage: View {
|
||||
let imageURL: String?
|
||||
var fallbackImageName: String = "placeholder-product"
|
||||
|
||||
var body: some View {
|
||||
CachedRemoteImage(imageURL: imageURL) {
|
||||
@@ -58,7 +59,7 @@ struct AsyncStoreImage: View {
|
||||
}
|
||||
|
||||
private var fallback: some View {
|
||||
Image("placeholder-product")
|
||||
Image(fallbackImageName)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
}
|
||||
|
||||
@@ -269,9 +269,17 @@ extension StoreDetailView {
|
||||
Spacer()
|
||||
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
AsyncStoreImage(imageURL: resolvedURL(item.imageURL))
|
||||
.frame(width: 92, height: 92)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||
Group {
|
||||
if item.isPizzaSummary {
|
||||
Image("placeholder-pizza")
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
} else {
|
||||
AsyncStoreImage(imageURL: resolvedURL(item.imageURL))
|
||||
}
|
||||
}
|
||||
.frame(width: 92, height: 92)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||
|
||||
Button {
|
||||
guard isStoreOpen else {
|
||||
|
||||
@@ -297,8 +297,9 @@ struct UserProfileView: View {
|
||||
guard let item else { return }
|
||||
do {
|
||||
guard let data = try await item.loadTransferable(type: Data.self) else { return }
|
||||
guard let image = UIImage(data: data),
|
||||
let jpegData = image.jpegData(compressionQuality: 0.82) else { return }
|
||||
guard let image = UIImage(data: data) 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())"
|
||||
} catch {
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user