diff --git a/pedi-foods/Darwin/Assets.xcassets/placeholder-product.imageset/Contents.json b/pedi-foods/Darwin/Assets.xcassets/placeholder-product.imageset/Contents.json new file mode 100644 index 0000000..7e07a38 --- /dev/null +++ b/pedi-foods/Darwin/Assets.xcassets/placeholder-product.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "placeholder-product.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/pedi-foods/Darwin/Assets.xcassets/placeholder-product.imageset/placeholder-product.png b/pedi-foods/Darwin/Assets.xcassets/placeholder-product.imageset/placeholder-product.png new file mode 100644 index 0000000..889fa6c Binary files /dev/null and b/pedi-foods/Darwin/Assets.xcassets/placeholder-product.imageset/placeholder-product.png differ diff --git a/pedi-foods/Sources/PediFoods/Components/StoreCard.swift b/pedi-foods/Sources/PediFoods/Components/StoreCard.swift index 0d64168..d3a6b0d 100644 --- a/pedi-foods/Sources/PediFoods/Components/StoreCard.swift +++ b/pedi-foods/Sources/PediFoods/Components/StoreCard.swift @@ -6,19 +6,7 @@ struct FeaturedStoreCard: View { var body: some View { VStack(alignment: .leading, spacing: 12) { ZStack(alignment: .topLeading) { - RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous) - .fill(AppColors.brandSoft) - .frame(height: 120) - .overlay( - Circle() - .fill(AppColors.surface) - .frame(width: 64, height: 64) - .overlay( - Image(systemName: store.iconName) - .font(.title2) - .foregroundStyle(AppColors.primary) - ) - ) + mediaBlock if let promo = store.promoText { Text(promo) @@ -76,6 +64,58 @@ struct FeaturedStoreCard: View { .background(AppColors.surface) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous)) } + + private var mediaBlock: some View { + ZStack { + RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous) + .fill(AppColors.brandSoft) + + mediaImage + } + .frame(height: 120) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + + @ViewBuilder + private var mediaImage: some View { + if let imageURL = store.imageURL, + let url = URL(string: imageURL) { + AsyncImage(url: url) { phase in + switch phase { + case .success(let image): + image + .resizable() + .scaledToFill() + default: + storeIconPlaceholder + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .clipped() + } else { + storeIconPlaceholder + } + } + + private var storeIconPlaceholder: some View { + ZStack { + Image("placeholder-product") + .resizable() + .scaledToFill() + .opacity(0.7) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .clipped() + + Circle() + .fill(AppColors.surface.opacity(0.92)) + .frame(width: 64, height: 64) + .overlay( + Image(systemName: store.iconName) + .font(.title2) + .foregroundStyle(AppColors.primary) + ) + } + } } struct FeaturedStoreCardModel: Identifiable { @@ -88,6 +128,7 @@ struct FeaturedStoreCardModel: Identifiable { let promoText: String? let isFavorite: Bool let iconName: String + let imageURL: String? } struct SpecialOfferCard: View { diff --git a/pedi-foods/Sources/PediFoods/ContentView.swift b/pedi-foods/Sources/PediFoods/ContentView.swift index 790ef4c..8100c91 100644 --- a/pedi-foods/Sources/PediFoods/ContentView.swift +++ b/pedi-foods/Sources/PediFoods/ContentView.swift @@ -5,6 +5,10 @@ struct ContentView: View { @State var selectedTab: MainTab = .home private let tokenStore: TokenStore = DefaultTokenStore() @State var appState = AppState() + @State var isBootstrappingSession = false + #if os(iOS) + @State private var sessionExpiredObserver: NSObjectProtocol? + #endif #if os(Android) @State var snackbarCenter = SnackbarCenter.shared #else @@ -18,7 +22,11 @@ struct ContentView: View { case .auth: AuthFlowView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState) case .main: - MainTabView(selectedTab: $selectedTab, root: $root, tokenStore: tokenStore, appState: $appState) + if isBootstrappingSession { + sessionBootstrapLoadingView + } else { + MainTabView(selectedTab: $selectedTab, root: $root, tokenStore: tokenStore, appState: $appState) + } } } @@ -32,48 +40,187 @@ struct ContentView: View { FiltersModalView() } } + .onChange(of: appState.address.display) { _, _ in + dismissAddressPickerIfAddressExists() + } + .onChange(of: appState.address.selectedId) { _, _ in + dismissAddressPickerIfAddressExists() + } + .onChange(of: appState.address.latitude) { _, _ in + dismissAddressPickerIfAddressExists() + } + .onChange(of: appState.address.longitude) { _, _ in + dismissAddressPickerIfAddressExists() + } + .task(id: root) { + await bootstrapSessionStateIfNeeded() + } + .onAppear { + #if os(iOS) + attachSessionExpiredObserverIfNeeded() + #endif + } + .onDisappear { + #if os(iOS) + detachSessionExpiredObserver() + #endif + } } + + private var sessionBootstrapLoadingView: some View { + VStack(spacing: 12) { + ProgressView() + Text("Carregando sua sessão...") + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(AppColors.backgroundLight) + } + + @MainActor + private func bootstrapSessionStateIfNeeded() async { + guard root == .main else { return } + guard isBootstrappingSession == false else { return } + + isBootstrappingSession = true + + if let jwt = tokenStore.jwt { + appState.session.jwt = jwt + appState.session.isAuthenticated = true + } + + if let cachedAddress = SessionStateStore.loadAddress() { + appState.address = cachedAddress + dismissAddressPickerIfAddressExists() + } + + // Only refresh profile when there is no local address cache. + // This avoids forcing Profile flow on startup and still recovers + // existing addresses already registered in backend. + if hasConfiguredAddress() == false { + do { + let response = try await ApiService().profile() + if response.error == false, let customer = response.result { + hydrateAppState(with: customer) + } + } catch let error as ApiServiceError { + if case .sessionExpired = error { + forceLogoutToStart() + } + } catch { + // Keep local state when backend refresh fails transiently. + } + } + + isBootstrappingSession = false + } + + @MainActor + private func hydrateAppState(with customer: CustomerProfile) { + appState.profile.id = customer.id + appState.profile.name = customer.name + appState.profile.email = customer.email + appState.profile.phone = customer.phoneNumber ?? "" + SessionStateStore.setActiveUserKey( + SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email) + ) + + let addresses = customer.addressBook ?? [] + guard addresses.isEmpty == false else { + return + } + + let preferredAddress = addresses.first(where: { $0.id == appState.address.selectedId }) ?? addresses.first + if let preferredAddress { + applyAddress(preferredAddress) + SessionStateStore.saveAddress(appState.address) + } + } + + @MainActor + private func applyAddress(_ address: CustomerAddress) { + appState.address.selectedId = address.id + + let cleanLabel = (address.label ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + if cleanLabel.isEmpty == false { + appState.address.display = cleanLabel + } + + if let lat = address.latLong?.first, let lng = address.latLong?.dropFirst().first { + appState.address.latitude = lat + appState.address.longitude = lng + } + } + + @MainActor + private func forceLogoutToStart() { + tokenStore.clear() + SessionStateStore.clearActiveUser() + isBootstrappingSession = false + appState = AppState() + selectedTab = .home + root = .auth + } + + private func hasConfiguredAddress() -> Bool { + if appState.address.selectedId != nil { + return true + } + if appState.address.latitude != nil, appState.address.longitude != nil { + return true + } + let normalized = appState.address.display + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + return normalized.isEmpty == false && normalized != "defina seu endereco" + } + + @MainActor + private func dismissAddressPickerIfAddressExists() { + guard appState.activeModal == .addressPicker else { return } + if hasConfiguredAddress() { + appState.activeModal = nil + } + } + + #if os(iOS) + private func attachSessionExpiredObserverIfNeeded() { + guard sessionExpiredObserver == nil else { return } + sessionExpiredObserver = NotificationCenter.default.addObserver( + forName: .sessionExpired, + object: nil, + queue: .main + ) { _ in + Task { @MainActor in + forceLogoutToStart() + } + } + } + + private func detachSessionExpiredObserver() { + guard let observer = sessionExpiredObserver else { return } + NotificationCenter.default.removeObserver(observer) + sessionExpiredObserver = nil + } + #endif } struct AddressPickerModalView: View { @Binding var appState: AppState @Binding var selectedTab: MainTab - @Environment(\.dismiss) var dismiss var body: some View { NavigationStack { - VStack(spacing: 12) { - if let message = appState.address.onboardingMessage { - Text(message) - .font(AppTypography.body) - .multilineTextAlignment(.center) - .foregroundStyle(AppColors.textPrimary) - .padding(.horizontal, 16) - .padding(.vertical, 12) - .frame(maxWidth: .infinity) - .background(AppColors.brandSoft) - .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) - .padding(.horizontal, 20) - } - - Text("Selecionar endereco") - .font(AppTypography.heading2) - Text("Fluxo de endereco sera implementado na etapa de checkout/perfil.") - .font(AppTypography.body) - .multilineTextAlignment(.center) - .foregroundStyle(AppColors.textMuted) - - Button("Ir para Perfil > Endereços") { - selectedTab = .profile - dismiss() - } - .font(AppTypography.heading3) - .foregroundStyle(AppColors.primary) - .padding(.top, 8) + AddressesView( + message: appState.address.onboardingMessage, + appState: $appState, + selectionMode: true + ) + .onAppear { + appState.address.onboardingMessage = nil } - .padding(24) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(AppColors.backgroundLight) } } } diff --git a/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings b/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings index 8b02950..38eba85 100644 --- a/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings +++ b/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings @@ -156,6 +156,10 @@ } } }, + "Carregando sua sessão..." : { + "comment" : "A loading message displayed while bootstrapping the user's session.", + "isCommentAutoGenerated" : true + }, "Carrinho" : { "comment" : "A label for the cart section of the app.", "isCommentAutoGenerated" : true @@ -314,10 +318,6 @@ "comment" : "A description of the filters feature that will be added to the home screen.", "isCommentAutoGenerated" : true }, - "Fluxo de endereco sera implementado na etapa de checkout/perfil." : { - "comment" : "A message explaining that the address selection feature will be implemented in the checkout and profile screens.", - "isCommentAutoGenerated" : true - }, "Hello [%@](https://skip.tools)!" : { "comment" : "Welcome tab contents", "extractionState" : "stale", @@ -385,10 +385,6 @@ "comment" : "A description below the text field where the user inputs their OTP code.", "isCommentAutoGenerated" : true }, - "Ir para Perfil > Endereços" : { - "comment" : "A button that navigates to the user profile screen when pressed.", - "isCommentAutoGenerated" : true - }, "Já tem uma conta?" : { "comment" : "A question displayed below the \"Cadastrar e Receber Código\" button in the registration view. The text is a hyperlink to the login view.", "isCommentAutoGenerated" : true @@ -513,6 +509,9 @@ "O que vai querer \npedir hoje?" : { "comment" : "A title displayed above the search bar in the home view.", "isCommentAutoGenerated" : true + }, + "O que vai querer pedir hoje?\n " : { + }, "para %@" : { "comment" : "A text label displaying the email address to which the one-time password (OTP) was sent. The text is truncated to fit within one line.", @@ -618,10 +617,6 @@ } } }, - "Selecionar endereco" : { - "comment" : "A title for the address picker modal view.", - "isCommentAutoGenerated" : true - }, "Settings" : { "comment" : "Tab bar item title for the Settings tab", "extractionState" : "stale", diff --git a/pedi-foods/Sources/PediFoods/Resources/placeholder-product.png b/pedi-foods/Sources/PediFoods/Resources/placeholder-product.png new file mode 100644 index 0000000..889fa6c Binary files /dev/null and b/pedi-foods/Sources/PediFoods/Resources/placeholder-product.png differ diff --git a/pedi-foods/Sources/PediFoods/Services/ApiClient.swift b/pedi-foods/Sources/PediFoods/Services/ApiClient.swift index 7a4a1ac..e044f5a 100644 --- a/pedi-foods/Sources/PediFoods/Services/ApiClient.swift +++ b/pedi-foods/Sources/PediFoods/Services/ApiClient.swift @@ -39,6 +39,11 @@ enum NetworkError: Error, LocalizedError { } } +private struct ApiErrorDescriptor { + let code: String? + let message: String? +} + struct ApiRequest: Sendable { let path: String let method: String @@ -166,6 +171,10 @@ private extension ApiClient { let nsError = error as NSError let apiMessage = serverMessage(from: nsError) + let payload = serverPayload(from: nsError) + if isSessionExpiredPayload(code: payload?.code, message: payload?.message ?? apiMessage) { + return .unauthorized(payload?.message ?? apiMessage) + } switch nsError.code { case 401, 403: @@ -228,6 +237,11 @@ private extension ApiClient { throw NetworkError.invalidResponse } + let payload = serverPayload(from: data) + if isSessionExpiredPayload(code: payload?.code, message: payload?.message) { + throw NetworkError.unauthorized(payload?.message) + } + if http.statusCode == 429 { let retryAfter = Int(http.value(forHTTPHeaderField: "Retry-After") ?? "") throw NetworkError.rateLimited(retryAfter) @@ -283,19 +297,44 @@ private extension ApiClient { } func serverMessage(from data: Data) -> String? { + let payload = serverPayload(from: data) + if let message = payload?.message, message.isEmpty == false { + return message + } + if let code = payload?.code, code.isEmpty == false { + return "Erro: \(code)" + } + return String(data: data, encoding: .utf8) + } + + func serverPayload(from data: Data) -> ApiErrorDescriptor? { if let envelope = try? JSONDecoder().decode(ApiEnvelope.self, from: data) { - return envelope.message + return ApiErrorDescriptor(code: envelope.code, message: envelope.message) } if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { - if let message = object["message"] as? String { - return message - } - if let code = object["code"] as? String { - return "Erro: \(code)" + let code = object["code"] as? String + let message = object["message"] as? String + if code != nil || message != nil { + return ApiErrorDescriptor(code: code, message: message) } } - return String(data: data, encoding: .utf8) + + return nil + } + + func isSessionExpiredPayload(code: String?, message: String?) -> Bool { + let normalizedCode = (code ?? "").lowercased() + let normalizedMessage = (message ?? "").lowercased() + + if normalizedCode.contains("auth") || normalizedCode.contains("token") || normalizedCode.contains("unauthorized") { + return true + } + if normalizedMessage.contains("token") && (normalizedMessage.contains("expir") || normalizedMessage.contains("invalid") || normalizedMessage.contains("sess")) { + return true + } + + return false } #if canImport(LCEssentials) && os(iOS) @@ -318,6 +357,15 @@ private extension ApiClient { return nil } + + func serverPayload(from error: NSError) -> ApiErrorDescriptor? { + if let reason = error.userInfo[NSLocalizedFailureReasonErrorKey] as? String, + let data = reason.data(using: .utf8), + let payload = serverPayload(from: data) { + return payload + } + return nil + } #endif func buildURL(path: String, query: [URLQueryItem]) throws -> URL { diff --git a/pedi-foods/Sources/PediFoods/Services/ApiService.swift b/pedi-foods/Sources/PediFoods/Services/ApiService.swift index d0289e1..c5482f9 100644 --- a/pedi-foods/Sources/PediFoods/Services/ApiService.swift +++ b/pedi-foods/Sources/PediFoods/Services/ApiService.swift @@ -32,14 +32,40 @@ final class ApiService { return try await client.send(req) } catch let error as NetworkError { if case .unauthorized(let message) = error { - tokenStore.clear() - NotificationCenter.default.post(name: .sessionExpired, object: message) + expireSession(message) throw ApiServiceError.sessionExpired(message) } throw error } } + private func sendEnvelope(_ req: ApiRequest) async throws -> ApiEnvelope { + let envelope: ApiEnvelope = try await send(req) + if isSessionExpiredEnvelope(envelope) { + expireSession(envelope.message) + throw ApiServiceError.sessionExpired(envelope.message) + } + return envelope + } + + private func isSessionExpiredEnvelope(_ envelope: ApiEnvelope) -> Bool { + guard envelope.error else { return false } + let code = (envelope.code ?? "").lowercased() + let message = (envelope.message ?? "").lowercased() + if code.contains("auth") || code.contains("token") || code.contains("unauthorized") { + return true + } + if message.contains("token") && (message.contains("expir") || message.contains("invalid") || message.contains("sess")) { + return true + } + return false + } + + private func expireSession(_ message: String?) { + tokenStore.clear() + NotificationCenter.default.post(name: .sessionExpired, object: message) + } + // MARK: - Auth func registerCustomer(name: String, email: String, phoneNumber: String, birthDate: String? = nil) async throws -> ApiEnvelope { @@ -53,19 +79,19 @@ final class ApiService { } 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) + return try await sendEnvelope(req) } func requestOtp(email: String, phoneNumber: String) async throws -> ApiEnvelope { 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) + return try await sendEnvelope(req) } func validateOtp(email: String, phoneNumber: String, otp: String) async throws -> ApiEnvelope { let body = try makeLoginBody(email: email, phoneNumber: phoneNumber, otp: otp) let req = ApiRequest(path: "/api/customer/login", method: "POST", module: .customer, requiresAuth: false, body: body) - let response: ApiEnvelope = try await send(req) + let response: ApiEnvelope = try await sendEnvelope(req) if let token = response.result?.token { tokenStore.jwt = token } @@ -97,7 +123,7 @@ final class ApiService { func profile() async throws -> ApiEnvelope { let req = ApiRequest(path: "/api/customer/profile", method: "GET", module: .customer, requiresAuth: true) - return try await send(req) + return try await sendEnvelope(req) } func addCustomerAddress(_ address: CustomerAddress) async throws -> ApiEnvelope { @@ -112,7 +138,7 @@ final class ApiService { 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) + return try await sendEnvelope(req) } func lookupZipCode(_ zipCode: String) async throws -> ApiEnvelope { @@ -128,16 +154,22 @@ final class ApiService { } let req = ApiRequest(path: "/api/public/cep/\(formatted)", method: "GET", module: .none, requiresAuth: true) - return try await send(req) + return try await sendEnvelope(req) } // MARK: - Stores - func listStores(lat: Double, lng: Double, category: String? = nil, search: String? = nil) async throws -> ApiEnvelope<[StoreSummary]> { - var items = [ - URLQueryItem(name: "lat", value: String(lat)), - URLQueryItem(name: "lng", value: String(lng)) - ] + func listPublicCategories() async throws -> ApiEnvelope<[PublicCategory]> { + let req = ApiRequest(path: "/api/public/categories", method: "GET", module: .none, requiresAuth: false) + return try await sendEnvelope(req) + } + + func listStores(lat: Double? = nil, lng: Double? = nil, category: String? = nil, search: String? = nil) async throws -> ApiEnvelope<[StoreSummary]> { + var items: [URLQueryItem] = [] + if let lat, let lng { + items.append(URLQueryItem(name: "lat", value: String(lat))) + items.append(URLQueryItem(name: "lng", value: String(lng))) + } if let category { items.append(URLQueryItem(name: "category", value: category)) } @@ -145,7 +177,7 @@ final class ApiService { items.append(URLQueryItem(name: "search", value: search)) } let req = ApiRequest(path: "/api/app/stores", method: "GET", module: .app, requiresAuth: true, queryItems: items) - return try await send(req) + return try await sendEnvelope(req) } } @@ -222,6 +254,12 @@ struct StoreSummary: Decodable { let statusLabel: String? } +struct PublicCategory: Decodable { + let id: String + let name: String + let icon: String? +} + struct CustomerProfileUpdatePayload: Encodable { let addressBook: [CustomerAddressPayload] diff --git a/pedi-foods/Sources/PediFoods/Services/SessionStateStore.swift b/pedi-foods/Sources/PediFoods/Services/SessionStateStore.swift new file mode 100644 index 0000000..8dcf9e1 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Services/SessionStateStore.swift @@ -0,0 +1,139 @@ +import Foundation + +private struct PersistedAddressState: Codable { + let selectedId: String? + let display: String + let latitude: Double? + let longitude: Double? +} + +enum SessionStateStore { + private static let legacyAddressKey = "session.address.state.v1" + private static let addressKeyPrefix = "session.address.state.v2." + private static let activeUserKey = "session.active.user.v1" + + static func makeUserKey(profileId: String?, email: String?) -> String? { + let id = (profileId ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + if id.isEmpty == false { + return "id:\(id)" + } + + let mail = (email ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + if mail.isEmpty == false { + return "email:\(mail)" + } + + return nil + } + + static func setActiveUserKey(_ userKey: String?) { + let trimmed = (userKey ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + UserDefaults.standard.removeObject(forKey: activeUserKey) + } else { + UserDefaults.standard.set(trimmed, forKey: activeUserKey) + } + } + + static func loadActiveUserKey() -> String? { + let value = UserDefaults.standard.string(forKey: activeUserKey)? + .trimmingCharacters(in: .whitespacesAndNewlines) + if let value, value.isEmpty == false { + return value + } + return nil + } + + private static func addressStorageKey(for userKey: String?) -> String { + let key = (userKey ?? loadActiveUserKey() ?? "anonymous") + .trimmingCharacters(in: .whitespacesAndNewlines) + let safe = key.replacingOccurrences(of: " ", with: "_") + return addressKeyPrefix + safe + } + + static func loadAddress() -> AddressState? { + let defaults = UserDefaults.standard + let activeKey = loadActiveUserKey() + let scopedKey = addressStorageKey(for: activeKey) + + if let data = defaults.data(forKey: scopedKey), + let decoded = try? JSONDecoder().decode(PersistedAddressState.self, from: data) { + return AddressState( + selectedId: decoded.selectedId, + display: decoded.display.isEmpty ? "Defina seu endereco" : decoded.display, + latitude: decoded.latitude, + longitude: decoded.longitude, + onboardingMessage: nil + ) + } + + // Backward-compatible fallback for data persisted before user scoping. + let anonymousKey = addressStorageKey(for: "anonymous") + if let data = defaults.data(forKey: anonymousKey), + let decoded = try? JSONDecoder().decode(PersistedAddressState.self, from: data) { + let recovered = AddressState( + selectedId: decoded.selectedId, + display: decoded.display.isEmpty ? "Defina seu endereco" : decoded.display, + latitude: decoded.latitude, + longitude: decoded.longitude, + onboardingMessage: nil + ) + + // Migrate anonymous cache into the current active user namespace. + if let activeKey, activeKey.isEmpty == false { + let payload = PersistedAddressState( + selectedId: recovered.selectedId, + display: recovered.display, + latitude: recovered.latitude, + longitude: recovered.longitude + ) + if let migratedData = try? JSONEncoder().encode(payload) { + defaults.set(migratedData, forKey: scopedKey) + } + } + + return recovered + } + + guard let data = defaults.data(forKey: legacyAddressKey), + let decoded = try? JSONDecoder().decode(PersistedAddressState.self, from: data) else { + return nil + } + + let migrated = AddressState( + selectedId: decoded.selectedId, + display: decoded.display.isEmpty ? "Defina seu endereco" : decoded.display, + latitude: decoded.latitude, + longitude: decoded.longitude, + onboardingMessage: nil + ) + saveAddress(migrated) + defaults.removeObject(forKey: legacyAddressKey) + return migrated + } + + static func saveAddress(_ state: AddressState) { + let payload = PersistedAddressState( + selectedId: state.selectedId, + display: state.display, + latitude: state.latitude, + longitude: state.longitude + ) + guard let data = try? JSONEncoder().encode(payload) else { return } + UserDefaults.standard.set(data, forKey: addressStorageKey(for: nil)) + } + + static func clearAddress() { + let defaults = UserDefaults.standard + defaults.removeObject(forKey: addressStorageKey(for: nil)) + defaults.removeObject(forKey: legacyAddressKey) + } + + static func clearActiveUser() { + UserDefaults.standard.removeObject(forKey: activeUserKey) + } +} diff --git a/pedi-foods/Sources/PediFoods/Views/Auth/OtpView.swift b/pedi-foods/Sources/PediFoods/Views/Auth/OtpView.swift index 488ac3d..896a78b 100644 --- a/pedi-foods/Sources/PediFoods/Views/Auth/OtpView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Auth/OtpView.swift @@ -187,7 +187,7 @@ struct OtpView: View { SnackbarCenter.shared.show(title: "Login realizado com sucesso.", style: .success, icon: "checkmark.seal.fill", duration: 2.5) appState.session.isAuthenticated = true appState.session.jwt = response.result?.token - appState.profile.email = email + hydrateUserState(from: response.result?.customer) routeAfterLogin() } } catch { @@ -221,6 +221,44 @@ struct OtpView: View { root = .main } + private func hydrateUserState(from customer: CustomerProfile?) { + if let customer { + appState.profile.id = customer.id + appState.profile.name = customer.name + appState.profile.email = customer.email + appState.profile.phone = customer.phoneNumber ?? "" + SessionStateStore.setActiveUserKey( + SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email) + ) + + if let preferred = customer.addressBook?.first { + appState.address.selectedId = preferred.id + let label = (preferred.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + appState.address.display = label.isEmpty ? "Defina seu endereco" : label + if let lat = preferred.latLong?.first, let lng = preferred.latLong?.dropFirst().first { + appState.address.latitude = lat + appState.address.longitude = lng + } + SessionStateStore.saveAddress(appState.address) + } else if let cached = SessionStateStore.loadAddress() { + appState.address = cached + } else { + appState.address = AddressState() + } + return + } + + appState.profile.email = email + SessionStateStore.setActiveUserKey( + SessionStateStore.makeUserKey(profileId: nil, email: email) + ) + if let cached = SessionStateStore.loadAddress() { + appState.address = cached + } else { + appState.address = AddressState() + } + } + private func hasConfiguredAddress() -> Bool { if appState.address.selectedId != nil { return true diff --git a/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift b/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift index 425505a..481dc0a 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift @@ -1,6 +1,7 @@ import Foundation import SwiftUI #if os(iOS) +import LCEssentials import UIKit #endif @@ -9,7 +10,7 @@ struct HomeView: View { @State var searchText = "" @State var selectedCategory = "all" @State var categories: [CategoryModel] = [ - .init(id: "all", title: "Todas", systemIcon: "line.3.horizontal.decrease.circle") + .init(id: "all", title: "Todas", systemIcon: "line.3.horizontal.decrease.circle", emojiIcon: nil) ] @State var scrollOffset: CGFloat = 0 @State var collapseBaseOffset: CGFloat = 0 @@ -120,7 +121,8 @@ struct HomeView: View { Task { await bootstrapStoresFlow( forceLocationRefresh: true, - category: selectedCategory == "all" ? nil : selectedCategory + category: selectedCategory == "all" ? nil : selectedCategory, + refreshCategories: true ) } } @@ -166,13 +168,15 @@ struct HomeView: View { .foregroundStyle(AppColors.brandDark) ) - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .center, spacing: 4) { Text("DELIVERY LOCATION") .font(AppTypography.overline) .tracking(AppTypography.captionLetterSpacing) .foregroundStyle(AppColors.brandSoft) + .multilineTextAlignment(.center) Button { + appState.address.onboardingMessage = nil appState.activeModal = .addressPicker } label: { HStack(spacing: 6) { @@ -186,8 +190,7 @@ struct HomeView: View { } .buttonStyle(.plain) } - - Spacer() + .frame(maxWidth: .infinity, alignment: .center) Circle() .fill(Color.white.opacity(0.18)) @@ -199,13 +202,21 @@ struct HomeView: View { } .opacity(topRowOpacity) .offset(y: collapseProgress * -12) - - Text("O que vai querer \npedir hoje?") - .font(AppTypography.heading2) - .foregroundStyle(AppColors.textInverse) - .opacity(titleOpacity) - .offset(y: collapseProgress * -20) - + + if UIDevice().modelName.lowercased().contains("se") || UIDevice().modelName.lowercased().contains("16e") { + Text("O que vai querer \npedir hoje?") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textInverse) + .opacity(titleOpacity) + .offset(y: collapseProgress * -20) + } else { + Text("O que vai querer pedir hoje?\n ") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textInverse) + .opacity(titleOpacity) + .offset(y: collapseProgress * -20) + } + SearchBar(placeholder: "Search menu, restaurant or craving", text: $searchText) { appState.activeModal = .filters } @@ -240,6 +251,7 @@ struct HomeView: View { CategoryChip( title: category.title, systemIcon: category.systemIcon, + emojiIcon: category.emojiIcon, isActive: category.id == selectedCategory ) .onTapGesture { @@ -293,10 +305,27 @@ struct HomeView: View { category: store.category ?? "Loja", promoText: nil, isFavorite: appState.favorites.storeIds.contains(store.id), - iconName: "storefront" + iconName: "storefront", + imageURL: resolveStoreImageURL(logo: store.logo, cover: store.cover) ) } + private func resolveStoreImageURL(logo: String?, cover: String?) -> String? { + let preferred = [logo, cover] + .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } + .first { $0.isEmpty == false } + + guard let raw = preferred else { return nil } + + if raw.lowercased().hasPrefix("http://") || raw.lowercased().hasPrefix("https://") { + return raw + } + + let base = ApiConfig.baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + let path = raw.hasPrefix("/") ? raw : "/\(raw)" + return "\(base)\(path)" + } + private func formatDistance(_ distance: Double?) -> String { guard let distance else { return "Distância indisponível" } if distance >= 1 { @@ -314,7 +343,10 @@ struct HomeView: View { isLoadingStores = true storesError = nil - guard let coordinate = await resolveCoordinates(forceRefresh: forceLocationRefresh) else { + let coordinate = await resolveCoordinates(forceRefresh: forceLocationRefresh) + let hasAddress = hasConfiguredAddress() + + if coordinate == nil && hasAddress == false { isLoadingStores = false stores = [] storesError = "Defina um endereço para visualizar os estabelecimentos mais próximos." @@ -324,7 +356,11 @@ struct HomeView: View { } do { - let response = try await ApiService().listStores(lat: coordinate.0, lng: coordinate.1, category: category) + let response = try await ApiService().listStores( + lat: coordinate?.0, + lng: coordinate?.1, + category: category + ) isLoadingStores = false if response.error { stores = [] @@ -333,8 +369,8 @@ struct HomeView: View { } let results = response.result ?? [] stores = results - if refreshCategories { - categories = buildCategories(from: results) + if refreshCategories || (category == nil && categories.count <= 1) { + await loadHomeCategories(withFallbackStores: results) if categories.contains(where: { $0.id == selectedCategory }) == false { selectedCategory = "all" } @@ -349,7 +385,7 @@ struct HomeView: View { private func buildCategories(from stores: [StoreSummary]) -> [CategoryModel] { var unique: [CategoryModel] = [ - .init(id: "all", title: "Todas", systemIcon: "line.3.horizontal.decrease.circle") + .init(id: "all", title: "Todas", systemIcon: "line.3.horizontal.decrease.circle", emojiIcon: nil) ] var seen = Set() @@ -359,11 +395,59 @@ struct HomeView: View { let dedupe = raw.lowercased() if seen.contains(dedupe) { continue } seen.insert(dedupe) - unique.append(.init(id: raw, title: raw, systemIcon: categoryIcon(for: raw))) + unique.append(.init(id: raw, title: raw, systemIcon: categoryIcon(for: raw), emojiIcon: nil)) } return unique } + @MainActor + private func loadHomeCategories(withFallbackStores stores: [StoreSummary]) async { + do { + let response = try await ApiService().listPublicCategories() + if response.error == false, let remote = response.result, remote.isEmpty == false { + categories = mapPublicCategories(remote) + return + } + } catch { + // Fallback handled below. + } + + categories = buildCategories(from: stores) + } + + private func mapPublicCategories(_ remote: [PublicCategory]) -> [CategoryModel] { + var mapped: [CategoryModel] = [] + var seen = Set() + + for item in remote { + let id = item.id.trimmingCharacters(in: .whitespacesAndNewlines) + let title = item.name.trimmingCharacters(in: .whitespacesAndNewlines) + if id.isEmpty || title.isEmpty { continue } + if seen.contains(id.lowercased()) { continue } + seen.insert(id.lowercased()) + mapped.append( + .init( + id: id, + title: title, + systemIcon: id.lowercased() == "all" ? "line.3.horizontal.decrease.circle" : nil, + emojiIcon: item.icon + ) + ) + } + + if mapped.contains(where: { $0.id.lowercased() == "all" }) == false { + mapped.insert(.init(id: "all", title: "Todas", systemIcon: "line.3.horizontal.decrease.circle", emojiIcon: nil), at: 0) + } else { + mapped.sort { lhs, rhs in + if lhs.id.lowercased() == "all" { return true } + if rhs.id.lowercased() == "all" { return false } + return lhs.title < rhs.title + } + } + + return mapped + } + 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" } @@ -440,18 +524,25 @@ struct HomeView: View { struct CategoryModel: Identifiable { let id: String let title: String - let systemIcon: String + let systemIcon: String? + let emojiIcon: String? } struct CategoryChip: View { let title: String - let systemIcon: String + let systemIcon: String? + let emojiIcon: String? let isActive: Bool var body: some View { HStack(spacing: 8) { - Image(systemName: systemIcon) - .font(.caption) + if let emojiIcon, emojiIcon.isEmpty == false { + Text(emojiIcon) + .font(.body) + } else if let systemIcon, systemIcon.isEmpty == false { + Image(systemName: systemIcon) + .font(.caption) + } Text(title) .font(AppTypography.heading3) } diff --git a/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift b/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift index ed40ce2..542bbf6 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift @@ -32,8 +32,8 @@ struct ProfileView: View { SecondaryButton(title: "Sair") { tokenStore.clear() - appState.session.isAuthenticated = false - appState.session.jwt = nil + SessionStateStore.clearActiveUser() + appState = AppState() root = .auth } .padding(.horizontal, 20) @@ -81,6 +81,7 @@ struct ProfileView: View { struct AddressesView: View { let message: String? @Binding var appState: AppState + var selectionMode: Bool = false @Environment(\.dismiss) var dismiss @State var isLoading = false @State var errorMessage: String? = nil @@ -127,7 +128,23 @@ struct AddressesView: View { .padding(.top, 24) } else { ForEach(Array(addresses.enumerated()), id: \.offset) { index, address in - AddressCard(item: addressToListItem(address, isPrimary: index == 0)) + let isSelected: Bool = { + if let selectedId = appState.address.selectedId { + return address.id == selectedId + } + return index == 0 + }() + + if selectionMode { + Button { + selectAddress(address) + } label: { + AddressCard(item: addressToListItem(address, isPrimary: isSelected)) + } + .buttonStyle(.plain) + } else { + AddressCard(item: addressToListItem(address, isPrimary: isSelected)) + } } } } @@ -159,6 +176,7 @@ struct AddressesView: View { appState.address.latitude = nil appState.address.longitude = nil } + SessionStateStore.saveAddress(appState.address) SnackbarCenter.shared.show(title: "Endereço adicionado com sucesso.", style: .success, icon: "checkmark.seal.fill", duration: 3.0) } } @@ -219,6 +237,26 @@ struct AddressesView: View { } } + private func selectAddress(_ address: CustomerAddress) { + appState.address.selectedId = address.id + let label = (address.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + appState.address.display = label.isEmpty ? "Defina seu endereco" : label + + if let lat = address.latLong?.first, let lng = address.latLong?.dropFirst().first { + appState.address.latitude = lat + appState.address.longitude = lng + } else { + appState.address.latitude = nil + appState.address.longitude = nil + } + + SessionStateStore.saveAddress(appState.address) + + if selectionMode { + dismiss() + } + } + func addressToListItem(_ address: CustomerAddress, isPrimary: Bool) -> AddressListItem { let title = address.label?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? (address.label ?? "Endereço") : "Endereço" let line1 = [address.address, address.number] @@ -266,14 +304,26 @@ struct AddressesView: View { return } - addresses = response.result?.addressBook ?? [] - if let first = addresses.first { - appState.address.selectedId = first.id - appState.address.display = first.label ?? "Defina seu endereco" - if let lat = first.latLong?.first, let lng = first.latLong?.dropFirst().first { + if let customer = response.result { + appState.profile.id = customer.id + appState.profile.name = customer.name + appState.profile.email = customer.email + appState.profile.phone = customer.phoneNumber ?? appState.profile.phone + SessionStateStore.setActiveUserKey( + SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email) + ) + addresses = customer.addressBook ?? [] + } else { + addresses = [] + } + if let selected = addresses.first(where: { $0.id == appState.address.selectedId }) ?? addresses.first { + appState.address.selectedId = selected.id + appState.address.display = selected.label ?? "Defina seu endereco" + if let lat = selected.latLong?.first, let lng = selected.latLong?.dropFirst().first { appState.address.latitude = lat appState.address.longitude = lng } + SessionStateStore.saveAddress(appState.address) } } catch { errorMessage = error.localizedDescription