Colapse fix
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -74,6 +74,15 @@ final class ApiClient {
|
||||
}
|
||||
|
||||
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)
|
||||
return try await sendWithLCEssentials(request)
|
||||
#else
|
||||
|
||||
@@ -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<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)
|
||||
return try await send(req)
|
||||
}
|
||||
|
||||
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 response: ApiEnvelope<LoginResult> = 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<CustomerProfile> {
|
||||
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<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
|
||||
|
||||
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<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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
extension Notification.Name {
|
||||
static let sessionExpired = Notification.Name("SessionExpiredNotification")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
.background(scrollOffsetObserver)
|
||||
.simultaneousGesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { value in
|
||||
let range = headerExpandedHeight - headerCollapsedHeight
|
||||
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)
|
||||
.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<Content: View>(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<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
|
||||
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<Content: View>: 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()
|
||||
}
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> UIScrollView {
|
||||
let scrollView = UIScrollView()
|
||||
scrollView.showsVerticalScrollIndicator = false
|
||||
scrollView.alwaysBounceVertical = true
|
||||
@MainActor
|
||||
private final class ScrollOffsetProbeView: UIView {
|
||||
var onOffsetChange: (CGFloat) -> Void = { _ in }
|
||||
|
||||
let host = UIHostingController(rootView: content)
|
||||
host.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
host.view.backgroundColor = .clear
|
||||
scrollView.addSubview(host.view)
|
||||
private weak var observedScrollView: UIScrollView?
|
||||
private var observation: NSKeyValueObservation?
|
||||
|
||||
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)
|
||||
])
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .clear
|
||||
isUserInteractionEnabled = false
|
||||
}
|
||||
|
||||
context.coordinator.hostingController = host
|
||||
scrollView.delegate = context.coordinator
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
}
|
||||
|
||||
deinit {
|
||||
observation?.invalidate()
|
||||
}
|
||||
|
||||
override func didMoveToSuperview() {
|
||||
super.didMoveToSuperview()
|
||||
attachIfNeeded()
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
private func findEnclosingScrollView() -> UIScrollView? {
|
||||
var view: UIView? = self
|
||||
while let current = view {
|
||||
if let scrollView = current as? UIScrollView {
|
||||
return scrollView
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: UIScrollView, context: Context) {
|
||||
context.coordinator.hostingController?.rootView = content
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject, UIScrollViewDelegate {
|
||||
var hostingController: UIHostingController<Content>?
|
||||
let onOffsetChange: (CGFloat) -> Void
|
||||
|
||||
init(onOffsetChange: @escaping (CGFloat) -> Void) {
|
||||
self.onOffsetChange = onOffsetChange
|
||||
}
|
||||
|
||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
onOffsetChange(scrollView.contentOffset.y)
|
||||
view = current.superview
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user