From 3176b67914bc5f923143d5880a269fc766783c53 Mon Sep 17 00:00:00 2001 From: Daniel Arantes Loverde Date: Mon, 9 Feb 2026 14:41:18 -0300 Subject: [PATCH] Colapse fix --- .../PediFoods/Components/Buttons.swift | 9 +- .../PediFoods/Components/StoreCard.swift | 4 +- .../PediFoods/Resources/Localizable.xcstrings | 16 +- .../PediFoods/Services/ApiClient.swift | 9 + .../PediFoods/Services/ApiService.swift | 222 +++++++++++++- .../PediFoods/Services/SessionEvents.swift | 6 + .../PediFoods/Views/Main/HomeView.swift | 263 +++++++++------- .../PediFoods/Views/Main/ProfileView.swift | 281 +++++++++++++++++- 8 files changed, 683 insertions(+), 127 deletions(-) create mode 100644 pedi-foods/Sources/PediFoods/Services/SessionEvents.swift diff --git a/pedi-foods/Sources/PediFoods/Components/Buttons.swift b/pedi-foods/Sources/PediFoods/Components/Buttons.swift index 3201097..ba1a964 100644 --- a/pedi-foods/Sources/PediFoods/Components/Buttons.swift +++ b/pedi-foods/Sources/PediFoods/Components/Buttons.swift @@ -58,14 +58,11 @@ struct SecondaryButton: View { image } } - .foregroundStyle(AppColors.primary) + .foregroundStyle(AppColors.textInverse) .frame(maxWidth: fullWidth ? .infinity : nil) .frame(height: 56) - .background(AppColors.primary) - .overlay( - RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous) - .stroke(AppColors.primary.opacity(0.3), lineWidth: 1) - ) + .background(AppColors.secondary) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) } .buttonStyle(.plain) } diff --git a/pedi-foods/Sources/PediFoods/Components/StoreCard.swift b/pedi-foods/Sources/PediFoods/Components/StoreCard.swift index 228e542..0d64168 100644 --- a/pedi-foods/Sources/PediFoods/Components/StoreCard.swift +++ b/pedi-foods/Sources/PediFoods/Components/StoreCard.swift @@ -79,7 +79,7 @@ struct FeaturedStoreCard: View { } struct FeaturedStoreCardModel: Identifiable { - let id = UUID() + let id: String let name: String let rating: Double let reviews: String @@ -124,7 +124,7 @@ struct SpecialOfferCard: View { } struct SpecialOfferCardModel: Identifiable { - let id = UUID() + let id: String let title: String let subtitle: String let colors: [Color] diff --git a/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings b/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings index 4e1cb27..8b02950 100644 --- a/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings +++ b/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings @@ -118,6 +118,10 @@ "comment" : "A welcome message displayed in the login view.", "isCommentAutoGenerated" : true }, + "Buscando endereço pelo CEP..." : { + "comment" : "A message displayed while an address is being looked up by ZIP code.", + "isCommentAutoGenerated" : true + }, "Buscando estabelecimentos próximos..." : { "comment" : "A message indicating that the app is searching for nearby stores.", "isCommentAutoGenerated" : true @@ -498,12 +502,16 @@ } } }, + "Novo endereço" : { + "comment" : "A label for a form to add a new address.", + "isCommentAutoGenerated" : true + }, "Novo por aqui?" : { "comment" : "A text that appears at the bottom of the screen, inviting users to create an account.", "isCommentAutoGenerated" : true }, - "O que vai querer pedir hoje?" : { - "comment" : "A heading displayed above a search bar in the home view.", + "O que vai querer \npedir hoje?" : { + "comment" : "A title displayed above the search bar in the home view.", "isCommentAutoGenerated" : true }, "para %@" : { @@ -556,6 +564,10 @@ } } }, + "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/Services/ApiClient.swift b/pedi-foods/Sources/PediFoods/Services/ApiClient.swift index 52ddaf9..7a4a1ac 100644 --- a/pedi-foods/Sources/PediFoods/Services/ApiClient.swift +++ b/pedi-foods/Sources/PediFoods/Services/ApiClient.swift @@ -74,6 +74,15 @@ final class ApiClient { } func send(_ request: ApiRequest) async throws -> T { + // NOTE: + // /api/customer/login is strict about body fields (email/phoneNumber/otp). + // On iOS, routing this endpoint through URLSession ensures JSON body arrives as-is. + if request.path == "/api/customer/login" { + if let body = request.body, let bodyText = String(data: body, encoding: .utf8) { + print("[ApiClient] /api/customer/login body: \(bodyText)") + } + return try await sendWithURLSession(request) + } #if canImport(LCEssentials) && os(iOS) return try await sendWithLCEssentials(request) #else diff --git a/pedi-foods/Sources/PediFoods/Services/ApiService.swift b/pedi-foods/Sources/PediFoods/Services/ApiService.swift index f5765c5..d0289e1 100644 --- a/pedi-foods/Sources/PediFoods/Services/ApiService.swift +++ b/pedi-foods/Sources/PediFoods/Services/ApiService.swift @@ -33,6 +33,7 @@ final class ApiService { } catch let error as NetworkError { if case .unauthorized(let message) = error { tokenStore.clear() + NotificationCenter.default.post(name: .sessionExpired, object: message) throw ApiServiceError.sessionExpired(message) } throw error @@ -56,13 +57,13 @@ final class ApiService { } func requestOtp(email: String, phoneNumber: String) async throws -> ApiEnvelope { - let body = try JSONEncoder().encode(["email": email, "phoneNumber": phoneNumber]) + let body = try makeLoginBody(email: email, phoneNumber: phoneNumber, otp: nil) let req = ApiRequest(path: "/api/customer/login", method: "POST", module: .customer, requiresAuth: false, body: body) return try await send(req) } func validateOtp(email: String, phoneNumber: String, otp: String) async throws -> ApiEnvelope { - let body = try JSONEncoder().encode(["email": email, "phoneNumber": phoneNumber, "otp": otp]) + let body = try makeLoginBody(email: email, phoneNumber: phoneNumber, otp: otp) let req = ApiRequest(path: "/api/customer/login", method: "POST", module: .customer, requiresAuth: false, body: body) let response: ApiEnvelope = try await send(req) if let token = response.result?.token { @@ -71,11 +72,65 @@ final class ApiService { return response } + private func makeLoginBody(email: String, phoneNumber: String, otp: String?) throws -> Data { + let sanitizedEmail = email.trimmingCharacters(in: .whitespacesAndNewlines) + let sanitizedPhone = phoneNumber.trimmingCharacters(in: .whitespacesAndNewlines) + + guard sanitizedEmail.isEmpty == false, sanitizedPhone.isEmpty == false else { + throw NetworkError.httpError(400, "Email e telefone são obrigatórios.") + } + + var payload: [String: String] = [ + "email": sanitizedEmail, + "phoneNumber": sanitizedPhone, + "phone": sanitizedPhone + ] + if let otp, otp.isEmpty == false { + payload["otp"] = otp + } + + guard JSONSerialization.isValidJSONObject(payload) else { + throw NetworkError.invalidResponse + } + return try JSONSerialization.data(withJSONObject: payload, options: []) + } + func profile() async throws -> ApiEnvelope { let req = ApiRequest(path: "/api/customer/profile", method: "GET", module: .customer, requiresAuth: true) return try await send(req) } + func addCustomerAddress(_ address: CustomerAddress) async throws -> ApiEnvelope { + let currentProfile = try await profile() + guard currentProfile.error == false, let customer = currentProfile.result else { + throw NetworkError.invalidResponse + } + + var addressBook = (customer.addressBook ?? []).map(CustomerAddressPayload.init(from:)) + addressBook.insert(CustomerAddressPayload(from: address), at: 0) + + let payload = CustomerProfileUpdatePayload(addressBook: addressBook) + let body = try JSONEncoder().encode(payload) + let req = ApiRequest(path: "/api/customer/\(customer.id)", method: "POST", module: .customer, requiresAuth: true, body: body) + return try await send(req) + } + + func lookupZipCode(_ zipCode: String) async throws -> ApiEnvelope { + let digits = zipCode.filter(\.isNumber) + let normalized = String(digits.prefix(8)) + let formatted: String + if normalized.count == 8 { + let prefix = String(normalized.prefix(5)) + let suffix = String(normalized.dropFirst(5)) + formatted = "\(prefix)-\(suffix)" + } else { + formatted = normalized + } + + let req = ApiRequest(path: "/api/public/cep/\(formatted)", method: "GET", module: .none, requiresAuth: true) + return try await send(req) + } + // MARK: - Stores func listStores(lat: Double, lng: Double, category: String? = nil, search: String? = nil) async throws -> ApiEnvelope<[StoreSummary]> { @@ -166,3 +221,166 @@ struct StoreSummary: Decodable { let isOpen: Bool? let statusLabel: String? } + +struct CustomerProfileUpdatePayload: Encodable { + let addressBook: [CustomerAddressPayload] + + enum CodingKeys: String, CodingKey { + case addressBook = "address_book" + } +} + +struct CustomerAddressPayload: Encodable { + let label: String? + let address: String? + let number: String? + let complement: String? + let neighborhood: String? + let city: String? + let state: String? + let zipCode: String? + let latLong: [Double]? + + enum CodingKeys: String, CodingKey { + case label + case address + case number + case complement + case neighborhood + case city + case state + case zipCode + case latLong = "lat_long" + } + + init(from address: CustomerAddress) { + self.label = address.label + self.address = address.address + self.number = address.number + self.complement = address.complement + self.neighborhood = address.neighborhood + self.city = address.city + self.state = address.state + self.zipCode = address.zipCode + self.latLong = address.latLong + } +} + +struct CepLookupResult: Decodable { + let zipCode: String? + let street: String? + let neighborhood: String? + let city: String? + let state: String? + let complement: String? + let latitude: Double? + let longitude: Double? + + enum CodingKeys: String, CodingKey { + case zipCode + case cep + case zip + case normalized + case raw + case street + case logradouro + case address + case neighborhood + case bairro + case district + case city + case cidade + case localidade + case state + case estado + case uf + case complement + case complemento + case latitude + case lat + case longitude + case lng + } + + enum NormalizedKeys: String, CodingKey { + case cep + case logradouro + case bairro + case cidade + case uf + case latitude + case longitude + } + + enum RawKeys: String, CodingKey { + case cep + case address + case district + case city + case state + case lat + case lng + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let normalizedContainer = try? container.nestedContainer(keyedBy: NormalizedKeys.self, forKey: .normalized) + let rawContainer = try? container.nestedContainer(keyedBy: RawKeys.self, forKey: .raw) + + let directZip = CepLookupResult.decodeString(from: container, keys: [.zipCode, .cep, .zip]) + let directStreet = CepLookupResult.decodeString(from: container, keys: [.street, .logradouro, .address]) + let directNeighborhood = CepLookupResult.decodeString(from: container, keys: [.neighborhood, .bairro, .district]) + let directCity = CepLookupResult.decodeString(from: container, keys: [.city, .cidade, .localidade]) + let directState = CepLookupResult.decodeString(from: container, keys: [.state, .estado, .uf]) + let directComplement = CepLookupResult.decodeString(from: container, keys: [.complement, .complemento]) + let directLatitude = CepLookupResult.decodeFlexibleDouble(from: container, keys: [.latitude, .lat]) + let directLongitude = CepLookupResult.decodeFlexibleDouble(from: container, keys: [.longitude, .lng]) + + let normalizedZip = normalizedContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.cep]) } + let normalizedStreet = normalizedContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.logradouro]) } + let normalizedNeighborhood = normalizedContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.bairro]) } + let normalizedCity = normalizedContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.cidade]) } + let normalizedState = normalizedContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.uf]) } + let normalizedLatitude = normalizedContainer.flatMap { CepLookupResult.decodeFlexibleDouble(from: $0, keys: [.latitude]) } + let normalizedLongitude = normalizedContainer.flatMap { CepLookupResult.decodeFlexibleDouble(from: $0, keys: [.longitude]) } + + let rawZip = rawContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.cep]) } + let rawStreet = rawContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.address]) } + let rawNeighborhood = rawContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.district]) } + let rawCity = rawContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.city]) } + let rawState = rawContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.state]) } + let rawLatitude = rawContainer.flatMap { CepLookupResult.decodeFlexibleDouble(from: $0, keys: [.lat]) } + let rawLongitude = rawContainer.flatMap { CepLookupResult.decodeFlexibleDouble(from: $0, keys: [.lng]) } + + zipCode = directZip ?? normalizedZip ?? rawZip + street = directStreet ?? normalizedStreet ?? rawStreet + neighborhood = directNeighborhood ?? normalizedNeighborhood ?? rawNeighborhood + city = directCity ?? normalizedCity ?? rawCity + state = directState ?? normalizedState ?? rawState + complement = directComplement + latitude = directLatitude ?? normalizedLatitude ?? rawLatitude + longitude = directLongitude ?? normalizedLongitude ?? rawLongitude + } + + private static func decodeString(from container: KeyedDecodingContainer, keys: [K]) -> String? { + for key in keys { + if let value = try? container.decode(String.self, forKey: key) { + return value + } + } + return nil + } + + private static func decodeFlexibleDouble(from container: KeyedDecodingContainer, keys: [K]) -> Double? { + for key in keys { + if let value = try? container.decode(Double.self, forKey: key) { + return value + } + if let valueAsString = try? container.decode(String.self, forKey: key), + let parsed = Double(valueAsString.replacingOccurrences(of: ",", with: ".")) { + return parsed + } + } + return nil + } +} diff --git a/pedi-foods/Sources/PediFoods/Services/SessionEvents.swift b/pedi-foods/Sources/PediFoods/Services/SessionEvents.swift new file mode 100644 index 0000000..7402ab1 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Services/SessionEvents.swift @@ -0,0 +1,6 @@ +import Foundation + +extension Notification.Name { + static let sessionExpired = Notification.Name("SessionExpiredNotification") +} + diff --git a/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift b/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift index 13c78c3..425505a 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift @@ -1,3 +1,4 @@ +import Foundation import SwiftUI #if os(iOS) import UIKit @@ -6,52 +7,55 @@ import UIKit struct HomeView: View { @Binding var appState: AppState @State var searchText = "" - @State var selectedCategory = "Stores" + @State var selectedCategory = "all" + @State var categories: [CategoryModel] = [ + .init(id: "all", title: "Todas", systemIcon: "line.3.horizontal.decrease.circle") + ] @State var scrollOffset: CGFloat = 0 + @State var collapseBaseOffset: CGFloat = 0 + @State var collapseDragStartOffset: CGFloat? = nil @State var hasRequestedLocation = false @State var isLoadingStores = false @State var storesError: String? = nil @State var stores: [StoreSummary] = [] - private let categories: [CategoryModel] = [ - .init(title: "Stores", systemIcon: "storefront"), - .init(title: "Asian", systemIcon: "fork.knife"), - .init(title: "Breakfast", systemIcon: "sun.max"), - .init(title: "Pizza", systemIcon: "takeoutbag.and.cup.and.straw"), - .init(title: "Dessert", systemIcon: "cup.and.saucer") - ] - private let specials: [SpecialOfferCardModel] = [ - .init(title: "Get 50% OFF", subtitle: "For your first order", colors: [Color(hex: "#1E6B43"), Color(hex: "#58A56C")]), - .init(title: "Enjoy spicy day", subtitle: "Delivery in 20 min", colors: [Color(hex: "#F04B3E"), Color(hex: "#FF8C42")]) + .init(id: "ddddd", title: "Get 50% OFF", subtitle: "For your first order", colors: [Color(hex: "#1E6B43"), Color(hex: "#58A56C")]), + .init(id: "eeeee", title: "Enjoy spicy day", subtitle: "Delivery in 20 min", colors: [Color(hex: "#F04B3E"), Color(hex: "#FF8C42")]) ] - private let headerExpandedHeight: CGFloat = 260 + private let headerExpandedHeight: CGFloat = 240 private let headerCollapsedHeight: CGFloat = 120 - private let contentTopPadding: CGFloat = 296 + private let contentTopSpacing: CGFloat = 18 + private let contentBottomSpacing: CGFloat = 120 var body: some View { - let collapseProgress = clamp(value: -scrollOffset / (headerExpandedHeight - headerCollapsedHeight), lower: 0, upper: 1) + let collapseProgress = clamp(value: scrollOffset / (headerExpandedHeight - headerCollapsedHeight), lower: 0, upper: 1) let headerHeight = headerExpandedHeight - (headerExpandedHeight - headerCollapsedHeight) * collapseProgress - let headerPadding = headerExpandedHeight - (headerExpandedHeight - headerCollapsedHeight) * collapseProgress return ZStack(alignment: .top) { - #if os(iOS) - TrackableScrollView(onOffsetChange: { value in - scrollOffset = min(0, -value) - }) { + ScrollView(showsIndicators: false) { contentStack - .padding(.top, headerPadding - 10) - .padding(.bottom, 24) + .padding(.top, headerExpandedHeight + contentTopSpacing) + .padding(.bottom, contentBottomSpacing) } - .frame(maxWidth: .infinity, maxHeight: .infinity) - #else - ScrollView { - contentStack - .padding(.top, headerPadding + 16) - .padding(.bottom, 24) - } - #endif + .background(scrollOffsetObserver) + .simultaneousGesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + let range = headerExpandedHeight - headerCollapsedHeight + if collapseDragStartOffset == nil { + collapseDragStartOffset = collapseBaseOffset + } + let start = collapseDragStartOffset ?? collapseBaseOffset + let candidate = start - value.translation.height + scrollOffset = clamp(value: candidate, lower: 0, upper: range) + } + .onEnded { _ in + collapseBaseOffset = scrollOffset + collapseDragStartOffset = nil + } + ) header(collapseProgress: collapseProgress, height: headerHeight) .frame(maxWidth: .infinity, alignment: .top) @@ -63,16 +67,15 @@ struct HomeView: View { if hasRequestedLocation == false { hasRequestedLocation = true Task { - await bootstrapStoresFlow() + await bootstrapStoresFlow(refreshCategories: true) } } + collapseBaseOffset = scrollOffset } } private var contentStack: some View { VStack(spacing: 24) { - scrollOffsetMarker - categoriesSection section(title: "Featured") { @@ -115,7 +118,10 @@ struct HomeView: View { .foregroundStyle(AppColors.textMuted) Button("Tentar novamente") { Task { - await bootstrapStoresFlow(forceLocationRefresh: true) + await bootstrapStoresFlow( + forceLocationRefresh: true, + category: selectedCategory == "all" ? nil : selectedCategory + ) } } .font(AppTypography.heading3) @@ -194,8 +200,8 @@ struct HomeView: View { .opacity(topRowOpacity) .offset(y: collapseProgress * -12) - Text("O que vai querer pedir hoje?") - .font(AppTypography.heading1) + Text("O que vai querer \npedir hoje?") + .font(AppTypography.heading2) .foregroundStyle(AppColors.textInverse) .opacity(titleOpacity) .offset(y: collapseProgress * -20) @@ -203,23 +209,13 @@ struct HomeView: View { SearchBar(placeholder: "Search menu, restaurant or craving", text: $searchText) { appState.activeModal = .filters } - .offset(y: collapseProgress * -140) + .offset(y: collapseProgress * -120) } .padding(.horizontal, 20) .padding(.top, 18) } } - @ViewBuilder - private var scrollOffsetMarker: some View { - #if os(iOS) - EmptyView() - #else - ScrollOffsetReader() - .offset(y: -contentTopPadding) - #endif - } - private func section(title: String, @ViewBuilder content: () -> Content) -> some View { VStack(alignment: .leading, spacing: 16) { Text(title) @@ -244,10 +240,14 @@ struct HomeView: View { CategoryChip( title: category.title, systemIcon: category.systemIcon, - isActive: category.title == selectedCategory + isActive: category.id == selectedCategory ) .onTapGesture { - selectedCategory = category.title + guard category.id != selectedCategory else { return } + selectedCategory = category.id + Task { + await bootstrapStoresFlow(category: category.id == "all" ? nil : category.id) + } } } } @@ -285,6 +285,7 @@ struct HomeView: View { private func mapStoreToCard(_ store: StoreSummary) -> FeaturedStoreCardModel { FeaturedStoreCardModel( + id: store.id, name: store.name, rating: store.rating ?? 0, reviews: "0", @@ -305,7 +306,11 @@ struct HomeView: View { } @MainActor - private func bootstrapStoresFlow(forceLocationRefresh: Bool = false) async { + private func bootstrapStoresFlow( + forceLocationRefresh: Bool = false, + category: String? = nil, + refreshCategories: Bool = false + ) async { isLoadingStores = true storesError = nil @@ -319,14 +324,21 @@ struct HomeView: View { } do { - let response = try await ApiService().listStores(lat: coordinate.0, lng: coordinate.1) + let response = try await ApiService().listStores(lat: coordinate.0, lng: coordinate.1, category: category) isLoadingStores = false if response.error { stores = [] storesError = response.message ?? "Não foi possível carregar os estabelecimentos." return } - stores = response.result ?? [] + let results = response.result ?? [] + stores = results + if refreshCategories { + categories = buildCategories(from: results) + if categories.contains(where: { $0.id == selectedCategory }) == false { + selectedCategory = "all" + } + } storesError = nil } catch { isLoadingStores = false @@ -335,6 +347,32 @@ struct HomeView: View { } } + private func buildCategories(from stores: [StoreSummary]) -> [CategoryModel] { + var unique: [CategoryModel] = [ + .init(id: "all", title: "Todas", systemIcon: "line.3.horizontal.decrease.circle") + ] + var seen = Set() + + for store in stores { + let raw = (store.category ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if raw.isEmpty { continue } + let dedupe = raw.lowercased() + if seen.contains(dedupe) { continue } + seen.insert(dedupe) + unique.append(.init(id: raw, title: raw, systemIcon: categoryIcon(for: raw))) + } + return unique + } + + private func categoryIcon(for category: String) -> String { + let value = category.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current).lowercased() + if value.contains("pizza") { return "takeoutbag.and.cup.and.straw" } + if value.contains("lanche") || value.contains("hamburg") || value.contains("burger") { return "fork.knife" } + if value.contains("cafe") || value.contains("breakfast") { return "sun.max" } + if value.contains("doce") || value.contains("sobremesa") || value.contains("dessert") { return "cup.and.saucer" } + return "storefront" + } + @MainActor private func resolveCoordinates(forceRefresh: Bool) async -> (Double, Double)? { if !forceRefresh { @@ -382,10 +420,25 @@ struct HomeView: View { } return "Não foi possível carregar os estabelecimentos." } + + @ViewBuilder + private var scrollOffsetObserver: some View { +#if os(iOS) + ScrollOffsetObserver { y in + // Use only upward displacement for collapse and ignore top bounce. + let normalized = max(0, y) + scrollOffset = normalized + collapseBaseOffset = normalized + } + .frame(width: 0, height: 0) +#else + EmptyView() +#endif + } } struct CategoryModel: Identifiable { - let id = UUID() + let id: String let title: String let systemIcon: String } @@ -434,80 +487,78 @@ struct SearchBar: View { } } -struct ScrollOffsetKey: PreferenceKey { - static let defaultValue: CGFloat = 0 - static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { - value = nextValue() - } -} - -struct ScrollOffsetReader: View { - var body: some View { - GeometryReader { proxy in - Color.clear - .preference(key: ScrollOffsetKey.self, value: proxy.frame(in: .named("scroll")).minY) - } - .frame(height: 1) - } -} - func clamp(value: CGFloat, lower: CGFloat, upper: CGFloat) -> CGFloat { min(max(value, lower), upper) } #if os(iOS) @MainActor -struct TrackableScrollView: UIViewRepresentable { +private struct ScrollOffsetObserver: UIViewRepresentable { let onOffsetChange: (CGFloat) -> Void - let content: Content - init(onOffsetChange: @escaping (CGFloat) -> Void, @ViewBuilder content: () -> Content) { - self.onOffsetChange = onOffsetChange - self.content = content() + func makeUIView(context: Context) -> ScrollOffsetProbeView { + let view = ScrollOffsetProbeView() + view.onOffsetChange = onOffsetChange + return view } - func makeCoordinator() -> Coordinator { - Coordinator(onOffsetChange: onOffsetChange) + func updateUIView(_ uiView: ScrollOffsetProbeView, context: Context) { + uiView.onOffsetChange = onOffsetChange + uiView.attachIfNeeded() + } +} + +@MainActor +private final class ScrollOffsetProbeView: UIView { + var onOffsetChange: (CGFloat) -> Void = { _ in } + + private weak var observedScrollView: UIScrollView? + private var observation: NSKeyValueObservation? + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear + isUserInteractionEnabled = false } - func makeUIView(context: Context) -> UIScrollView { - let scrollView = UIScrollView() - scrollView.showsVerticalScrollIndicator = false - scrollView.alwaysBounceVertical = true - - let host = UIHostingController(rootView: content) - host.view.translatesAutoresizingMaskIntoConstraints = false - host.view.backgroundColor = .clear - scrollView.addSubview(host.view) - - NSLayoutConstraint.activate([ - host.view.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), - host.view.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), - host.view.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), - host.view.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), - host.view.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor) - ]) - - context.coordinator.hostingController = host - scrollView.delegate = context.coordinator - return scrollView + required init?(coder: NSCoder) { + super.init(coder: coder) } - func updateUIView(_ uiView: UIScrollView, context: Context) { - context.coordinator.hostingController?.rootView = content + deinit { + observation?.invalidate() } - final class Coordinator: NSObject, UIScrollViewDelegate { - var hostingController: UIHostingController? - let onOffsetChange: (CGFloat) -> Void + override func didMoveToSuperview() { + super.didMoveToSuperview() + attachIfNeeded() + } - init(onOffsetChange: @escaping (CGFloat) -> Void) { - self.onOffsetChange = onOffsetChange + override func didMoveToWindow() { + super.didMoveToWindow() + attachIfNeeded() + } + + func attachIfNeeded() { + guard let scrollView = findEnclosingScrollView() else { return } + guard scrollView !== observedScrollView else { return } + + observation?.invalidate() + observedScrollView = scrollView + observation = scrollView.observe(\.contentOffset, options: [.initial, .new]) { [weak self] sv, _ in + self?.onOffsetChange(sv.contentOffset.y) } + } - func scrollViewDidScroll(_ scrollView: UIScrollView) { - onOffsetChange(scrollView.contentOffset.y) + private func findEnclosingScrollView() -> UIScrollView? { + var view: UIView? = self + while let current = view { + if let scrollView = current as? UIScrollView { + return scrollView + } + view = current.superview } + return nil } } #endif diff --git a/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift b/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift index 9979e64..ed40ce2 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift @@ -28,13 +28,6 @@ struct ProfileView: View { .font(AppTypography.body) .foregroundStyle(AppColors.textPrimary) - NavigationLink(isActive: $openAddressesOnboarding) { - AddressesView(message: onboardingMessage, appState: $appState) - } label: { - EmptyView() - } - .hidden() - Spacer() SecondaryButton(title: "Sair") { @@ -56,6 +49,11 @@ struct ProfileView: View { appState.address.onboardingMessage = nil openAddressesOnboarding = true } + .sheet(isPresented: $openAddressesOnboarding) { + NavigationStack { + AddressesView(message: onboardingMessage, appState: $appState) + } + } } private var header: some View { @@ -87,6 +85,7 @@ struct AddressesView: View { @State var isLoading = false @State var errorMessage: String? = nil @State var addresses: [CustomerAddress] = [] + @State var openAddAddressForm = false let tabBarClearance: CGFloat = 96 @@ -142,9 +141,28 @@ struct AddressesView: View { bottomOverlay .padding(.bottom, tabBarClearance) } + } .navigationBarBackButtonHidden(true) .toolbar(.hidden, for: .navigationBar) + .sheet(isPresented: $openAddAddressForm) { + NavigationStack { + AddAddressFormView { updatedAddresses in + addresses = updatedAddresses + let first = updatedAddresses.first + appState.address.selectedId = first?.id + appState.address.display = first?.label?.isEmpty == false ? (first?.label ?? "Defina seu endereco") : "Defina seu endereco" + if let lat = first?.latLong?.first, let lng = first?.latLong?.dropFirst().first { + appState.address.latitude = lat + appState.address.longitude = lng + } else { + appState.address.latitude = nil + appState.address.longitude = nil + } + SnackbarCenter.shared.show(title: "Endereço adicionado com sucesso.", style: .success, icon: "checkmark.seal.fill", duration: 3.0) + } + } + } .onAppear { if isLoading == false, addresses.isEmpty { Task { @@ -182,7 +200,7 @@ struct AddressesView: View { .fill(AppColors.backgroundLight) .frame(height: 136) - Button(action: {}) { + Button(action: { openAddAddressForm = true }) { HStack(spacing: 12) { Image(systemName: "mappin.circle.fill") .font(.system(size: 24)) @@ -265,6 +283,252 @@ struct AddressesView: View { } } +struct AddAddressFormView: View { + @Environment(\.dismiss) var dismiss + @Environment(\.colorScheme) var colorScheme + @State var label = "" + @State var zipCode = "" + @State var address = "" + @State var number = "" + @State var complement = "" + @State var neighborhood = "" + @State var city = "" + @State var state = "" + @State var isLoading = false + @State var isLookingUpZipCode = false + @State var zipLookupMessage: String? = nil + @State var lastLookedUpZipCode = "" + @State var lookedUpLatitude: Double? = nil + @State var lookedUpLongitude: Double? = nil + + let onSave: ([CustomerAddress]) -> Void + + private var isFormValid: Bool { + !label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + normalizeZipCodeForAPI(zipCode).count == 8 && + !address.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + !number.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + !neighborhood.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + !city.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + !state.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + @ViewBuilder private var logoImage: some View { +#if os(Android) + SwiftUI.Image("pedifoods") + .resizable() +#else + SwiftUI.Image("pedifoods") + .resizable() +#endif + } + + var body: some View { + ZStack { + (colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea() + + ScrollView { + VStack(spacing: 0) { + logoImage + .scaledToFit() + .frame(width: 120, height: 120) + + Text("Novo endereço") + .font(AppTypography.heading1) + .foregroundStyle(colorScheme == .dark ? Color.white : AppColors.textPrimary) + + Text("Preencha os dados abaixo para adicionar um endereço.") + .font(AppTypography.body) + .foregroundStyle(colorScheme == .dark ? Color.white : AppColors.textPrimary) + .multilineTextAlignment(.center) + .padding(.top, 8) + .padding(.bottom, 20) + + VStack(alignment: .leading, spacing: 16) { + LoginField(icon: "tag", placeholder: "Ex: Casa, Trabalho", text: $label) + LoginField(icon: "mail", placeholder: "CEP", text: $zipCode) + .onChange(of: zipCode) { _, newValue in + let masked = formatZipCodeBR(newValue) + if masked != newValue { + zipCode = masked + } + + let normalized = normalizeZipCodeForAPI(masked) + if normalized.count == 8, normalized != lastLookedUpZipCode, !isLookingUpZipCode { + Task { + await lookupAddressByZipCode(normalized) + } + } + } + if isLookingUpZipCode { + HStack(spacing: 8) { + ProgressView() + Text("Buscando endereço pelo CEP...") + .font(.caption) + .foregroundStyle(AppColors.textMuted) + } + .padding(.horizontal, 6) + } else if let zipLookupMessage { + Text(zipLookupMessage) + .font(.caption) + .foregroundStyle(AppColors.textMuted) + .padding(.horizontal, 6) + } + LoginField(icon: "mappin.and.ellipse", placeholder: "Rua / Avenida", text: $address) + LoginField(icon: "number", placeholder: "Número", text: $number) + LoginField(icon: "plus.app", placeholder: "Complemento (opcional)", text: $complement) + LoginField(icon: "square.grid.2x2", placeholder: "Bairro", text: $neighborhood) + LoginField(icon: "building.2", placeholder: "Cidade", text: $city) + LoginField(icon: "map", placeholder: "Estado (UF)", text: $state) + .onChange(of: state) { _, newValue in + let normalized = String(newValue.uppercased().prefix(2)) + if normalized != newValue { + state = normalized + } + } + } + .padding(.horizontal, 24) + .padding(.bottom, 20) + + PrimaryButton(title: "Salvar endereço", image: Image(systemName: "checkmark")) { + saveAddress() + } + .padding(.horizontal, 24) + .disabled(!isFormValid || isLoading) + .opacity((!isFormValid || isLoading) ? 0.5 : 1.0) + .tint(AppColors.tertiary) + + SecondaryButton(title: "Cancelar") { + dismiss() + } + .padding(.horizontal, 24) + .padding(.top, 12) + + Spacer().frame(height: 120) + } + } + .padding(.top, 0) + } + .navigationBarBackButtonHidden(true) + .toolbar(.hidden, for: .navigationBar) + } + + private func saveAddress() { + guard !isLoading else { return } + let latLong: [Double]? = { + if let lat = lookedUpLatitude, let lng = lookedUpLongitude { + return [lat, lng] + } + return nil + }() + + let newAddress = CustomerAddress( + id: UUID().uuidString, + label: clean(label), + address: clean(address), + number: clean(number), + complement: optional(clean(complement)), + neighborhood: clean(neighborhood), + city: clean(city), + state: clean(state), + zipCode: optional(normalizeZipCodeForAPI(zipCode)), + latLong: latLong + ) + isLoading = true + zipLookupMessage = nil + + Task { + do { + let response = try await ApiService().addCustomerAddress(newAddress) + await MainActor.run { + isLoading = false + if response.error { + zipLookupMessage = response.message ?? "Não foi possível salvar o endereço." + SnackbarCenter.shared.show(title: zipLookupMessage ?? "Não foi possível salvar o endereço.", style: .error, icon: "xmark.octagon.fill", duration: 4.0) + return + } + + let updatedAddresses = response.result?.addressBook ?? [newAddress] + onSave(updatedAddresses) + dismiss() + } + } catch { + await MainActor.run { + isLoading = false + let message = error.localizedDescription + zipLookupMessage = message + SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 4.0) + } + } + } + } + + private func clean(_ value: String) -> String { + value.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func optional(_ value: String) -> String? { + value.isEmpty ? nil : value + } + + @MainActor + private func lookupAddressByZipCode(_ zip: String) async { + isLookingUpZipCode = true + zipLookupMessage = nil + defer { isLookingUpZipCode = false } + + do { + let response = try await ApiService().lookupZipCode(zip) + lastLookedUpZipCode = zip + + guard response.error == false, let result = response.result else { + zipLookupMessage = response.message ?? "Não foi possível consultar este CEP." + return + } + + fillAddressFields(with: result) + zipLookupMessage = "Endereço preenchido automaticamente." + } catch { + zipLookupMessage = "Não foi possível consultar o CEP agora." + } + } + + private func fillAddressFields(with result: CepLookupResult) { + if address.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + address = result.street?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + } + if neighborhood.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + neighborhood = result.neighborhood?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + } + if city.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + city = result.city?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + } + if state.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + state = String((result.state ?? "").uppercased().prefix(2)) + } + if complement.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + complement = result.complement?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + } + lookedUpLatitude = result.latitude + lookedUpLongitude = result.longitude + } +} + +func formatZipCodeBR(_ input: String) -> String { + let digits = input.filter(\.isNumber) + let limited = String(digits.prefix(8)) + if limited.count <= 5 { + return limited + } + let prefix = String(limited.prefix(5)) + let suffix = String(limited.dropFirst(5)) + return "\(prefix)-\(suffix)" +} + +func normalizeZipCodeForAPI(_ input: String) -> String { + String(input.filter(\.isNumber).prefix(8)) +} + struct AddressCard: View { let item: AddressListItem @@ -287,7 +551,6 @@ struct AddressCard: View { .background(AppColors.tertiary) .clipShape(Capsule()) .lineLimit(1) - .fixedSize(horizontal: true, vertical: false) } }