Not has address

This commit is contained in:
Daniel Arantes Loverde
2026-02-07 17:16:45 -03:00
parent 92c2a67736
commit 54686397b9
28 changed files with 2115 additions and 498 deletions

View File

@@ -2,6 +2,9 @@ import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
#if canImport(LCEssentials) && os(iOS)
import LCEssentials
#endif
enum NetworkError: Error, LocalizedError {
case invalidURL
@@ -36,7 +39,7 @@ enum NetworkError: Error, LocalizedError {
}
}
struct ApiRequest {
struct ApiRequest: Sendable {
let path: String
let method: String
let module: ApiModule
@@ -71,18 +74,117 @@ final class ApiClient {
}
func send<T: Decodable>(_ request: ApiRequest) async throws -> T {
#if canImport(LCEssentials) && os(iOS)
return try await sendWithLCEssentials(request)
#else
return try await sendWithURLSession(request)
#endif
}
}
private extension ApiClient {
#if canImport(LCEssentials) && os(iOS)
func sendWithLCEssentials<T: Decodable>(_ request: ApiRequest) async throws -> T {
let urlString = try buildURL(path: request.path, query: request.queryItems).absoluteString
let method = request.method
let headers = buildHeaders(for: request)
let params = request.body
var attempt = 1
while attempt <= maxAttempts {
do {
let responseString = try await Self.performLCERequest(
url: urlString,
params: params,
method: method,
headers: headers
)
guard let data = responseString.data(using: .utf8) else {
throw NetworkError.decodeError("Resposta nao UTF-8")
}
do {
return try JSONDecoder().decode(T.self, from: data)
} catch {
throw NetworkError.decodeError(String(data: data, encoding: .utf8))
}
} catch {
let mapped = mapError(error)
guard shouldRetry(mapped), attempt < maxAttempts else {
throw mapped
}
try await Task.sleep(nanoseconds: backoff(for: attempt, error: mapped))
attempt += 1
}
}
throw NetworkError.invalidResponse
}
@MainActor
static func performLCERequest(
url: String,
params: Data?,
method: String,
headers: [String: String]
) async throws -> String {
let httpMethod = toHTTPMethod(method)
return try await API.shared.request(
url: url,
params: params,
method: httpMethod,
headers: headers,
jsonEncoding: true,
debug: true
)
}
static func toHTTPMethod(_ method: String) -> httpMethod {
switch method.uppercased() {
case "POST": return .post
case "PUT": return .put
case "DELETE": return .delete
default: return .get
}
}
func mapError(_ error: Error) -> NetworkError {
printError(title: "httpReqError", msg: error.localizedDescription)
if let network = error as? NetworkError {
return network
}
if let decoding = error as? DecodingError {
return .decodeError(String(describing: decoding))
}
let nsError = error as NSError
let apiMessage = serverMessage(from: nsError)
switch nsError.code {
case 401, 403:
return .unauthorized(apiMessage)
case 429:
return .rateLimited(nil)
case 400...599:
return .httpError(nsError.code, apiMessage)
default:
break
}
if nsError.domain == NSURLErrorDomain {
return .transportError(nsError.localizedDescription)
}
return .transportError(nsError.localizedDescription)
}
#endif
func sendWithURLSession<T: Decodable>(_ request: ApiRequest) async throws -> T {
let url = try buildURL(path: request.path, query: request.queryItems)
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = request.method
urlRequest.httpBody = request.body
urlRequest.setValue("application/json", forHTTPHeaderField: "Accept")
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
if let token = ApiConfig.token(for: request.module) {
urlRequest.setValue(token, forHTTPHeaderField: "Atomenta-Token")
}
if request.requiresAuth, let jwt = tokenStore.jwt {
urlRequest.setValue("Bearer \(jwt)", forHTTPHeaderField: "Authorization")
for (key, value) in buildHeaders(for: request) {
urlRequest.setValue(value, forHTTPHeaderField: key)
}
var attempt = 1
@@ -105,7 +207,7 @@ final class ApiClient {
throw NetworkError.invalidResponse
}
private func perform<T: Decodable>(_ request: URLRequest, as type: T.Type) async throws -> T {
func perform<T: Decodable>(_ request: URLRequest, as type: T.Type) async throws -> T {
let (data, response): (Data, URLResponse)
do {
(data, response) = try await session.data(for: request)
@@ -137,7 +239,22 @@ final class ApiClient {
}
}
private func shouldRetry(_ error: NetworkError) -> Bool {
func buildHeaders(for request: ApiRequest) -> [String: String] {
var headers: [String: String] = [
"Accept": "application/json",
"Content-Type": "application/json"
]
if let token = ApiConfig.token(for: request.module) {
headers["Atomenta-Token"] = token
}
if request.requiresAuth, let jwt = tokenStore.jwt {
headers["Authorization"] = "Bearer \(jwt)"
}
return headers
}
func shouldRetry(_ error: NetworkError) -> Bool {
switch error {
case .rateLimited, .transportError:
return true
@@ -148,7 +265,7 @@ final class ApiClient {
}
}
private func backoff(for attempt: Int, error: NetworkError) -> UInt64 {
func backoff(for attempt: Int, error: NetworkError) -> UInt64 {
if case .rateLimited(let retryAfter) = error, let retryAfter {
return UInt64(retryAfter) * 1_000_000_000
}
@@ -156,7 +273,7 @@ final class ApiClient {
return min(baseBackoffNanoseconds * multiplier, 2_000_000_000)
}
private func serverMessage(from data: Data) -> String? {
func serverMessage(from data: Data) -> String? {
if let envelope = try? JSONDecoder().decode(ApiEnvelope<EmptyResult>.self, from: data) {
return envelope.message
}
@@ -172,7 +289,29 @@ final class ApiClient {
return String(data: data, encoding: .utf8)
}
private func buildURL(path: String, query: [URLQueryItem]) throws -> URL {
#if canImport(LCEssentials) && os(iOS)
func serverMessage(from error: NSError) -> String? {
if let reason = error.userInfo[NSLocalizedFailureReasonErrorKey] as? String,
!reason.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
if let data = reason.data(using: .utf8),
let parsed = serverMessage(from: data),
!parsed.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return parsed
}
return reason
}
if let description = error.userInfo[NSLocalizedDescriptionKey] as? String,
!description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
!description.lowercased().contains("nsurlerrordomain") {
return description
}
return nil
}
#endif
func buildURL(path: String, query: [URLQueryItem]) throws -> URL {
guard var components = URLComponents(url: ApiConfig.baseURL, resolvingAgainstBaseURL: false) else {
throw NetworkError.invalidURL
}

View File

@@ -1,6 +1,6 @@
import Foundation
enum ApiModule {
enum ApiModule: Sendable {
case app
case customer
case store

View File

@@ -41,14 +41,28 @@ final class ApiService {
// MARK: - Auth
func requestOtp(email: String) async throws -> ApiEnvelope<EmptyResult> {
let body = try JSONEncoder().encode(["email": email])
func registerCustomer(name: String, email: String, phoneNumber: String, birthDate: String? = nil) async throws -> ApiEnvelope<RegistrationResult> {
var payload: [String: String] = [
"name": name,
"email": email,
"phoneNumber": phoneNumber
]
if let birthDate, birthDate.isEmpty == false {
payload["birthDate"] = birthDate
}
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/customer", method: "POST", module: .customer, requiresAuth: false, body: body)
return try await send(req)
}
func requestOtp(email: String, phoneNumber: String) async throws -> ApiEnvelope<EmptyResult> {
let body = try JSONEncoder().encode(["email": email, "phoneNumber": phoneNumber])
let req = ApiRequest(path: "/api/customer/login", method: "POST", module: .customer, requiresAuth: false, body: body)
return try await send(req)
}
func validateOtp(email: String, otp: String) async throws -> ApiEnvelope<LoginResult> {
let body = try JSONEncoder().encode(["email": email, "otp": otp])
func validateOtp(email: String, phoneNumber: String, otp: String) async throws -> ApiEnvelope<LoginResult> {
let body = try JSONEncoder().encode(["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 {
@@ -84,6 +98,12 @@ final class ApiService {
struct EmptyResult: Decodable {}
struct RegistrationResult: Decodable {
let id: String?
let name: String?
let email: String?
}
struct LoginResult: Decodable {
let token: String
let customer: CustomerProfile?
@@ -94,6 +114,43 @@ struct CustomerProfile: Decodable {
let name: String
let email: String
let phoneNumber: String?
let profilePicture: String?
let addressBook: [CustomerAddress]?
enum CodingKeys: String, CodingKey {
case id
case name
case email
case phoneNumber
case profilePicture
case addressBook = "address_book"
}
}
struct CustomerAddress: Decodable {
let id: String?
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 id
case label
case address
case number
case complement
case neighborhood
case city
case state
case zipCode
case latLong = "lat_long"
}
}
struct StoreSummary: Decodable {

View File

@@ -4,8 +4,10 @@ import Foundation
import CoreLocation
#endif
@MainActor
final class LocationService: NSObject {
typealias LocationResult = Result<(Double, Double), LocationError>
static let shared = LocationService()
#if os(iOS)
enum LocationError: Error {
@@ -35,13 +37,8 @@ final class LocationService: NSObject {
func requestLocation(_ completion: @escaping (LocationResult) -> Void) {
#if os(iOS)
guard CLLocationManager.locationServicesEnabled() else {
completion(.failure(.servicesDisabled))
return
}
self.completion = completion
manager.requestWhenInUseAuthorization()
manager.requestLocation()
handleAuthorizationStatus(manager.authorizationStatus)
#else
let defaults = UserDefaults.standard
if defaults.bool(forKey: "location_permission_denied") {
@@ -59,16 +56,55 @@ final class LocationService: NSObject {
completion(.success((lat, lng)))
#endif
}
func cachedLocation() -> (Double, Double)? {
#if os(iOS)
guard let location = manager.location else {
return nil
}
return (location.coordinate.latitude, location.coordinate.longitude)
#else
let defaults = UserDefaults.standard
guard let latRaw = defaults.string(forKey: "last_location_lat"),
let lngRaw = defaults.string(forKey: "last_location_lng"),
let lat = Double(latRaw),
let lng = Double(lngRaw) else {
return nil
}
return (lat, lng)
#endif
}
func requestLocationAsync(timeoutSeconds: TimeInterval = 8) async -> (Double, Double)? {
await withCheckedContinuation { continuation in
var hasResumed = false
func resumeOnce(_ value: (Double, Double)?) {
guard hasResumed == false else { return }
hasResumed = true
continuation.resume(returning: value)
}
requestLocation { result in
switch result {
case .success(let coordinate):
resumeOnce(coordinate)
case .failure:
resumeOnce(nil)
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + timeoutSeconds) {
resumeOnce(nil)
}
}
}
}
#if os(iOS)
extension LocationService: CLLocationManagerDelegate {
extension LocationService: @preconcurrency CLLocationManagerDelegate {
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
let status = manager.authorizationStatus
if status == .denied || status == .restricted {
completion?(.failure(.denied))
completion = nil
}
handleAuthorizationStatus(manager.authorizationStatus)
}
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
@@ -81,5 +117,20 @@ extension LocationService: CLLocationManagerDelegate {
completion?(.failure(.unavailable))
completion = nil
}
private func handleAuthorizationStatus(_ status: CLAuthorizationStatus) {
switch status {
case .notDetermined:
manager.requestWhenInUseAuthorization()
case .authorizedAlways, .authorizedWhenInUse:
manager.requestLocation()
case .denied, .restricted:
completion?(.failure(.denied))
completion = nil
@unknown default:
completion?(.failure(.unavailable))
completion = nil
}
}
}
#endif