Colapse fix

This commit is contained in:
Daniel Arantes Loverde
2026-02-09 14:41:18 -03:00
parent 54686397b9
commit 3176b67914
8 changed files with 683 additions and 127 deletions

View File

@@ -58,14 +58,11 @@ struct SecondaryButton: View {
image image
} }
} }
.foregroundStyle(AppColors.primary) .foregroundStyle(AppColors.textInverse)
.frame(maxWidth: fullWidth ? .infinity : nil) .frame(maxWidth: fullWidth ? .infinity : nil)
.frame(height: 56) .frame(height: 56)
.background(AppColors.primary) .background(AppColors.secondary)
.overlay( .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)
.stroke(AppColors.primary.opacity(0.3), lineWidth: 1)
)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }

View File

@@ -79,7 +79,7 @@ struct FeaturedStoreCard: View {
} }
struct FeaturedStoreCardModel: Identifiable { struct FeaturedStoreCardModel: Identifiable {
let id = UUID() let id: String
let name: String let name: String
let rating: Double let rating: Double
let reviews: String let reviews: String
@@ -124,7 +124,7 @@ struct SpecialOfferCard: View {
} }
struct SpecialOfferCardModel: Identifiable { struct SpecialOfferCardModel: Identifiable {
let id = UUID() let id: String
let title: String let title: String
let subtitle: String let subtitle: String
let colors: [Color] let colors: [Color]

View File

@@ -118,6 +118,10 @@
"comment" : "A welcome message displayed in the login view.", "comment" : "A welcome message displayed in the login view.",
"isCommentAutoGenerated" : true "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..." : { "Buscando estabelecimentos próximos..." : {
"comment" : "A message indicating that the app is searching for nearby stores.", "comment" : "A message indicating that the app is searching for nearby stores.",
"isCommentAutoGenerated" : true "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?" : { "Novo por aqui?" : {
"comment" : "A text that appears at the bottom of the screen, inviting users to create an account.", "comment" : "A text that appears at the bottom of the screen, inviting users to create an account.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"O que vai querer pedir hoje?" : { "O que vai querer \npedir hoje?" : {
"comment" : "A heading displayed above a search bar in the home view.", "comment" : "A title displayed above the search bar in the home view.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"para %@" : { "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." : { "Preencha os dados abaixo para começar." : {
"comment" : "A description below the form fields in the registration view, instructing the user to fill them out to start.", "comment" : "A description below the form fields in the registration view, instructing the user to fill them out to start.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true

View File

@@ -74,6 +74,15 @@ final class ApiClient {
} }
func send<T: Decodable>(_ request: ApiRequest) async throws -> T { func send<T: Decodable>(_ 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) #if canImport(LCEssentials) && os(iOS)
return try await sendWithLCEssentials(request) return try await sendWithLCEssentials(request)
#else #else

View File

@@ -33,6 +33,7 @@ final class ApiService {
} catch let error as NetworkError { } catch let error as NetworkError {
if case .unauthorized(let message) = error { if case .unauthorized(let message) = error {
tokenStore.clear() tokenStore.clear()
NotificationCenter.default.post(name: .sessionExpired, object: message)
throw ApiServiceError.sessionExpired(message) throw ApiServiceError.sessionExpired(message)
} }
throw error throw error
@@ -56,13 +57,13 @@ final class ApiService {
} }
func requestOtp(email: String, phoneNumber: String) async throws -> ApiEnvelope<EmptyResult> { func requestOtp(email: String, phoneNumber: String) async throws -> ApiEnvelope<EmptyResult> {
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) let req = ApiRequest(path: "/api/customer/login", method: "POST", module: .customer, requiresAuth: false, body: body)
return try await send(req) return try await send(req)
} }
func validateOtp(email: String, phoneNumber: String, otp: String) async throws -> ApiEnvelope<LoginResult> { func validateOtp(email: String, phoneNumber: String, otp: String) async throws -> ApiEnvelope<LoginResult> {
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 req = ApiRequest(path: "/api/customer/login", method: "POST", module: .customer, requiresAuth: false, body: body)
let response: ApiEnvelope<LoginResult> = try await send(req) let response: ApiEnvelope<LoginResult> = try await send(req)
if let token = response.result?.token { if let token = response.result?.token {
@@ -71,11 +72,65 @@ final class ApiService {
return response 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<CustomerProfile> { func profile() async throws -> ApiEnvelope<CustomerProfile> {
let req = ApiRequest(path: "/api/customer/profile", method: "GET", module: .customer, requiresAuth: true) let req = ApiRequest(path: "/api/customer/profile", method: "GET", module: .customer, requiresAuth: true)
return try await send(req) return try await send(req)
} }
func addCustomerAddress(_ address: CustomerAddress) async throws -> ApiEnvelope<CustomerProfile> {
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<CepLookupResult> {
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 // MARK: - Stores
func listStores(lat: Double, lng: Double, category: String? = nil, search: String? = nil) async throws -> ApiEnvelope<[StoreSummary]> { 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 isOpen: Bool?
let statusLabel: String? 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<K: CodingKey>(from container: KeyedDecodingContainer<K>, 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<K: CodingKey>(from container: KeyedDecodingContainer<K>, 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
}
}

View File

@@ -0,0 +1,6 @@
import Foundation
extension Notification.Name {
static let sessionExpired = Notification.Name("SessionExpiredNotification")
}

View File

@@ -1,3 +1,4 @@
import Foundation
import SwiftUI import SwiftUI
#if os(iOS) #if os(iOS)
import UIKit import UIKit
@@ -6,52 +7,55 @@ import UIKit
struct HomeView: View { struct HomeView: View {
@Binding var appState: AppState @Binding var appState: AppState
@State var searchText = "" @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 scrollOffset: CGFloat = 0
@State var collapseBaseOffset: CGFloat = 0
@State var collapseDragStartOffset: CGFloat? = nil
@State var hasRequestedLocation = false @State var hasRequestedLocation = false
@State var isLoadingStores = false @State var isLoadingStores = false
@State var storesError: String? = nil @State var storesError: String? = nil
@State var stores: [StoreSummary] = [] @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] = [ private let specials: [SpecialOfferCardModel] = [
.init(title: "Get 50% OFF", subtitle: "For your first order", colors: [Color(hex: "#1E6B43"), Color(hex: "#58A56C")]), .init(id: "ddddd", 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: "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 headerCollapsedHeight: CGFloat = 120
private let contentTopPadding: CGFloat = 296 private let contentTopSpacing: CGFloat = 18
private let contentBottomSpacing: CGFloat = 120
var body: some View { 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 headerHeight = headerExpandedHeight - (headerExpandedHeight - headerCollapsedHeight) * collapseProgress
let headerPadding = headerExpandedHeight - (headerExpandedHeight - headerCollapsedHeight) * collapseProgress
return ZStack(alignment: .top) { return ZStack(alignment: .top) {
#if os(iOS) ScrollView(showsIndicators: false) {
TrackableScrollView(onOffsetChange: { value in
scrollOffset = min(0, -value)
}) {
contentStack contentStack
.padding(.top, headerPadding - 10) .padding(.top, headerExpandedHeight + contentTopSpacing)
.padding(.bottom, 24) .padding(.bottom, contentBottomSpacing)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .background(scrollOffsetObserver)
#else .simultaneousGesture(
ScrollView { DragGesture(minimumDistance: 0)
contentStack .onChanged { value in
.padding(.top, headerPadding + 16) let range = headerExpandedHeight - headerCollapsedHeight
.padding(.bottom, 24) if collapseDragStartOffset == nil {
} collapseDragStartOffset = collapseBaseOffset
#endif }
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) header(collapseProgress: collapseProgress, height: headerHeight)
.frame(maxWidth: .infinity, alignment: .top) .frame(maxWidth: .infinity, alignment: .top)
@@ -63,16 +67,15 @@ struct HomeView: View {
if hasRequestedLocation == false { if hasRequestedLocation == false {
hasRequestedLocation = true hasRequestedLocation = true
Task { Task {
await bootstrapStoresFlow() await bootstrapStoresFlow(refreshCategories: true)
} }
} }
collapseBaseOffset = scrollOffset
} }
} }
private var contentStack: some View { private var contentStack: some View {
VStack(spacing: 24) { VStack(spacing: 24) {
scrollOffsetMarker
categoriesSection categoriesSection
section(title: "Featured") { section(title: "Featured") {
@@ -115,7 +118,10 @@ struct HomeView: View {
.foregroundStyle(AppColors.textMuted) .foregroundStyle(AppColors.textMuted)
Button("Tentar novamente") { Button("Tentar novamente") {
Task { Task {
await bootstrapStoresFlow(forceLocationRefresh: true) await bootstrapStoresFlow(
forceLocationRefresh: true,
category: selectedCategory == "all" ? nil : selectedCategory
)
} }
} }
.font(AppTypography.heading3) .font(AppTypography.heading3)
@@ -194,8 +200,8 @@ struct HomeView: View {
.opacity(topRowOpacity) .opacity(topRowOpacity)
.offset(y: collapseProgress * -12) .offset(y: collapseProgress * -12)
Text("O que vai querer pedir hoje?") Text("O que vai querer \npedir hoje?")
.font(AppTypography.heading1) .font(AppTypography.heading2)
.foregroundStyle(AppColors.textInverse) .foregroundStyle(AppColors.textInverse)
.opacity(titleOpacity) .opacity(titleOpacity)
.offset(y: collapseProgress * -20) .offset(y: collapseProgress * -20)
@@ -203,23 +209,13 @@ struct HomeView: View {
SearchBar(placeholder: "Search menu, restaurant or craving", text: $searchText) { SearchBar(placeholder: "Search menu, restaurant or craving", text: $searchText) {
appState.activeModal = .filters appState.activeModal = .filters
} }
.offset(y: collapseProgress * -140) .offset(y: collapseProgress * -120)
} }
.padding(.horizontal, 20) .padding(.horizontal, 20)
.padding(.top, 18) .padding(.top, 18)
} }
} }
@ViewBuilder
private var scrollOffsetMarker: some View {
#if os(iOS)
EmptyView()
#else
ScrollOffsetReader()
.offset(y: -contentTopPadding)
#endif
}
private func section<Content: View>(title: String, @ViewBuilder content: () -> Content) -> some View { private func section<Content: View>(title: String, @ViewBuilder content: () -> Content) -> some View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
Text(title) Text(title)
@@ -244,10 +240,14 @@ struct HomeView: View {
CategoryChip( CategoryChip(
title: category.title, title: category.title,
systemIcon: category.systemIcon, systemIcon: category.systemIcon,
isActive: category.title == selectedCategory isActive: category.id == selectedCategory
) )
.onTapGesture { .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 { private func mapStoreToCard(_ store: StoreSummary) -> FeaturedStoreCardModel {
FeaturedStoreCardModel( FeaturedStoreCardModel(
id: store.id,
name: store.name, name: store.name,
rating: store.rating ?? 0, rating: store.rating ?? 0,
reviews: "0", reviews: "0",
@@ -305,7 +306,11 @@ struct HomeView: View {
} }
@MainActor @MainActor
private func bootstrapStoresFlow(forceLocationRefresh: Bool = false) async { private func bootstrapStoresFlow(
forceLocationRefresh: Bool = false,
category: String? = nil,
refreshCategories: Bool = false
) async {
isLoadingStores = true isLoadingStores = true
storesError = nil storesError = nil
@@ -319,14 +324,21 @@ struct HomeView: View {
} }
do { 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 isLoadingStores = false
if response.error { if response.error {
stores = [] stores = []
storesError = response.message ?? "Não foi possível carregar os estabelecimentos." storesError = response.message ?? "Não foi possível carregar os estabelecimentos."
return 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 storesError = nil
} catch { } catch {
isLoadingStores = false 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<String>()
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 @MainActor
private func resolveCoordinates(forceRefresh: Bool) async -> (Double, Double)? { private func resolveCoordinates(forceRefresh: Bool) async -> (Double, Double)? {
if !forceRefresh { if !forceRefresh {
@@ -382,10 +420,25 @@ struct HomeView: View {
} }
return "Não foi possível carregar os estabelecimentos." 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 { struct CategoryModel: Identifiable {
let id = UUID() let id: String
let title: String let title: String
let systemIcon: 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 { func clamp(value: CGFloat, lower: CGFloat, upper: CGFloat) -> CGFloat {
min(max(value, lower), upper) min(max(value, lower), upper)
} }
#if os(iOS) #if os(iOS)
@MainActor @MainActor
struct TrackableScrollView<Content: View>: UIViewRepresentable { private struct ScrollOffsetObserver: UIViewRepresentable {
let onOffsetChange: (CGFloat) -> Void let onOffsetChange: (CGFloat) -> Void
let content: Content
init(onOffsetChange: @escaping (CGFloat) -> Void, @ViewBuilder content: () -> Content) { func makeUIView(context: Context) -> ScrollOffsetProbeView {
self.onOffsetChange = onOffsetChange let view = ScrollOffsetProbeView()
self.content = content() view.onOffsetChange = onOffsetChange
return view
} }
func makeCoordinator() -> Coordinator { func updateUIView(_ uiView: ScrollOffsetProbeView, context: Context) {
Coordinator(onOffsetChange: onOffsetChange) 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 { required init?(coder: NSCoder) {
let scrollView = UIScrollView() super.init(coder: coder)
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
} }
func updateUIView(_ uiView: UIScrollView, context: Context) { deinit {
context.coordinator.hostingController?.rootView = content observation?.invalidate()
} }
final class Coordinator: NSObject, UIScrollViewDelegate { override func didMoveToSuperview() {
var hostingController: UIHostingController<Content>? super.didMoveToSuperview()
let onOffsetChange: (CGFloat) -> Void attachIfNeeded()
}
init(onOffsetChange: @escaping (CGFloat) -> Void) { override func didMoveToWindow() {
self.onOffsetChange = onOffsetChange 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) { private func findEnclosingScrollView() -> UIScrollView? {
onOffsetChange(scrollView.contentOffset.y) var view: UIView? = self
while let current = view {
if let scrollView = current as? UIScrollView {
return scrollView
}
view = current.superview
} }
return nil
} }
} }
#endif #endif

View File

@@ -28,13 +28,6 @@ struct ProfileView: View {
.font(AppTypography.body) .font(AppTypography.body)
.foregroundStyle(AppColors.textPrimary) .foregroundStyle(AppColors.textPrimary)
NavigationLink(isActive: $openAddressesOnboarding) {
AddressesView(message: onboardingMessage, appState: $appState)
} label: {
EmptyView()
}
.hidden()
Spacer() Spacer()
SecondaryButton(title: "Sair") { SecondaryButton(title: "Sair") {
@@ -56,6 +49,11 @@ struct ProfileView: View {
appState.address.onboardingMessage = nil appState.address.onboardingMessage = nil
openAddressesOnboarding = true openAddressesOnboarding = true
} }
.sheet(isPresented: $openAddressesOnboarding) {
NavigationStack {
AddressesView(message: onboardingMessage, appState: $appState)
}
}
} }
private var header: some View { private var header: some View {
@@ -87,6 +85,7 @@ struct AddressesView: View {
@State var isLoading = false @State var isLoading = false
@State var errorMessage: String? = nil @State var errorMessage: String? = nil
@State var addresses: [CustomerAddress] = [] @State var addresses: [CustomerAddress] = []
@State var openAddAddressForm = false
let tabBarClearance: CGFloat = 96 let tabBarClearance: CGFloat = 96
@@ -142,9 +141,28 @@ struct AddressesView: View {
bottomOverlay bottomOverlay
.padding(.bottom, tabBarClearance) .padding(.bottom, tabBarClearance)
} }
} }
.navigationBarBackButtonHidden(true) .navigationBarBackButtonHidden(true)
.toolbar(.hidden, for: .navigationBar) .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 { .onAppear {
if isLoading == false, addresses.isEmpty { if isLoading == false, addresses.isEmpty {
Task { Task {
@@ -182,7 +200,7 @@ struct AddressesView: View {
.fill(AppColors.backgroundLight) .fill(AppColors.backgroundLight)
.frame(height: 136) .frame(height: 136)
Button(action: {}) { Button(action: { openAddAddressForm = true }) {
HStack(spacing: 12) { HStack(spacing: 12) {
Image(systemName: "mappin.circle.fill") Image(systemName: "mappin.circle.fill")
.font(.system(size: 24)) .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 { struct AddressCard: View {
let item: AddressListItem let item: AddressListItem
@@ -287,7 +551,6 @@ struct AddressCard: View {
.background(AppColors.tertiary) .background(AppColors.tertiary)
.clipShape(Capsule()) .clipShape(Capsule())
.lineLimit(1) .lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
} }
} }