diff --git a/pedi-foods/Darwin/Assets.xcassets/LaunchBackground.colorset/Contents.json b/pedi-foods/Darwin/Assets.xcassets/LaunchBackground.colorset/Contents.json new file mode 100644 index 0000000..35fe5b3 --- /dev/null +++ b/pedi-foods/Darwin/Assets.xcassets/LaunchBackground.colorset/Contents.json @@ -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 + } +} diff --git a/pedi-foods/Darwin/Assets.xcassets/placeholder-pizza.imageset/Contents.json b/pedi-foods/Darwin/Assets.xcassets/placeholder-pizza.imageset/Contents.json new file mode 100644 index 0000000..162c776 --- /dev/null +++ b/pedi-foods/Darwin/Assets.xcassets/placeholder-pizza.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "placeholder-pizza.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/pedi-foods/Darwin/Assets.xcassets/placeholder-pizza.imageset/placeholder-pizza.png b/pedi-foods/Darwin/Assets.xcassets/placeholder-pizza.imageset/placeholder-pizza.png new file mode 100644 index 0000000..e4072c0 Binary files /dev/null and b/pedi-foods/Darwin/Assets.xcassets/placeholder-pizza.imageset/placeholder-pizza.png differ diff --git a/pedi-foods/Darwin/PediFoods.xcodeproj/project.pbxproj b/pedi-foods/Darwin/PediFoods.xcodeproj/project.pbxproj index 416a597..fd9fbd3 100644 --- a/pedi-foods/Darwin/PediFoods.xcodeproj/project.pbxproj +++ b/pedi-foods/Darwin/PediFoods.xcodeproj/project.pbxproj @@ -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 */ diff --git a/pedi-foods/Darwin/Sources/LaunchScreen.storyboard b/pedi-foods/Darwin/Sources/LaunchScreen.storyboard index bd2b472..6383af8 100644 --- a/pedi-foods/Darwin/Sources/LaunchScreen.storyboard +++ b/pedi-foods/Darwin/Sources/LaunchScreen.storyboard @@ -23,7 +23,7 @@ - + diff --git a/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings b/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings index 143f19f..7bab26e 100644 --- a/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings +++ b/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings @@ -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 diff --git a/pedi-foods/Sources/PediFoods/Resources/placeholder-pizza.png b/pedi-foods/Sources/PediFoods/Resources/placeholder-pizza.png new file mode 100644 index 0000000..e4072c0 Binary files /dev/null and b/pedi-foods/Sources/PediFoods/Resources/placeholder-pizza.png differ diff --git a/pedi-foods/Sources/PediFoods/Services/ApiClient.swift b/pedi-foods/Sources/PediFoods/Services/ApiClient.swift index bd4b4c2..f5cab1d 100644 --- a/pedi-foods/Sources/PediFoods/Services/ApiClient.swift +++ b/pedi-foods/Sources/PediFoods/Services/ApiClient.swift @@ -82,7 +82,6 @@ final class ApiClient { } func send(_ 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 diff --git a/pedi-foods/Sources/PediFoods/Services/ApiModels.swift b/pedi-foods/Sources/PediFoods/Services/ApiModels.swift index 6c6e077..dc73a2e 100644 --- a/pedi-foods/Sources/PediFoods/Services/ApiModels.swift +++ b/pedi-foods/Sources/PediFoods/Services/ApiModels.swift @@ -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 } } diff --git a/pedi-foods/Sources/PediFoods/Services/ApiOrderModels.swift b/pedi-foods/Sources/PediFoods/Services/ApiOrderModels.swift index dd95608..b4627b5 100644 --- a/pedi-foods/Sources/PediFoods/Services/ApiOrderModels.swift +++ b/pedi-foods/Sources/PediFoods/Services/ApiOrderModels.swift @@ -28,6 +28,7 @@ struct CreateOrderItemPayload: Codable { let qty: Int let price: Double let addons: [CreateOrderAddonPayload] + let choices: [String]? } struct CreateOrderAddonPayload: Codable { diff --git a/pedi-foods/Sources/PediFoods/Services/ApiService.swift b/pedi-foods/Sources/PediFoods/Services/ApiService.swift index eec5989..4eb13a7 100644 --- a/pedi-foods/Sources/PediFoods/Services/ApiService.swift +++ b/pedi-foods/Sources/PediFoods/Services/ApiService.swift @@ -212,6 +212,13 @@ final class ApiService { return try await updateCustomerAddressBook(customerId: customer.id, addressBook: updatedAddressBook) } + func setDefaultAddress(addressId: String) async throws -> ApiEnvelope { + let req = ApiRequest(path: "/api/customer/addresses/\(addressId)/default", method: "PATCH", module: .customer, requiresAuth: true) + let envelope: ApiEnvelope = try await sendEnvelope(req) + AppContentCache.shared.invalidate(prefix: profileCachePrefix) + return envelope + } + func lookupZipCode(_ zipCode: String) async throws -> ApiEnvelope { let digits = zipCode.filter(\.isNumber) let normalized = String(digits.prefix(8)) diff --git a/pedi-foods/Sources/PediFoods/Services/FeatureControlService.swift b/pedi-foods/Sources/PediFoods/Services/FeatureControlService.swift index 0c77e5b..09f12bf 100644 --- a/pedi-foods/Sources/PediFoods/Services/FeatureControlService.swift +++ b/pedi-foods/Sources/PediFoods/Services/FeatureControlService.swift @@ -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) } diff --git a/pedi-foods/Sources/PediFoods/State/AppState.swift b/pedi-foods/Sources/PediFoods/State/AppState.swift index fab59f6..3c117bf 100644 --- a/pedi-foods/Sources/PediFoods/State/AppState.swift +++ b/pedi-foods/Sources/PediFoods/State/AppState.swift @@ -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 ) } } diff --git a/pedi-foods/Sources/PediFoods/Views/Main/AddAddressFormView.swift b/pedi-foods/Sources/PediFoods/Views/Main/AddAddressFormView.swift index c391380..505b510 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/AddAddressFormView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/AddAddressFormView.swift @@ -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 diff --git a/pedi-foods/Sources/PediFoods/Views/Main/AddressComponents.swift b/pedi-foods/Sources/PediFoods/Views/Main/AddressComponents.swift index d195279..04d2817 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/AddressComponents.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/AddressComponents.swift @@ -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) diff --git a/pedi-foods/Sources/PediFoods/Views/Main/AddressesView.swift b/pedi-foods/Sources/PediFoods/Views/Main/AddressesView.swift index b15b176..9817781 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/AddressesView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/AddressesView.swift @@ -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 diff --git a/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView+Logic.swift b/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView+Logic.swift index 55629a6..438bd90 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView+Logic.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView+Logic.swift @@ -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) diff --git a/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView.swift b/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView.swift index 47552e3..9180b4d 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView.swift @@ -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, onSavedCardConfirmed: @escaping (String) -> Void, onOrderCreated: @escaping (String, String?) -> Void) { - self.savedCards = savedCards + init(cardContext: CardPaymentContext, appState: Binding, 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 { diff --git a/pedi-foods/Sources/PediFoods/Views/Main/PizzaProductDetailSheet+Flow.swift b/pedi-foods/Sources/PediFoods/Views/Main/PizzaProductDetailSheet+Flow.swift index f96b628..30cfd6b 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/PizzaProductDetailSheet+Flow.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/PizzaProductDetailSheet+Flow.swift @@ -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.. 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..= 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) } diff --git a/pedi-foods/Sources/PediFoods/Views/Main/PizzaProductDetailSheet.swift b/pedi-foods/Sources/PediFoods/Views/Main/PizzaProductDetailSheet.swift index 0ee5760..18c640c 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/PizzaProductDetailSheet.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/PizzaProductDetailSheet.swift @@ -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 diff --git a/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift b/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift index 1348e34..148fbc3 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift @@ -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") diff --git a/pedi-foods/Sources/PediFoods/Views/Main/ReviewsView.swift b/pedi-foods/Sources/PediFoods/Views/Main/ReviewsView.swift index e1e3fb1..f14094b 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/ReviewsView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/ReviewsView.swift @@ -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)) diff --git a/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailSupport.swift b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailSupport.swift index 1e52f7d..c303c68 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailSupport.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailSupport.swift @@ -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() } diff --git a/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView+Components.swift b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView+Components.swift index ecd0908..5924dbc 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView+Components.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView+Components.swift @@ -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 { diff --git a/pedi-foods/Sources/PediFoods/Views/Main/UserProfileView.swift b/pedi-foods/Sources/PediFoods/Views/Main/UserProfileView.swift index a778f68..d9c3d81 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/UserProfileView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/UserProfileView.swift @@ -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 }