diff --git a/.gitea/workflows/beta.yml b/.gitea/workflows/beta.yml index d406ee9..64c6a52 100644 --- a/.gitea/workflows/beta.yml +++ b/.gitea/workflows/beta.yml @@ -35,7 +35,7 @@ jobs: - name: Write App Store Connect API key run: | - cat > Darwin/fastlane/apikey.json < fastlane/apikey.json < Darwin/fastlane/apikey.json < fastlane/apikey.json < - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Darwin/Sources/Main.swift b/Darwin/Sources/Main.swift deleted file mode 100644 index bbf005a..0000000 --- a/Darwin/Sources/Main.swift +++ /dev/null @@ -1,86 +0,0 @@ -import SwiftUI -import PediFoods - -private typealias AppRootView = PediFoodsRootView -private typealias AppDelegate = PediFoodsAppDelegate - -/// The entry point to the app simply loads the App implementation from SPM module. -@main struct AppMain: App { - @AppDelegateAdaptor(AppMainDelegate.self) var appDelegate - @Environment(\.scenePhase) private var scenePhase - - var body: some Scene { - WindowGroup { - AppRootView() - } - .onChange(of: scenePhase) { oldPhase, newPhase in - switch newPhase { - case .active: - AppDelegate.shared.onResume() - case .inactive: - AppDelegate.shared.onPause() - case .background: - AppDelegate.shared.onStop() - @unknown default: - print("unknown app phase: \(newPhase)") - } - } - } -} - -#if canImport(UIKit) -typealias AppDelegateAdaptor = UIApplicationDelegateAdaptor -typealias AppMainDelegateBase = UIApplicationDelegate -typealias AppType = UIApplication -#elseif canImport(AppKit) -typealias AppDelegateAdaptor = NSApplicationDelegateAdaptor -typealias AppMainDelegateBase = NSApplicationDelegate -typealias AppType = NSApplication -#endif - -@MainActor final class AppMainDelegate: NSObject, AppMainDelegateBase { - let application = AppType.shared - - #if canImport(UIKit) - func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { - AppDelegate.shared.onInit() - return true - } - - func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { - AppDelegate.shared.onLaunch() - return true - } - - func applicationWillTerminate(_ application: UIApplication) { - AppDelegate.shared.onDestroy() - } - - func applicationDidReceiveMemoryWarning(_ application: UIApplication) { - AppDelegate.shared.onLowMemory() - } - - // support for SkipNotify.fetchNotificationToken() - - func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { - NotificationCenter.default.post(name: NSNotification.Name("didRegisterForRemoteNotificationsWithDeviceToken"), object: application, userInfo: ["deviceToken": deviceToken]) - } - - func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: any Error) { - NotificationCenter.default.post(name: NSNotification.Name("didFailToRegisterForRemoteNotificationsWithError"), object: application, userInfo: ["error": error]) - } - #elseif canImport(AppKit) - func applicationWillFinishLaunching(_ notification: Notification) { - AppDelegate.shared.onInit() - } - - func applicationDidFinishLaunching(_ notification: Notification) { - AppDelegate.shared.onLaunch() - } - - func applicationWillTerminate(_ application: Notification) { - AppDelegate.shared.onDestroy() - } - #endif - -} diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..44b39c8 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,86 @@ +![](loverde_company_logo_full.png) + +Copyright (C) Loverde Company - All Rights Reserved +---------- + +# ⚠️ INFORMAÇÕES CONFIDENCIAIS E PROPRIEDADE INTELECTUAL ⚠️ + +## 🚨 AVISO LEGAL IMPORTANTE + +**ATENÇÃO: Este repositório contém informações CONFIDENCIAIS e PROPRIETÁRIAS.** + +### ⛔ PROIBIÇÕES ABSOLUTAS + +- **NÃO** compartilhe este código com terceiros +- **NÃO** faça fork, clone ou cópia sem autorização expressa +- **NÃO** utilize qualquer parte deste código em outros projetos +- **NÃO** distribua, venda ou comercialize este software +- **NÃO** reverse engineer ou descompile o código +- **NÃO** acesse sem permissão explícita do proprietário + +### 🔒 DIREITOS DE PROPRIEDADE INTELECTUAL + +Este software é propriedade exclusiva de **Daniel Arantes Loverde** e está protegido por: + +- **Lei de Propriedade Intelectual (Lei 9.610/98)** +- **Lei de Software (Lei 9.609/98)** +- **Lei Geral de Proteção de Dados (LGPD - Lei 13.709/18)** +- **Direitos Autorais e Patentes** + +### ⚖️ CONSEQUÊNCIAS LEGAIS + +O acesso não autorizado, uso indevido, cópia ou distribuição deste código constitui: + +- **CRIME** de violação de propriedade intelectual +- **CRIME** de roubo de propriedade intelectual +- **CRIME** de violação de direitos autorais +- **CRIME** de acesso não autorizado a sistema informatizado + +**Penas previstas: 2 a 5 anos de reclusão + multa** + +### 🛡️ MEDIDAS DE SEGURANÇA + +- **Monitoramento**: Todo acesso é registrado e monitorado +- **Rastreamento**: IPs e atividades são rastreados +- **Logs**: Sistema completo de auditoria +- **Criptografia**: Dados protegidos com AES-256 +- **Autenticação**: Acesso restrito com JWT + OTP + +### 📧 CONTATO AUTORIZADO + +**ÚNICO CONTATO VÁLIDO:** +- **Email**: daniel@loverde.com.br +- **Autorização**: Apenas via email oficial + +### 🔐 CLASSIFICAÇÃO + +**NÍVEL DE CONFIDENCIALIDADE: MÁXIMO** +- **Restrito**: Apenas para o proprietário +- **Não Divulgar**: Informações proprietárias +- **Não Compartilhar**: Código fonte protegido + +--- + +## ⚠️ DISCLAIMER FINAL + +**Ao acessar este repositório, você concorda que:** + +1. **Reconhece** que este é um software proprietário +2. **Compromete-se** a não violar os direitos de propriedade intelectual +3. **Aceita** as consequências legais por qualquer violação +4. **Entende** que o acesso é monitorado e rastreado +5. **Concorda** em respeitar todas as leis aplicáveis + +--- + +*Este documento é parte integrante do software e deve ser mantido confidencial.* + +--- + +This source code is protected under international copyright law. All rights +reserved and protected by the copyright holders. +This file is confidential and only available to authorized individuals with the +permission of the copyright holders. If you encounter this file and do not have +permission, please contact the copyright holders and delete this file. + +Autor: Daniel Arantes Loverde diff --git a/NotificationServiceExtension/Info.plist b/NotificationServiceExtension/Info.plist new file mode 100644 index 0000000..4f01c63 --- /dev/null +++ b/NotificationServiceExtension/Info.plist @@ -0,0 +1,31 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + NotificationService + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionPointIdentifier + com.apple.usernotifications.service + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).NotificationService + + + diff --git a/NotificationServiceExtension/NotificationService.swift b/NotificationServiceExtension/NotificationService.swift new file mode 100644 index 0000000..ad5c69a --- /dev/null +++ b/NotificationServiceExtension/NotificationService.swift @@ -0,0 +1,60 @@ +import UserNotifications + +/// Rich (image) push — see docs/api/push-notifications-integration-guide.md §4.3. +/// Only fires when Atomenta's push payload sets `"mutable-content": 1`, which +/// it does by default on any push carrying an image (real wire key is +/// `"image"`, not the guide's `"imageUrl"` — verified against +/// `PushNotificationService.dispatchApns`/`dispatchApnsMany` in Atomenta). +/// `@unchecked Sendable`: the extension process only ever runs one +/// `didReceive`/`serviceExtensionTimeWillExpire` cycle at a time — there's no +/// real concurrent access to `contentHandler`/`bestAttemptContent` to guard +/// against, just a background download completion handing back to this +/// instance. +final class NotificationService: UNNotificationServiceExtension, @unchecked Sendable { + private var contentHandler: ((UNNotificationContent) -> Void)? + private var bestAttemptContent: UNMutableNotificationContent? + + override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { + self.contentHandler = contentHandler + let mutableContent = (request.content.mutableCopy() as? UNMutableNotificationContent) ?? UNMutableNotificationContent() + bestAttemptContent = mutableContent + + guard let imageURLString = request.content.userInfo["image"] as? String, + let imageURL = URL(string: imageURLString) else { + deliver() + return + } + + URLSession.shared.downloadTask(with: imageURL) { [weak self] location, _, _ in + if let location, let attachment = Self.attachment(fromDownloadedFile: location) { + self?.bestAttemptContent?.attachments = [attachment] + } + self?.deliver() + }.resume() + } + + /// The system calls this if `didReceive` doesn't finish within its time + /// budget — must still deliver the best content built so far, since + /// `contentHandler` is contractually required to run exactly once. + override func serviceExtensionTimeWillExpire() { + deliver() + } + + private func deliver() { + guard let bestAttemptContent else { return } + contentHandler?(bestAttemptContent) + contentHandler = nil + } + + private static func attachment(fromDownloadedFile location: URL) -> UNNotificationAttachment? { + let tmpURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension("jpg") + do { + try FileManager.default.moveItem(at: location, to: tmpURL) + return try UNNotificationAttachment(identifier: "image", url: tmpURL) + } catch { + return nil + } + } +} diff --git a/PROFILE_PLAN.md b/PROFILE_PLAN.md deleted file mode 100644 index b1931cf..0000000 --- a/PROFILE_PLAN.md +++ /dev/null @@ -1,31 +0,0 @@ -e# Plano - Tela de Perfil - -Legenda: -- 🟢 Concluído -- 🟡 Em validação -- 🔴 Pendente - -Última atualização: 2026-02-13 - -## Entregas - -- 🟢 Redesign do conteúdo da tela de perfil no estilo do modelo (header + cards de menu) -- 🟢 Header ajustada para encostar no topo e nas laterais -- 🟢 Redução de escala dos itens da lista (ícones, fonte, altura e paddings) -- 🟢 Faixa de proteção no rodapé para não deixar conteúdo por trás da tabbar -- 🟢 Confirmação com alert padrão ao tocar em "Sair da Conta" -- 🟢 Fluxo de editar endereço reutilizando a tela de novo endereço -- 🟢 Swipe para excluir endereço com compatibilidade Android (gesto custom) -- 🟢 Polimento visual do swipe/delete (sem vazamento de fundo vermelho) - -## Pendências - -- 🟡 Validação visual final no device (iOS e Android) com screenshots finais -- 🔴 Definir destinos reais dos itens ainda placeholder: - - Meus Cartões - - Minhas Avaliações - - Cupons de Desconto - - Configurações/Ajuda (confirmar nomenclatura final) -- 🔴 Corrigir build global do projeto (dependência `LCEssentials` ausente em `StoreDetailView.swift`) - -Autor: Daniel Arantes Loverde diff --git a/PROJECT_ANALYSIS_SKIP.md b/PROJECT_ANALYSIS_SKIP.md deleted file mode 100644 index 6e7b20b..0000000 --- a/PROJECT_ANALYSIS_SKIP.md +++ /dev/null @@ -1,52 +0,0 @@ -# Análise Técnica — Arquitetura Skip (PediFoods) - -## Entendimento confirmado - -O projeto usa **Skip com fonte principal em Swift/SwiftUI** e geração/transpilação para Android. - -Diretriz operacional validada: - -1. **iOS é a plataforma prioritária** de desenvolvimento e validação. -2. **Android entra depois**, com adaptações isoladas por compilação condicional. -3. Toda alteração deve preservar comportamento de iOS e evitar regressão cross-platform. - -## Como o projeto está estruturado - -1. Código compartilhado principal: `Sources/PediFoods/*` (Swift/SwiftUI). -2. Entry point iOS: `Darwin/Sources/Main.swift`. -3. Entry point Android: `Android/app/src/main/kotlin/Main.kt`. -4. Configuração Skip: `Sources/PediFoods/Skip/skip.yml` (`mode: native`). -5. Build/transpilação Android via plugin Skip (`skipstone`) no target Swift Package. - -## Evidências no código (padrão já em uso) - -1. Uso extensivo de condicionais: - - `#if os(iOS)` - - `#if os(Android)` - - `#if canImport(UIKit)` -2. Dependência iOS-only no `Package.swift`: - - `LCEssentials` com `condition: .when(platforms: [.iOS])` -3. Exemplo claro de adaptação segura: - - `ContentView.swift`: `SnackbarCenter` com implementação distinta para Android e iOS. - -## Regra prática para próximas tarefas - -1. Implementar primeiro para iOS. -2. Validar iOS (build/execução/comportamento). -3. Só depois adaptar Android, sempre em bloco condicional separado. -4. Nunca alterar caminho iOS por causa de ajuste Android. -5. Em mudanças de UI de inicialização (ex.: splash), tratar iOS e Android em fluxos independentes. - -## Nota específica para Splash - -Estado atual identificado: - -1. iOS com Launch Screen gerado automaticamente (`INFOPLIST_KEY_UILaunchScreen_Generation = YES`). -2. Android sem implementação custom de splash dedicada no `res/values` + tema de launch. - -Implicação: - -1. Mudança de splash para iOS pode ser feita sem tocar Android. -2. Quando Android for tratado, deve entrar em camada própria (tema/recursos Android), sem impactar o fluxo iOS. - -Autor: Daniel Arantes Loverde diff --git a/Package.swift b/Package.swift deleted file mode 100644 index e63b9a8..0000000 --- a/Package.swift +++ /dev/null @@ -1,20 +0,0 @@ -// swift-tools-version: 6.1 -import PackageDescription - -let package = Package( - name: "pedi-foods", - defaultLocalization: "en", - platforms: [.iOS(.v17), .macOS(.v14)], - products: [ - .library(name: "PediFoods", type: .dynamic, targets: ["PediFoods"]), - ], - dependencies: [ - .package(url: "https://git.loverde.com.br/Loverde-Company-LTDA/LCEssentials.git", from: "1.0.5") - ], - targets: [ - .target(name: "PediFoods", dependencies: [ - .product(name: "LCEssentials", package: "lcessentials") - ], resources: [.process("Resources")]), - .testTarget(name: "PediFoodsTests", dependencies: ["PediFoods"]), - ] -) diff --git a/PediFoods.xcodeproj/project.pbxproj b/PediFoods.xcodeproj/project.pbxproj new file mode 100644 index 0000000..3a090eb --- /dev/null +++ b/PediFoods.xcodeproj/project.pbxproj @@ -0,0 +1,1296 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 01028EA36060E97C0CCF0CB0 /* FavoritesDecodingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7457DAF1217112C23BE2336F /* FavoritesDecodingTests.swift */; }; + 01302F4B1F896EF3F28FBC07 /* PushNotificationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 172FED2F32A096B9CF2D5583 /* PushNotificationCoordinator.swift */; }; + 033958BC906741BE90CFB5EC /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F18F768F7A2A47111A85FE88 /* LoginView.swift */; }; + 03E6BA6F09890996F8CC4465 /* StoreDetailView+Logic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 103668F3D2C86F68A07D76E7 /* StoreDetailView+Logic.swift */; }; + 045B54D94A16E3F9679C13B7 /* PublicLocationPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DC5416AEBA77C43CF55ABA8 /* PublicLocationPickerView.swift */; }; + 07967250980FD2CD851395BB /* PizzaProductDetailSheet+Flow.swift in Sources */ = {isa = PBXBuildFile; fileRef = B35D1C07050150E305FAF09A /* PizzaProductDetailSheet+Flow.swift */; }; + 09FA9556CD659F93C25447DB /* AuthenticatedProfileNavigationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3A0787FEAE67318791D6CFD /* AuthenticatedProfileNavigationTests.swift */; }; + 0B8F7AFF495964AE89EE3442 /* FeatureControlServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5684E2E831A9A0D03BE34120 /* FeatureControlServiceTests.swift */; }; + 0DAC2E33C08E494991B01262 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 9786809975F4461F277BFBA6 /* Localizable.xcstrings */; }; + 1067B48BE2EEADA0358DEDB6 /* AddressesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 395EA4C12C0187DA59DFFC17 /* AddressesView.swift */; }; + 13743E96C483538ED45A442E /* OrderTrackingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 709FCA67231B4E8D8F976C87 /* OrderTrackingView.swift */; }; + 14A36A6DCCE57F0BBF3A539D /* DeepLinkDestinationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15D9ECEE8C0CF44FFABB47F0 /* DeepLinkDestinationTests.swift */; }; + 17C3BAA2101C546BF4D226C2 /* PediFoodsApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52BFF93F21509F1AD4F6676B /* PediFoodsApp.swift */; }; + 1AFC280D183E5DC53949C317 /* AuthenticatedSessionFlowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEAA5F0BA8EBC7F2D19472D6 /* AuthenticatedSessionFlowTests.swift */; }; + 1DBB6DD668615F514180CB15 /* UserProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2AD93DBB0F52B4B485C97BA /* UserProfileView.swift */; }; + 1F4F5728574CA1C69F86E43B /* ApiReviewModelsDecodingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D8F18894F32F9F498705B59 /* ApiReviewModelsDecodingTests.swift */; }; + 22E3BF78B8C2B22D14EABE8B /* ImageSourceResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A82497C63D507E82FE02693 /* ImageSourceResolverTests.swift */; }; + 231E40975C5428826E30D0B0 /* HomeViewComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79D2854B8062D96742CFDD99 /* HomeViewComponents.swift */; }; + 23BEEB340760F8CD30B9EC8E /* ApiOrderTrackingModelsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D56A28027450A094082177F /* ApiOrderTrackingModelsTests.swift */; }; + 26412DECECB585D0B330A6C4 /* ApiPizzaModelsDecodingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05564A16F6CA0EB731DF1A6E /* ApiPizzaModelsDecodingTests.swift */; }; + 279A489F01434E0523EC1BAA /* ApiService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 507E868458359BE0E4FF25F1 /* ApiService.swift */; }; + 2875895B473F547F711EB088 /* ApiOrderModelsDecodingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 194F7B60A98FEB5C2CE24CAB /* ApiOrderModelsDecodingTests.swift */; }; + 2892D23B9E7DB6D6310C66C6 /* AuthFlowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36AA0706B5951E99AE98E6A3 /* AuthFlowView.swift */; }; + 29094277C01B763317A53BC0 /* LaunchSplashView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0B079DFAB606DCA87A536C9 /* LaunchSplashView.swift */; }; + 29E376FB24A2F19361D5BB6A /* ApiCustomerPayloadModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 246FDD4C8985BF2F5C900001 /* ApiCustomerPayloadModels.swift */; }; + 2A1940BB0EDC3FF50127B39A /* SnackbarOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4745C12F14B695C77DFE178 /* SnackbarOverlay.swift */; }; + 2B202626C34FC9F5C87C0388 /* ApiOrderModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4625B70E3E8BEEB9D0B2042 /* ApiOrderModels.swift */; }; + 2F56755488E951C453AC2126 /* LCEssentials in Frameworks */ = {isa = PBXBuildFile; productRef = B8D5E8821C95A331C3A19DA1 /* LCEssentials */; }; + 2FCB3403A764AD74AC3BD47C /* LegalDocumentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AA914FB8B32B106911FD67B /* LegalDocumentTests.swift */; }; + 33AB0B87867400F3D0C65FA0 /* ApiClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0DBC4B9B43C015C4DD411359 /* ApiClient.swift */; }; + 3689239282AADF06783CBBEA /* AuthFormatters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28B3C375FC18002A884A44F1 /* AuthFormatters.swift */; }; + 3BD69E880EBC797B87EE547B /* StoreDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71DB74B676A58B367F026090 /* StoreDetailView.swift */; }; + 3CD9864DF9CEC59AC37631A3 /* GuestLocationStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54795EDED987426386226358 /* GuestLocationStoreTests.swift */; }; + 3DDE6E72A5F656A287AA15C5 /* CheckoutViewLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4224AF6A5C5991A7993F1B03 /* CheckoutViewLogicTests.swift */; }; + 408D21B76BA5532970F084A6 /* PlatformCompat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 219E1456C64C77E80E5D8FA5 /* PlatformCompat.swift */; }; + 40EBE2CA4D34415AC934FCD5 /* ApiClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93749D009C7B305AC0DB5B65 /* ApiClientTests.swift */; }; + 41BE548D929D3814A736FA12 /* CheckoutView+Logic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7463F80A5AAACB5FBE6EB160 /* CheckoutView+Logic.swift */; }; + 42FD703A96353119FAB2F711 /* ApiPizzaModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6004BD79F6AAD79FF53CFCA /* ApiPizzaModels.swift */; }; + 44EE48E618C2DB3881FF5B13 /* FeatureFlagsStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68D166AF12044C0D21C4F51A /* FeatureFlagsStateTests.swift */; }; + 4508BD7E4DF8DF6CE6F9D8A7 /* ApiCardModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 912795B76154EFC8190EA448 /* ApiCardModels.swift */; }; + 489D8790BB0DAFCD25432DEA /* FeatureControlService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F4ED1B7B71C905115380DC0 /* FeatureControlService.swift */; }; + 4B85C214A0BF3DF50311C76C /* CartCheckoutFlowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7034E89267D9F5D86BF1E424 /* CartCheckoutFlowTests.swift */; }; + 508C3902DD2D1C42B4714724 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03214053A29868C05DBD43C2 /* NotificationService.swift */; }; + 5217E18CFCBFEC47C9C5214D /* CheckoutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FC876FED16E41B02B287218 /* CheckoutView.swift */; }; + 5550A874409B7C4CBCB2E738 /* AppContentCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35400BEE3D09C61B2058579F /* AppContentCacheTests.swift */; }; + 562033CE82B2347AE3059516 /* UserProfileFlowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 739EE6B1FFDC38C9B0C038F3 /* UserProfileFlowTests.swift */; }; + 56D7A9EEFFDCC4E69BC8E42B /* ImageSourceResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2081885D00F88746FCEC839 /* ImageSourceResolver.swift */; }; + 5995B417A47CD110B053F3D9 /* StoreDetailPizzaSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACB000ED1A0CFD200D73D4E3 /* StoreDetailPizzaSupport.swift */; }; + 5C20E8F1A2948C6D1336D5AC /* DesignSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E595B073D646DFF412D7D19 /* DesignSystem.swift */; }; + 5E0CCBA5FAF048D363D84CE0 /* GuestLocationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86E2AF8E6A5E353BA41B4E42 /* GuestLocationStore.swift */; }; + 5F457450CBF039AA0396E0E9 /* AddressComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CD06F9A7AF9365D1FBE35D0 /* AddressComponents.swift */; }; + 63AB40542AABCC3CB2390B87 /* HomeViewFilteringTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23B6B814027518969EB04341 /* HomeViewFilteringTests.swift */; }; + 64E1DF574300FD591ADB55C5 /* ApiCardModelsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C415019CC1F314EE7318864 /* ApiCardModelsTests.swift */; }; + 666B7A97829B0EE6176DC2F3 /* DeepLinkRouteEffectTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8DD2B73D89E6D33643EF58F5 /* DeepLinkRouteEffectTests.swift */; }; + 68AB5B542C6486AA7FFF3258 /* ProfileLoggedOutFlowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C13751F450A42A2BDB0783D /* ProfileLoggedOutFlowTests.swift */; }; + 691351CFE207B9F34361AEB6 /* ApiReviewModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6546F47FB08C47CCE3D63AE8 /* ApiReviewModels.swift */; }; + 69FD2F1AA2A0D7ACDCB849DE /* GuestSessionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9F14B1BDD7747C546DFAF0 /* GuestSessionService.swift */; }; + 6B2D29A3FAFB51F5A8E16A53 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B85C265A23C9BA93E3DD6898 /* HomeView.swift */; }; + 704327434852FB28F82915A2 /* SavedCardsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 258B22AD407CCD44A4DC0C06 /* SavedCardsView.swift */; }; + 704F004D8E59873BF6108FC4 /* ApiConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69A9FB3DE679C3D4C60E7E42 /* ApiConfig.swift */; }; + 709EF974B65A6E5F7C84F55D /* TestFixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB1ACA04E2868C622EF57D1 /* TestFixtures.swift */; }; + 70EA850C067AB6C6B87539E2 /* CheckoutTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FC454261845C1EDF970552A /* CheckoutTypes.swift */; }; + 716435D8A78871768512A316 /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B807C1B779CAE1340D377D43 /* ProfileView.swift */; }; + 7195DCDA731020CC7323853D /* TokenStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BDC45386D2B0D0BE4881FA4 /* TokenStore.swift */; }; + 7245BC999A945B6F2BFD726B /* StoreCatalogNormalizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C891B69E59F478F23FF8CDA /* StoreCatalogNormalizer.swift */; }; + 7660AB80E940C2C2BA1FE8BB /* OtpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E5FB65101BEF4615C7EB3AB /* OtpView.swift */; }; + 781F51880A58E8BE57B6EFCE /* DeepLinkDestination.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A3FB85A2A2E2952B9A2BAB0 /* DeepLinkDestination.swift */; }; + 7A75836F3C1B3E776A4F6C14 /* HomeScrollOffsetObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0286512E123F685F27082A2C /* HomeScrollOffsetObserver.swift */; }; + 7CAAE9391C622C1CAF3A08E3 /* UITestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = B33118037CF56CD70C332E2C /* UITestSupport.swift */; }; + 7CC171B83BC4CDEC38B25F8C /* SnackbarCenterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2CE116C585A7529CF309B3D /* SnackbarCenterTests.swift */; }; + 7D7BF04765D6CA6AC4F11DDD /* StoreCatalogNormalizerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18EC38EEE1E7105BC21E354C /* StoreCatalogNormalizerTests.swift */; }; + 80B476CCE2B90BEE2FA4CB37 /* PediFoodsUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56E3E6C1834786CA3621B7BB /* PediFoodsUITestsLaunchTests.swift */; }; + 811217F88AA350A70CC22479 /* NotificationServiceExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = C724E02EC34BB1D4352C2FF0 /* NotificationServiceExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 85EA52253BABA327EC07CFF0 /* AuthFormattersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 561E00C486D60EC2DF720EAF /* AuthFormattersTests.swift */; }; + 8D6622AE9325D02B830BD115 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 6CAA9B253B1A6C8815A0E69D /* LaunchScreen.storyboard */; }; + 90ADA376585F5438F3A1A1D6 /* HomeView+Favorites.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1517B141263438E466452037 /* HomeView+Favorites.swift */; }; + 918BCBC1EAD7EE134DB9742D /* CheckoutTypesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8FF15CEE5B137074E6B2489 /* CheckoutTypesTests.swift */; }; + 926E0C4DD3C82485D4262A62 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29B2F3558C58CD1E71F0F3A0 /* ContentView.swift */; }; + 939765EEF8567833CF9E27A1 /* ApiModelsDecodingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 794249487F718BCFBF176E8D /* ApiModelsDecodingTests.swift */; }; + 93B3F9B5BD866F29387684B6 /* StoreDetailView+Components.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC07B79A19D5214AB24B6C39 /* StoreDetailView+Components.swift */; }; + 9835E4C5F2EA0F7432260118 /* SessionEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5865360EA85401C6A85CBBD5 /* SessionEvents.swift */; }; + 9CFB1DF4CB441490C1290E32 /* ApiFavoriteModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCCEB6D0701BC09215B3D9AC /* ApiFavoriteModels.swift */; }; + 9EDDCECCAEEB8FA4909DA9D4 /* AppCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECBFCE20C54A79DEA8F4512B /* AppCoordinator.swift */; }; + 9F09502CF9331F0C94BFA5F0 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2B2C5BBDFF4473C472F45B4 /* AppState.swift */; }; + A115420D6854B7C9DCF339DE /* CartStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F9CDFF62AE19F3EE36A1D19 /* CartStateTests.swift */; }; + A24EE365C06DBA8FD2D91213 /* HomeFiltersFlowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B44E3CD64AF31CEFFD932D7D /* HomeFiltersFlowTests.swift */; }; + A3DFC65C635BE5D96660A432 /* Inputs.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB95D6804877D2ED244811D0 /* Inputs.swift */; }; + A68ED6FBE5307F8674A5A4AB /* HomeView+Data.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E0CCEF22D356F9EC723999D /* HomeView+Data.swift */; }; + A6AE21C3CC220D50251865BE /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 63DCCE423400D4D3785EE0F0 /* Assets.xcassets */; }; + AA0A4F088033729F9B74B2BB /* LegalViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30B3B6E69309F3EDF28E81CC /* LegalViews.swift */; }; + ABE383C64BD045B9732AA8C5 /* HomeView+Filtering.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0D7ECA55B1FE5FC617E84CB /* HomeView+Filtering.swift */; }; + AC7E0F52FEF7976D97190205 /* ApiModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60DC2520DBC74D68AEE659E5 /* ApiModels.swift */; }; + AD19173ACE58E819FEABD318 /* LoginEmailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDEE09D43411627E9D1973F2 /* LoginEmailView.swift */; }; + B193A29A377C68744B2F8FD5 /* PublicLocationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C44662BAA20AD862BE1DBC40 /* PublicLocationService.swift */; }; + B239BDCEB9F8934F1C71258C /* AppCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6A238BEC35852BBE8E79BA /* AppCache.swift */; }; + B24B898508C8A8EF4191EC89 /* SmokeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 160571AB2F8D4DF98C0BE16F /* SmokeTests.swift */; }; + B303CFDCE85737EA9392BDFE /* RegistrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0707B6F412DC1B1CE99349B /* RegistrationView.swift */; }; + B3B83AD2859F714EAAF93641 /* OrderRealtimeTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5E02FE8D1294A9A93EEB270 /* OrderRealtimeTracker.swift */; }; + B4AB8465254E51804FFF8671 /* KeychainStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89B662E8B7E1B8F1CF7E657E /* KeychainStore.swift */; }; + B58074D656C5E6D5CF3D8EC9 /* ReviewsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BF467DF1447525F04B242B0 /* ReviewsView.swift */; }; + C2245B550422BD7784B1E5D4 /* ApiCustomerPayloadModelsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6B08901A67C8D90A3B50882 /* ApiCustomerPayloadModelsTests.swift */; }; + C6D36ED105E3F5E7C01BC022 /* TextFieldCompat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E3A0A2E253ABFCA35D02F28 /* TextFieldCompat.swift */; }; + C6D6243D2D266D0F8BAC3F22 /* SessionStateStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08341A3C2B69FA9A135101D2 /* SessionStateStore.swift */; }; + C6F379D343C6FC9BEA7D1E18 /* ApiOrderTrackingModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3908E5B0512E1AA756DD012 /* ApiOrderTrackingModels.swift */; }; + CCE23FD7706B34BDE544E05A /* Buttons.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EC381D02A37312BE360024F /* Buttons.swift */; }; + CD96980F3D7B603126FDE6AC /* URLProtocolStub.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51798A414469C911D44F0F6 /* URLProtocolStub.swift */; }; + CEA08090E08B5E39FD0DD5D6 /* CachedRemoteImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = EECF1BBC608B65EC1F1B6114 /* CachedRemoteImage.swift */; }; + D2FB3C624B3C27E59BCA997A /* MainTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EA34EE194C71755B3C82F30 /* MainTabView.swift */; }; + D3EC6E0A68B809F7A3370B1B /* ProductDetailSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 553A05B9EAD0B9D0D9F1B369 /* ProductDetailSheet.swift */; }; + D48148B155BA2F76C41C0787 /* HomeFiltersStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FBD7D809ACE1840165A9DCC /* HomeFiltersStateTests.swift */; }; + D4D5C460FF25F353D6A49B62 /* FiltersModalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30024AC97E79912E1358DDFB /* FiltersModalView.swift */; }; + D73A4204B15D5DFE5FF75F59 /* AddCardFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB38ADB5D671BBB9120BF437 /* AddCardFormView.swift */; }; + D7B6082157DA0DF2382EB943 /* HomeGuestFlowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBD89D11DF567A6A6E078CC8 /* HomeGuestFlowTests.swift */; }; + D8FE2EA3ADFAD7BFA2A27D2E /* StoreDetailSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA44F5384D21B7F718F95522 /* StoreDetailSupport.swift */; }; + DBA0E46D6CFF520460D0418C /* AddAddressFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76B24E69A3F6A3EB86449870 /* AddAddressFormView.swift */; }; + DE499D6150C15EB046AF7963 /* CartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD5EE81AE4347B4086684F8B /* CartView.swift */; }; + E3C2AE0D45A31B955EFAEEAD /* OrderDetailsFlowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE0C7A506EDE8D0FD8F16AC6 /* OrderDetailsFlowTests.swift */; }; + E4D492E4B651A8FCC5016B43 /* PizzaFlavorAddonsSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5ECD3AA9543133BE623668D /* PizzaFlavorAddonsSheet.swift */; }; + E7817C56D8F083A231E11E4E /* LocationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAB9F980426D39E93E091051 /* LocationService.swift */; }; + EBBF7EFFDC9286D4F64544F4 /* PublicLocationModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = F85F176EA2B204CC95072D28 /* PublicLocationModels.swift */; }; + ECFEB3B3779C4F267EC148A4 /* SnackbarCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 226D1B7C049B405436DD36B4 /* SnackbarCenter.swift */; }; + F03F08F65DFAD9166884EAD4 /* StoreCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31916C1A4005396ADA5FF3BC /* StoreCard.swift */; }; + F280C0AC9902B13E2224C790 /* OrdersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF413C3924669E761BF455B8 /* OrdersView.swift */; }; + F2F123AA83C7B6593C1C6D06 /* OrderDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D2C6A0E85E133F947293181 /* OrderDetailsView.swift */; }; + F4A296C6C0D031BB9407F7A0 /* SavedCardsFlowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B90A5FBAD43B6C97C8D21 /* SavedCardsFlowTests.swift */; }; + F760557D906B860D0EA277DE /* SessionStateStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 747AD255B6D881E011FDF907 /* SessionStateStoreTests.swift */; }; + FFF187BC63BA0529029A4E5B /* PizzaProductDetailSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A072F5D761EFC2CAA767C3 /* PizzaProductDetailSheet.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 775AB248D5E4D152D79FB294 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 01350B58D2BE5935489326DB /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33EB2407F0F758EB98A769D3; + remoteInfo = NotificationServiceExtension; + }; + D6AA2F252EF50A52AB1ABBBE /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 01350B58D2BE5935489326DB /* Project object */; + proxyType = 1; + remoteGlobalIDString = B3B5CFF77A8ED019D1299ABB; + remoteInfo = PediFoods; + }; + E46E9EB8CF662687FE324B43 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 01350B58D2BE5935489326DB /* Project object */; + proxyType = 1; + remoteGlobalIDString = B3B5CFF77A8ED019D1299ABB; + remoteInfo = PediFoods; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + F0264640779728D58E6204F1 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 811217F88AA350A70CC22479 /* NotificationServiceExtension.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 0286512E123F685F27082A2C /* HomeScrollOffsetObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScrollOffsetObserver.swift; sourceTree = ""; }; + 03214053A29868C05DBD43C2 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; + 05564A16F6CA0EB731DF1A6E /* ApiPizzaModelsDecodingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiPizzaModelsDecodingTests.swift; sourceTree = ""; }; + 08341A3C2B69FA9A135101D2 /* SessionStateStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionStateStore.swift; sourceTree = ""; }; + 0DBC4B9B43C015C4DD411359 /* ApiClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiClient.swift; sourceTree = ""; }; + 103668F3D2C86F68A07D76E7 /* StoreDetailView+Logic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StoreDetailView+Logic.swift"; sourceTree = ""; }; + 1517B141263438E466452037 /* HomeView+Favorites.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeView+Favorites.swift"; sourceTree = ""; }; + 15D9ECEE8C0CF44FFABB47F0 /* DeepLinkDestinationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLinkDestinationTests.swift; sourceTree = ""; }; + 160571AB2F8D4DF98C0BE16F /* SmokeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmokeTests.swift; sourceTree = ""; }; + 172FED2F32A096B9CF2D5583 /* PushNotificationCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNotificationCoordinator.swift; sourceTree = ""; }; + 18EC38EEE1E7105BC21E354C /* StoreCatalogNormalizerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreCatalogNormalizerTests.swift; sourceTree = ""; }; + 194F7B60A98FEB5C2CE24CAB /* ApiOrderModelsDecodingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiOrderModelsDecodingTests.swift; sourceTree = ""; }; + 1A2B90A5FBAD43B6C97C8D21 /* SavedCardsFlowTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedCardsFlowTests.swift; sourceTree = ""; }; + 219E1456C64C77E80E5D8FA5 /* PlatformCompat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformCompat.swift; sourceTree = ""; }; + 226D1B7C049B405436DD36B4 /* SnackbarCenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnackbarCenter.swift; sourceTree = ""; }; + 23B6B814027518969EB04341 /* HomeViewFilteringTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewFilteringTests.swift; sourceTree = ""; }; + 246FDD4C8985BF2F5C900001 /* ApiCustomerPayloadModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiCustomerPayloadModels.swift; sourceTree = ""; }; + 258B22AD407CCD44A4DC0C06 /* SavedCardsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedCardsView.swift; sourceTree = ""; }; + 28B3C375FC18002A884A44F1 /* AuthFormatters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthFormatters.swift; sourceTree = ""; }; + 29B2F3558C58CD1E71F0F3A0 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 2EA34EE194C71755B3C82F30 /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = ""; }; + 2F4ED1B7B71C905115380DC0 /* FeatureControlService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureControlService.swift; sourceTree = ""; }; + 30024AC97E79912E1358DDFB /* FiltersModalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FiltersModalView.swift; sourceTree = ""; }; + 30B3B6E69309F3EDF28E81CC /* LegalViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegalViews.swift; sourceTree = ""; }; + 31916C1A4005396ADA5FF3BC /* StoreCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreCard.swift; sourceTree = ""; }; + 35400BEE3D09C61B2058579F /* AppContentCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppContentCacheTests.swift; sourceTree = ""; }; + 36AA0706B5951E99AE98E6A3 /* AuthFlowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthFlowView.swift; sourceTree = ""; }; + 395EA4C12C0187DA59DFFC17 /* AddressesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddressesView.swift; sourceTree = ""; }; + 3C891B69E59F478F23FF8CDA /* StoreCatalogNormalizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreCatalogNormalizer.swift; sourceTree = ""; }; + 4224AF6A5C5991A7993F1B03 /* CheckoutViewLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckoutViewLogicTests.swift; sourceTree = ""; }; + 4C415019CC1F314EE7318864 /* ApiCardModelsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiCardModelsTests.swift; sourceTree = ""; }; + 4DC5416AEBA77C43CF55ABA8 /* PublicLocationPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicLocationPickerView.swift; sourceTree = ""; }; + 4E0CCEF22D356F9EC723999D /* HomeView+Data.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeView+Data.swift"; sourceTree = ""; }; + 507E868458359BE0E4FF25F1 /* ApiService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiService.swift; sourceTree = ""; }; + 52BFF93F21509F1AD4F6676B /* PediFoodsApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PediFoodsApp.swift; sourceTree = ""; }; + 54795EDED987426386226358 /* GuestLocationStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuestLocationStoreTests.swift; sourceTree = ""; }; + 553A05B9EAD0B9D0D9F1B369 /* ProductDetailSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductDetailSheet.swift; sourceTree = ""; }; + 561E00C486D60EC2DF720EAF /* AuthFormattersTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthFormattersTests.swift; sourceTree = ""; }; + 5684E2E831A9A0D03BE34120 /* FeatureControlServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureControlServiceTests.swift; sourceTree = ""; }; + 56E3E6C1834786CA3621B7BB /* PediFoodsUITestsLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PediFoodsUITestsLaunchTests.swift; sourceTree = ""; }; + 5865360EA85401C6A85CBBD5 /* SessionEvents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionEvents.swift; sourceTree = ""; }; + 5A82497C63D507E82FE02693 /* ImageSourceResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageSourceResolverTests.swift; sourceTree = ""; }; + 5BB1ACA04E2868C622EF57D1 /* TestFixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestFixtures.swift; sourceTree = ""; }; + 5E5FB65101BEF4615C7EB3AB /* OtpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OtpView.swift; sourceTree = ""; }; + 60DC2520DBC74D68AEE659E5 /* ApiModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiModels.swift; sourceTree = ""; }; + 63DCCE423400D4D3785EE0F0 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 6546F47FB08C47CCE3D63AE8 /* ApiReviewModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiReviewModels.swift; sourceTree = ""; }; + 68D166AF12044C0D21C4F51A /* FeatureFlagsStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureFlagsStateTests.swift; sourceTree = ""; }; + 69A9FB3DE679C3D4C60E7E42 /* ApiConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiConfig.swift; sourceTree = ""; }; + 6BF467DF1447525F04B242B0 /* ReviewsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewsView.swift; sourceTree = ""; }; + 6CAA9B253B1A6C8815A0E69D /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = ""; }; + 6DD1D8390E57439A347F9E07 /* PediFoodsTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = PediFoodsTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 6EC381D02A37312BE360024F /* Buttons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Buttons.swift; sourceTree = ""; }; + 6FC454261845C1EDF970552A /* CheckoutTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckoutTypes.swift; sourceTree = ""; }; + 7034E89267D9F5D86BF1E424 /* CartCheckoutFlowTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CartCheckoutFlowTests.swift; sourceTree = ""; }; + 709FCA67231B4E8D8F976C87 /* OrderTrackingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderTrackingView.swift; sourceTree = ""; }; + 71DB74B676A58B367F026090 /* StoreDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreDetailView.swift; sourceTree = ""; }; + 739EE6B1FFDC38C9B0C038F3 /* UserProfileFlowTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileFlowTests.swift; sourceTree = ""; }; + 7457DAF1217112C23BE2336F /* FavoritesDecodingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesDecodingTests.swift; sourceTree = ""; }; + 7463F80A5AAACB5FBE6EB160 /* CheckoutView+Logic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CheckoutView+Logic.swift"; sourceTree = ""; }; + 747AD255B6D881E011FDF907 /* SessionStateStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionStateStoreTests.swift; sourceTree = ""; }; + 76B24E69A3F6A3EB86449870 /* AddAddressFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddAddressFormView.swift; sourceTree = ""; }; + 794249487F718BCFBF176E8D /* ApiModelsDecodingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiModelsDecodingTests.swift; sourceTree = ""; }; + 79D2854B8062D96742CFDD99 /* HomeViewComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewComponents.swift; sourceTree = ""; }; + 7A6D74FD2D2F2F79349EA04E /* PediFoods.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = PediFoods.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 7A9F14B1BDD7747C546DFAF0 /* GuestSessionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuestSessionService.swift; sourceTree = ""; }; + 7C13751F450A42A2BDB0783D /* ProfileLoggedOutFlowTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileLoggedOutFlowTests.swift; sourceTree = ""; }; + 7CD06F9A7AF9365D1FBE35D0 /* AddressComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddressComponents.swift; sourceTree = ""; }; + 7F9CDFF62AE19F3EE36A1D19 /* CartStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CartStateTests.swift; sourceTree = ""; }; + 86E2AF8E6A5E353BA41B4E42 /* GuestLocationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuestLocationStore.swift; sourceTree = ""; }; + 89B662E8B7E1B8F1CF7E657E /* KeychainStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainStore.swift; sourceTree = ""; }; + 8A3FB85A2A2E2952B9A2BAB0 /* DeepLinkDestination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLinkDestination.swift; sourceTree = ""; }; + 8AA914FB8B32B106911FD67B /* LegalDocumentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegalDocumentTests.swift; sourceTree = ""; }; + 8AF987AB319512C123DFD558 /* PediFoodsUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = PediFoodsUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 8D2C6A0E85E133F947293181 /* OrderDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderDetailsView.swift; sourceTree = ""; }; + 8DD2B73D89E6D33643EF58F5 /* DeepLinkRouteEffectTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLinkRouteEffectTests.swift; sourceTree = ""; }; + 8E3A0A2E253ABFCA35D02F28 /* TextFieldCompat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextFieldCompat.swift; sourceTree = ""; }; + 8FBD7D809ACE1840165A9DCC /* HomeFiltersStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeFiltersStateTests.swift; sourceTree = ""; }; + 912795B76154EFC8190EA448 /* ApiCardModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiCardModels.swift; sourceTree = ""; }; + 93749D009C7B305AC0DB5B65 /* ApiClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiClientTests.swift; sourceTree = ""; }; + 9786809975F4461F277BFBA6 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; + 9BDC45386D2B0D0BE4881FA4 /* TokenStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenStore.swift; sourceTree = ""; }; + 9D56A28027450A094082177F /* ApiOrderTrackingModelsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiOrderTrackingModelsTests.swift; sourceTree = ""; }; + 9D8F18894F32F9F498705B59 /* ApiReviewModelsDecodingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiReviewModelsDecodingTests.swift; sourceTree = ""; }; + 9E595B073D646DFF412D7D19 /* DesignSystem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DesignSystem.swift; sourceTree = ""; }; + 9FC876FED16E41B02B287218 /* CheckoutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckoutView.swift; sourceTree = ""; }; + 9FFFA1B7A41D715078A8CCC8 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + A2081885D00F88746FCEC839 /* ImageSourceResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageSourceResolver.swift; sourceTree = ""; }; + A51798A414469C911D44F0F6 /* URLProtocolStub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLProtocolStub.swift; sourceTree = ""; }; + AC07B79A19D5214AB24B6C39 /* StoreDetailView+Components.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StoreDetailView+Components.swift"; sourceTree = ""; }; + ACB000ED1A0CFD200D73D4E3 /* StoreDetailPizzaSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreDetailPizzaSupport.swift; sourceTree = ""; }; + B0707B6F412DC1B1CE99349B /* RegistrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegistrationView.swift; sourceTree = ""; }; + B2AD93DBB0F52B4B485C97BA /* UserProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileView.swift; sourceTree = ""; }; + B2CE116C585A7529CF309B3D /* SnackbarCenterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnackbarCenterTests.swift; sourceTree = ""; }; + B33118037CF56CD70C332E2C /* UITestSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITestSupport.swift; sourceTree = ""; }; + B35D1C07050150E305FAF09A /* PizzaProductDetailSheet+Flow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PizzaProductDetailSheet+Flow.swift"; sourceTree = ""; }; + B44E3CD64AF31CEFFD932D7D /* HomeFiltersFlowTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeFiltersFlowTests.swift; sourceTree = ""; }; + B807C1B779CAE1340D377D43 /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = ""; }; + B85C265A23C9BA93E3DD6898 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; + BBD89D11DF567A6A6E078CC8 /* HomeGuestFlowTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeGuestFlowTests.swift; sourceTree = ""; }; + BCCEB6D0701BC09215B3D9AC /* ApiFavoriteModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiFavoriteModels.swift; sourceTree = ""; }; + BEAA5F0BA8EBC7F2D19472D6 /* AuthenticatedSessionFlowTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticatedSessionFlowTests.swift; sourceTree = ""; }; + BF413C3924669E761BF455B8 /* OrdersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrdersView.swift; sourceTree = ""; }; + C0E7D6AA983FD6CF97727F10 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + C3A0787FEAE67318791D6CFD /* AuthenticatedProfileNavigationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticatedProfileNavigationTests.swift; sourceTree = ""; }; + C44662BAA20AD862BE1DBC40 /* PublicLocationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicLocationService.swift; sourceTree = ""; }; + C4745C12F14B695C77DFE178 /* SnackbarOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnackbarOverlay.swift; sourceTree = ""; }; + C6B08901A67C8D90A3B50882 /* ApiCustomerPayloadModelsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiCustomerPayloadModelsTests.swift; sourceTree = ""; }; + C724E02EC34BB1D4352C2FF0 /* NotificationServiceExtension.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = NotificationServiceExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + CA44F5384D21B7F718F95522 /* StoreDetailSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreDetailSupport.swift; sourceTree = ""; }; + CAB9F980426D39E93E091051 /* LocationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationService.swift; sourceTree = ""; }; + CD5EE81AE4347B4086684F8B /* CartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CartView.swift; sourceTree = ""; }; + D5E02FE8D1294A9A93EEB270 /* OrderRealtimeTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderRealtimeTracker.swift; sourceTree = ""; }; + D5ECD3AA9543133BE623668D /* PizzaFlavorAddonsSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PizzaFlavorAddonsSheet.swift; sourceTree = ""; }; + D6004BD79F6AAD79FF53CFCA /* ApiPizzaModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiPizzaModels.swift; sourceTree = ""; }; + D6A072F5D761EFC2CAA767C3 /* PizzaProductDetailSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PizzaProductDetailSheet.swift; sourceTree = ""; }; + D8FF15CEE5B137074E6B2489 /* CheckoutTypesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckoutTypesTests.swift; sourceTree = ""; }; + DA6A238BEC35852BBE8E79BA /* AppCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCache.swift; sourceTree = ""; }; + DB38ADB5D671BBB9120BF437 /* AddCardFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddCardFormView.swift; sourceTree = ""; }; + E0D7ECA55B1FE5FC617E84CB /* HomeView+Filtering.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeView+Filtering.swift"; sourceTree = ""; }; + E371D06A1B2BC79A307AE4C8 /* PediFoods.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = PediFoods.entitlements; sourceTree = ""; }; + EB95D6804877D2ED244811D0 /* Inputs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Inputs.swift; sourceTree = ""; }; + ECBFCE20C54A79DEA8F4512B /* AppCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCoordinator.swift; sourceTree = ""; }; + EECF1BBC608B65EC1F1B6114 /* CachedRemoteImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CachedRemoteImage.swift; sourceTree = ""; }; + F0B079DFAB606DCA87A536C9 /* LaunchSplashView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchSplashView.swift; sourceTree = ""; }; + F18F768F7A2A47111A85FE88 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = ""; }; + F2B2C5BBDFF4473C472F45B4 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; + F3908E5B0512E1AA756DD012 /* ApiOrderTrackingModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiOrderTrackingModels.swift; sourceTree = ""; }; + F4625B70E3E8BEEB9D0B2042 /* ApiOrderModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiOrderModels.swift; sourceTree = ""; }; + F85F176EA2B204CC95072D28 /* PublicLocationModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicLocationModels.swift; sourceTree = ""; }; + FDEE09D43411627E9D1973F2 /* LoginEmailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginEmailView.swift; sourceTree = ""; }; + FE0C7A506EDE8D0FD8F16AC6 /* OrderDetailsFlowTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderDetailsFlowTests.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 13DAC067D54833DC00ADDA53 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 2F56755488E951C453AC2126 /* LCEssentials in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 0B2971A188ADC06068E94193 /* Resources */ = { + isa = PBXGroup; + children = ( + 9786809975F4461F277BFBA6 /* Localizable.xcstrings */, + ); + path = Resources; + sourceTree = ""; + }; + 0C0093B0859FFFC84B9827FB /* Views */ = { + isa = PBXGroup; + children = ( + D028B69395DC85C9ACF8F840 /* Auth */, + C3E51098EEA82A07159740FD /* Launch */, + AFBC6E75D01939DF5C1F5D0F /* Main */, + ); + path = Views; + sourceTree = ""; + }; + 126A579C448C784867DC3AB1 /* Products */ = { + isa = PBXGroup; + children = ( + C724E02EC34BB1D4352C2FF0 /* NotificationServiceExtension.appex */, + 7A6D74FD2D2F2F79349EA04E /* PediFoods.app */, + 6DD1D8390E57439A347F9E07 /* PediFoodsTests.xctest */, + 8AF987AB319512C123DFD558 /* PediFoodsUITests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 16C5EADDD7893D92A2BBA44C /* Coordinators */ = { + isa = PBXGroup; + children = ( + ECBFCE20C54A79DEA8F4512B /* AppCoordinator.swift */, + 8A3FB85A2A2E2952B9A2BAB0 /* DeepLinkDestination.swift */, + ); + path = Coordinators; + sourceTree = ""; + }; + 35B16FADECD95C7D80CA4C58 /* Support */ = { + isa = PBXGroup; + children = ( + 219E1456C64C77E80E5D8FA5 /* PlatformCompat.swift */, + ); + path = Support; + sourceTree = ""; + }; + 4FD8F5F326DAB3B7385FF14D /* State */ = { + isa = PBXGroup; + children = ( + F2B2C5BBDFF4473C472F45B4 /* AppState.swift */, + 226D1B7C049B405436DD36B4 /* SnackbarCenter.swift */, + ); + path = State; + sourceTree = ""; + }; + 625A56B75CAE1B12715C00C7 /* Services */ = { + isa = PBXGroup; + children = ( + 912795B76154EFC8190EA448 /* ApiCardModels.swift */, + 0DBC4B9B43C015C4DD411359 /* ApiClient.swift */, + 69A9FB3DE679C3D4C60E7E42 /* ApiConfig.swift */, + 246FDD4C8985BF2F5C900001 /* ApiCustomerPayloadModels.swift */, + BCCEB6D0701BC09215B3D9AC /* ApiFavoriteModels.swift */, + 60DC2520DBC74D68AEE659E5 /* ApiModels.swift */, + F4625B70E3E8BEEB9D0B2042 /* ApiOrderModels.swift */, + F3908E5B0512E1AA756DD012 /* ApiOrderTrackingModels.swift */, + D6004BD79F6AAD79FF53CFCA /* ApiPizzaModels.swift */, + 6546F47FB08C47CCE3D63AE8 /* ApiReviewModels.swift */, + 507E868458359BE0E4FF25F1 /* ApiService.swift */, + DA6A238BEC35852BBE8E79BA /* AppCache.swift */, + 2F4ED1B7B71C905115380DC0 /* FeatureControlService.swift */, + 86E2AF8E6A5E353BA41B4E42 /* GuestLocationStore.swift */, + 7A9F14B1BDD7747C546DFAF0 /* GuestSessionService.swift */, + A2081885D00F88746FCEC839 /* ImageSourceResolver.swift */, + 89B662E8B7E1B8F1CF7E657E /* KeychainStore.swift */, + CAB9F980426D39E93E091051 /* LocationService.swift */, + D5E02FE8D1294A9A93EEB270 /* OrderRealtimeTracker.swift */, + F85F176EA2B204CC95072D28 /* PublicLocationModels.swift */, + C44662BAA20AD862BE1DBC40 /* PublicLocationService.swift */, + 172FED2F32A096B9CF2D5583 /* PushNotificationCoordinator.swift */, + 5865360EA85401C6A85CBBD5 /* SessionEvents.swift */, + 08341A3C2B69FA9A135101D2 /* SessionStateStore.swift */, + 3C891B69E59F478F23FF8CDA /* StoreCatalogNormalizer.swift */, + 9BDC45386D2B0D0BE4881FA4 /* TokenStore.swift */, + ); + path = Services; + sourceTree = ""; + }; + 751388ED084854A042DD69C8 /* NotificationServiceExtension */ = { + isa = PBXGroup; + children = ( + 9FFFA1B7A41D715078A8CCC8 /* Info.plist */, + 03214053A29868C05DBD43C2 /* NotificationService.swift */, + ); + path = NotificationServiceExtension; + sourceTree = ""; + }; + 9EAD88F6DC92C7FBB673C95A /* PediFoodsTests */ = { + isa = PBXGroup; + children = ( + 4C415019CC1F314EE7318864 /* ApiCardModelsTests.swift */, + 93749D009C7B305AC0DB5B65 /* ApiClientTests.swift */, + C6B08901A67C8D90A3B50882 /* ApiCustomerPayloadModelsTests.swift */, + 794249487F718BCFBF176E8D /* ApiModelsDecodingTests.swift */, + 194F7B60A98FEB5C2CE24CAB /* ApiOrderModelsDecodingTests.swift */, + 9D56A28027450A094082177F /* ApiOrderTrackingModelsTests.swift */, + 05564A16F6CA0EB731DF1A6E /* ApiPizzaModelsDecodingTests.swift */, + 9D8F18894F32F9F498705B59 /* ApiReviewModelsDecodingTests.swift */, + 35400BEE3D09C61B2058579F /* AppContentCacheTests.swift */, + 561E00C486D60EC2DF720EAF /* AuthFormattersTests.swift */, + 7F9CDFF62AE19F3EE36A1D19 /* CartStateTests.swift */, + D8FF15CEE5B137074E6B2489 /* CheckoutTypesTests.swift */, + 4224AF6A5C5991A7993F1B03 /* CheckoutViewLogicTests.swift */, + 15D9ECEE8C0CF44FFABB47F0 /* DeepLinkDestinationTests.swift */, + 8DD2B73D89E6D33643EF58F5 /* DeepLinkRouteEffectTests.swift */, + 7457DAF1217112C23BE2336F /* FavoritesDecodingTests.swift */, + 5684E2E831A9A0D03BE34120 /* FeatureControlServiceTests.swift */, + 68D166AF12044C0D21C4F51A /* FeatureFlagsStateTests.swift */, + 54795EDED987426386226358 /* GuestLocationStoreTests.swift */, + 8FBD7D809ACE1840165A9DCC /* HomeFiltersStateTests.swift */, + 23B6B814027518969EB04341 /* HomeViewFilteringTests.swift */, + 5A82497C63D507E82FE02693 /* ImageSourceResolverTests.swift */, + 8AA914FB8B32B106911FD67B /* LegalDocumentTests.swift */, + 747AD255B6D881E011FDF907 /* SessionStateStoreTests.swift */, + 160571AB2F8D4DF98C0BE16F /* SmokeTests.swift */, + B2CE116C585A7529CF309B3D /* SnackbarCenterTests.swift */, + 18EC38EEE1E7105BC21E354C /* StoreCatalogNormalizerTests.swift */, + A51798A414469C911D44F0F6 /* URLProtocolStub.swift */, + ); + path = PediFoodsTests; + sourceTree = ""; + }; + AFBC6E75D01939DF5C1F5D0F /* Main */ = { + isa = PBXGroup; + children = ( + 76B24E69A3F6A3EB86449870 /* AddAddressFormView.swift */, + DB38ADB5D671BBB9120BF437 /* AddCardFormView.swift */, + 7CD06F9A7AF9365D1FBE35D0 /* AddressComponents.swift */, + 395EA4C12C0187DA59DFFC17 /* AddressesView.swift */, + CD5EE81AE4347B4086684F8B /* CartView.swift */, + 6FC454261845C1EDF970552A /* CheckoutTypes.swift */, + 9FC876FED16E41B02B287218 /* CheckoutView.swift */, + 7463F80A5AAACB5FBE6EB160 /* CheckoutView+Logic.swift */, + 30024AC97E79912E1358DDFB /* FiltersModalView.swift */, + 0286512E123F685F27082A2C /* HomeScrollOffsetObserver.swift */, + B85C265A23C9BA93E3DD6898 /* HomeView.swift */, + 4E0CCEF22D356F9EC723999D /* HomeView+Data.swift */, + 1517B141263438E466452037 /* HomeView+Favorites.swift */, + E0D7ECA55B1FE5FC617E84CB /* HomeView+Filtering.swift */, + 79D2854B8062D96742CFDD99 /* HomeViewComponents.swift */, + 2EA34EE194C71755B3C82F30 /* MainTabView.swift */, + 8D2C6A0E85E133F947293181 /* OrderDetailsView.swift */, + BF413C3924669E761BF455B8 /* OrdersView.swift */, + 709FCA67231B4E8D8F976C87 /* OrderTrackingView.swift */, + D5ECD3AA9543133BE623668D /* PizzaFlavorAddonsSheet.swift */, + D6A072F5D761EFC2CAA767C3 /* PizzaProductDetailSheet.swift */, + B35D1C07050150E305FAF09A /* PizzaProductDetailSheet+Flow.swift */, + 553A05B9EAD0B9D0D9F1B369 /* ProductDetailSheet.swift */, + B807C1B779CAE1340D377D43 /* ProfileView.swift */, + 4DC5416AEBA77C43CF55ABA8 /* PublicLocationPickerView.swift */, + 6BF467DF1447525F04B242B0 /* ReviewsView.swift */, + 258B22AD407CCD44A4DC0C06 /* SavedCardsView.swift */, + ACB000ED1A0CFD200D73D4E3 /* StoreDetailPizzaSupport.swift */, + CA44F5384D21B7F718F95522 /* StoreDetailSupport.swift */, + 71DB74B676A58B367F026090 /* StoreDetailView.swift */, + AC07B79A19D5214AB24B6C39 /* StoreDetailView+Components.swift */, + 103668F3D2C86F68A07D76E7 /* StoreDetailView+Logic.swift */, + B2AD93DBB0F52B4B485C97BA /* UserProfileView.swift */, + ); + path = Main; + sourceTree = ""; + }; + C0C1E2AAD06BC8D9F221FFD5 = { + isa = PBXGroup; + children = ( + 751388ED084854A042DD69C8 /* NotificationServiceExtension */, + D9035999B1DF9773A5B7641E /* PediFoods */, + 9EAD88F6DC92C7FBB673C95A /* PediFoodsTests */, + CB598C3E047E250BA5A847DF /* PediFoodsUITests */, + 126A579C448C784867DC3AB1 /* Products */, + ); + sourceTree = ""; + }; + C3E51098EEA82A07159740FD /* Launch */ = { + isa = PBXGroup; + children = ( + F0B079DFAB606DCA87A536C9 /* LaunchSplashView.swift */, + ); + path = Launch; + sourceTree = ""; + }; + CB598C3E047E250BA5A847DF /* PediFoodsUITests */ = { + isa = PBXGroup; + children = ( + C3A0787FEAE67318791D6CFD /* AuthenticatedProfileNavigationTests.swift */, + BEAA5F0BA8EBC7F2D19472D6 /* AuthenticatedSessionFlowTests.swift */, + 7034E89267D9F5D86BF1E424 /* CartCheckoutFlowTests.swift */, + B44E3CD64AF31CEFFD932D7D /* HomeFiltersFlowTests.swift */, + BBD89D11DF567A6A6E078CC8 /* HomeGuestFlowTests.swift */, + FE0C7A506EDE8D0FD8F16AC6 /* OrderDetailsFlowTests.swift */, + 56E3E6C1834786CA3621B7BB /* PediFoodsUITestsLaunchTests.swift */, + 7C13751F450A42A2BDB0783D /* ProfileLoggedOutFlowTests.swift */, + 1A2B90A5FBAD43B6C97C8D21 /* SavedCardsFlowTests.swift */, + 5BB1ACA04E2868C622EF57D1 /* TestFixtures.swift */, + B33118037CF56CD70C332E2C /* UITestSupport.swift */, + 739EE6B1FFDC38C9B0C038F3 /* UserProfileFlowTests.swift */, + ); + path = PediFoodsUITests; + sourceTree = ""; + }; + D028B69395DC85C9ACF8F840 /* Auth */ = { + isa = PBXGroup; + children = ( + 36AA0706B5951E99AE98E6A3 /* AuthFlowView.swift */, + 28B3C375FC18002A884A44F1 /* AuthFormatters.swift */, + 30B3B6E69309F3EDF28E81CC /* LegalViews.swift */, + FDEE09D43411627E9D1973F2 /* LoginEmailView.swift */, + F18F768F7A2A47111A85FE88 /* LoginView.swift */, + 5E5FB65101BEF4615C7EB3AB /* OtpView.swift */, + B0707B6F412DC1B1CE99349B /* RegistrationView.swift */, + ); + path = Auth; + sourceTree = ""; + }; + D4D41A579B14E8A27AE766B0 /* DesignSystem */ = { + isa = PBXGroup; + children = ( + 9E595B073D646DFF412D7D19 /* DesignSystem.swift */, + ); + path = DesignSystem; + sourceTree = ""; + }; + D7552D3AF029ADAC7DE5A76B /* Components */ = { + isa = PBXGroup; + children = ( + 6EC381D02A37312BE360024F /* Buttons.swift */, + EECF1BBC608B65EC1F1B6114 /* CachedRemoteImage.swift */, + EB95D6804877D2ED244811D0 /* Inputs.swift */, + C4745C12F14B695C77DFE178 /* SnackbarOverlay.swift */, + 31916C1A4005396ADA5FF3BC /* StoreCard.swift */, + 8E3A0A2E253ABFCA35D02F28 /* TextFieldCompat.swift */, + ); + path = Components; + sourceTree = ""; + }; + D9035999B1DF9773A5B7641E /* PediFoods */ = { + isa = PBXGroup; + children = ( + 63DCCE423400D4D3785EE0F0 /* Assets.xcassets */, + 29B2F3558C58CD1E71F0F3A0 /* ContentView.swift */, + C0E7D6AA983FD6CF97727F10 /* Info.plist */, + 6CAA9B253B1A6C8815A0E69D /* LaunchScreen.storyboard */, + E371D06A1B2BC79A307AE4C8 /* PediFoods.entitlements */, + 52BFF93F21509F1AD4F6676B /* PediFoodsApp.swift */, + D7552D3AF029ADAC7DE5A76B /* Components */, + 16C5EADDD7893D92A2BBA44C /* Coordinators */, + D4D41A579B14E8A27AE766B0 /* DesignSystem */, + 0B2971A188ADC06068E94193 /* Resources */, + 625A56B75CAE1B12715C00C7 /* Services */, + 4FD8F5F326DAB3B7385FF14D /* State */, + 35B16FADECD95C7D80CA4C58 /* Support */, + 0C0093B0859FFFC84B9827FB /* Views */, + ); + path = PediFoods; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 3027BAE353B414EC70E2A2AC /* PediFoodsTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 8E3A7D85484057CE9D881564 /* Build configuration list for PBXNativeTarget "PediFoodsTests" */; + buildPhases = ( + E91B1ED717A22CC454423B96 /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + AAC14B07DC67365B47105A54 /* PBXTargetDependency */, + ); + name = PediFoodsTests; + productName = PediFoodsTests; + productReference = 6DD1D8390E57439A347F9E07 /* PediFoodsTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33EB2407F0F758EB98A769D3 /* NotificationServiceExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 3C7E6B873103305E7332898E /* Build configuration list for PBXNativeTarget "NotificationServiceExtension" */; + buildPhases = ( + 2661CA3D265B6235F979DF01 /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = NotificationServiceExtension; + productName = NotificationServiceExtension; + productReference = C724E02EC34BB1D4352C2FF0 /* NotificationServiceExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; + 66486D25E3A8A75EA8FACE44 /* PediFoodsUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = F4CA25A7E783E9F0B961A2E3 /* Build configuration list for PBXNativeTarget "PediFoodsUITests" */; + buildPhases = ( + 550F59C254CEF0B02DC9558B /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + 53BF00BD7CE0E4B89C9331A9 /* PBXTargetDependency */, + ); + name = PediFoodsUITests; + productName = PediFoodsUITests; + productReference = 8AF987AB319512C123DFD558 /* PediFoodsUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; + B3B5CFF77A8ED019D1299ABB /* PediFoods */ = { + isa = PBXNativeTarget; + buildConfigurationList = 84B4B4EE400A4F6AAE7F0658 /* Build configuration list for PBXNativeTarget "PediFoods" */; + buildPhases = ( + D5B291FBCE39187148F29BD6 /* Sources */, + 8CCC7BFEF89A92F0A68013B7 /* Resources */, + 13DAC067D54833DC00ADDA53 /* Frameworks */, + F0264640779728D58E6204F1 /* Embed Foundation Extensions */, + ); + buildRules = ( + ); + dependencies = ( + CD678F54B353CC2352FB6A88 /* PBXTargetDependency */, + ); + name = PediFoods; + packageProductDependencies = ( + B8D5E8821C95A331C3A19DA1 /* LCEssentials */, + ); + productName = PediFoods; + productReference = 7A6D74FD2D2F2F79349EA04E /* PediFoods.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 01350B58D2BE5935489326DB /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1430; + TargetAttributes = { + 33EB2407F0F758EB98A769D3 = { + DevelopmentTeam = K4E5BZMM4V; + ProvisioningStyle = Automatic; + }; + 66486D25E3A8A75EA8FACE44 = { + TestTargetID = B3B5CFF77A8ED019D1299ABB; + }; + B3B5CFF77A8ED019D1299ABB = { + ProvisioningStyle = Manual; + SystemCapabilities = { + com.apple.Push = { + enabled = YES; + }; + }; + }; + }; + }; + buildConfigurationList = 160F7992C53AEB34ACB61511 /* Build configuration list for PBXProject "PediFoods" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + es, + fr, + ja, + "zh-Hans", + ); + mainGroup = C0C1E2AAD06BC8D9F221FFD5; + packageReferences = ( + A90709E0CE6A667BD0C0FF25 /* XCRemoteSwiftPackageReference "LCEssentials" */, + ); + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33EB2407F0F758EB98A769D3 /* NotificationServiceExtension */, + B3B5CFF77A8ED019D1299ABB /* PediFoods */, + 3027BAE353B414EC70E2A2AC /* PediFoodsTests */, + 66486D25E3A8A75EA8FACE44 /* PediFoodsUITests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 8CCC7BFEF89A92F0A68013B7 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A6AE21C3CC220D50251865BE /* Assets.xcassets in Resources */, + 8D6622AE9325D02B830BD115 /* LaunchScreen.storyboard in Resources */, + 0DAC2E33C08E494991B01262 /* Localizable.xcstrings in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 2661CA3D265B6235F979DF01 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 508C3902DD2D1C42B4714724 /* NotificationService.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 550F59C254CEF0B02DC9558B /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 09FA9556CD659F93C25447DB /* AuthenticatedProfileNavigationTests.swift in Sources */, + 1AFC280D183E5DC53949C317 /* AuthenticatedSessionFlowTests.swift in Sources */, + 4B85C214A0BF3DF50311C76C /* CartCheckoutFlowTests.swift in Sources */, + A24EE365C06DBA8FD2D91213 /* HomeFiltersFlowTests.swift in Sources */, + D7B6082157DA0DF2382EB943 /* HomeGuestFlowTests.swift in Sources */, + E3C2AE0D45A31B955EFAEEAD /* OrderDetailsFlowTests.swift in Sources */, + 80B476CCE2B90BEE2FA4CB37 /* PediFoodsUITestsLaunchTests.swift in Sources */, + 68AB5B542C6486AA7FFF3258 /* ProfileLoggedOutFlowTests.swift in Sources */, + F4A296C6C0D031BB9407F7A0 /* SavedCardsFlowTests.swift in Sources */, + 709EF974B65A6E5F7C84F55D /* TestFixtures.swift in Sources */, + 7CAAE9391C622C1CAF3A08E3 /* UITestSupport.swift in Sources */, + 562033CE82B2347AE3059516 /* UserProfileFlowTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D5B291FBCE39187148F29BD6 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + DBA0E46D6CFF520460D0418C /* AddAddressFormView.swift in Sources */, + D73A4204B15D5DFE5FF75F59 /* AddCardFormView.swift in Sources */, + 5F457450CBF039AA0396E0E9 /* AddressComponents.swift in Sources */, + 1067B48BE2EEADA0358DEDB6 /* AddressesView.swift in Sources */, + 4508BD7E4DF8DF6CE6F9D8A7 /* ApiCardModels.swift in Sources */, + 33AB0B87867400F3D0C65FA0 /* ApiClient.swift in Sources */, + 704F004D8E59873BF6108FC4 /* ApiConfig.swift in Sources */, + 29E376FB24A2F19361D5BB6A /* ApiCustomerPayloadModels.swift in Sources */, + 9CFB1DF4CB441490C1290E32 /* ApiFavoriteModels.swift in Sources */, + AC7E0F52FEF7976D97190205 /* ApiModels.swift in Sources */, + 2B202626C34FC9F5C87C0388 /* ApiOrderModels.swift in Sources */, + C6F379D343C6FC9BEA7D1E18 /* ApiOrderTrackingModels.swift in Sources */, + 42FD703A96353119FAB2F711 /* ApiPizzaModels.swift in Sources */, + 691351CFE207B9F34361AEB6 /* ApiReviewModels.swift in Sources */, + 279A489F01434E0523EC1BAA /* ApiService.swift in Sources */, + B239BDCEB9F8934F1C71258C /* AppCache.swift in Sources */, + 9EDDCECCAEEB8FA4909DA9D4 /* AppCoordinator.swift in Sources */, + 9F09502CF9331F0C94BFA5F0 /* AppState.swift in Sources */, + 2892D23B9E7DB6D6310C66C6 /* AuthFlowView.swift in Sources */, + 3689239282AADF06783CBBEA /* AuthFormatters.swift in Sources */, + CCE23FD7706B34BDE544E05A /* Buttons.swift in Sources */, + CEA08090E08B5E39FD0DD5D6 /* CachedRemoteImage.swift in Sources */, + DE499D6150C15EB046AF7963 /* CartView.swift in Sources */, + 70EA850C067AB6C6B87539E2 /* CheckoutTypes.swift in Sources */, + 41BE548D929D3814A736FA12 /* CheckoutView+Logic.swift in Sources */, + 5217E18CFCBFEC47C9C5214D /* CheckoutView.swift in Sources */, + 926E0C4DD3C82485D4262A62 /* ContentView.swift in Sources */, + 781F51880A58E8BE57B6EFCE /* DeepLinkDestination.swift in Sources */, + 5C20E8F1A2948C6D1336D5AC /* DesignSystem.swift in Sources */, + 489D8790BB0DAFCD25432DEA /* FeatureControlService.swift in Sources */, + D4D5C460FF25F353D6A49B62 /* FiltersModalView.swift in Sources */, + 5E0CCBA5FAF048D363D84CE0 /* GuestLocationStore.swift in Sources */, + 69FD2F1AA2A0D7ACDCB849DE /* GuestSessionService.swift in Sources */, + 7A75836F3C1B3E776A4F6C14 /* HomeScrollOffsetObserver.swift in Sources */, + A68ED6FBE5307F8674A5A4AB /* HomeView+Data.swift in Sources */, + 90ADA376585F5438F3A1A1D6 /* HomeView+Favorites.swift in Sources */, + ABE383C64BD045B9732AA8C5 /* HomeView+Filtering.swift in Sources */, + 6B2D29A3FAFB51F5A8E16A53 /* HomeView.swift in Sources */, + 231E40975C5428826E30D0B0 /* HomeViewComponents.swift in Sources */, + 56D7A9EEFFDCC4E69BC8E42B /* ImageSourceResolver.swift in Sources */, + A3DFC65C635BE5D96660A432 /* Inputs.swift in Sources */, + B4AB8465254E51804FFF8671 /* KeychainStore.swift in Sources */, + 29094277C01B763317A53BC0 /* LaunchSplashView.swift in Sources */, + AA0A4F088033729F9B74B2BB /* LegalViews.swift in Sources */, + E7817C56D8F083A231E11E4E /* LocationService.swift in Sources */, + AD19173ACE58E819FEABD318 /* LoginEmailView.swift in Sources */, + 033958BC906741BE90CFB5EC /* LoginView.swift in Sources */, + D2FB3C624B3C27E59BCA997A /* MainTabView.swift in Sources */, + F2F123AA83C7B6593C1C6D06 /* OrderDetailsView.swift in Sources */, + B3B83AD2859F714EAAF93641 /* OrderRealtimeTracker.swift in Sources */, + 13743E96C483538ED45A442E /* OrderTrackingView.swift in Sources */, + F280C0AC9902B13E2224C790 /* OrdersView.swift in Sources */, + 7660AB80E940C2C2BA1FE8BB /* OtpView.swift in Sources */, + 17C3BAA2101C546BF4D226C2 /* PediFoodsApp.swift in Sources */, + E4D492E4B651A8FCC5016B43 /* PizzaFlavorAddonsSheet.swift in Sources */, + 07967250980FD2CD851395BB /* PizzaProductDetailSheet+Flow.swift in Sources */, + FFF187BC63BA0529029A4E5B /* PizzaProductDetailSheet.swift in Sources */, + 408D21B76BA5532970F084A6 /* PlatformCompat.swift in Sources */, + D3EC6E0A68B809F7A3370B1B /* ProductDetailSheet.swift in Sources */, + 716435D8A78871768512A316 /* ProfileView.swift in Sources */, + EBBF7EFFDC9286D4F64544F4 /* PublicLocationModels.swift in Sources */, + 045B54D94A16E3F9679C13B7 /* PublicLocationPickerView.swift in Sources */, + B193A29A377C68744B2F8FD5 /* PublicLocationService.swift in Sources */, + 01302F4B1F896EF3F28FBC07 /* PushNotificationCoordinator.swift in Sources */, + B303CFDCE85737EA9392BDFE /* RegistrationView.swift in Sources */, + B58074D656C5E6D5CF3D8EC9 /* ReviewsView.swift in Sources */, + 704327434852FB28F82915A2 /* SavedCardsView.swift in Sources */, + 9835E4C5F2EA0F7432260118 /* SessionEvents.swift in Sources */, + C6D6243D2D266D0F8BAC3F22 /* SessionStateStore.swift in Sources */, + ECFEB3B3779C4F267EC148A4 /* SnackbarCenter.swift in Sources */, + 2A1940BB0EDC3FF50127B39A /* SnackbarOverlay.swift in Sources */, + F03F08F65DFAD9166884EAD4 /* StoreCard.swift in Sources */, + 7245BC999A945B6F2BFD726B /* StoreCatalogNormalizer.swift in Sources */, + 5995B417A47CD110B053F3D9 /* StoreDetailPizzaSupport.swift in Sources */, + D8FE2EA3ADFAD7BFA2A27D2E /* StoreDetailSupport.swift in Sources */, + 93B3F9B5BD866F29387684B6 /* StoreDetailView+Components.swift in Sources */, + 03E6BA6F09890996F8CC4465 /* StoreDetailView+Logic.swift in Sources */, + 3BD69E880EBC797B87EE547B /* StoreDetailView.swift in Sources */, + C6D36ED105E3F5E7C01BC022 /* TextFieldCompat.swift in Sources */, + 7195DCDA731020CC7323853D /* TokenStore.swift in Sources */, + 1DBB6DD668615F514180CB15 /* UserProfileView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + E91B1ED717A22CC454423B96 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 64E1DF574300FD591ADB55C5 /* ApiCardModelsTests.swift in Sources */, + 40EBE2CA4D34415AC934FCD5 /* ApiClientTests.swift in Sources */, + C2245B550422BD7784B1E5D4 /* ApiCustomerPayloadModelsTests.swift in Sources */, + 939765EEF8567833CF9E27A1 /* ApiModelsDecodingTests.swift in Sources */, + 2875895B473F547F711EB088 /* ApiOrderModelsDecodingTests.swift in Sources */, + 23BEEB340760F8CD30B9EC8E /* ApiOrderTrackingModelsTests.swift in Sources */, + 26412DECECB585D0B330A6C4 /* ApiPizzaModelsDecodingTests.swift in Sources */, + 1F4F5728574CA1C69F86E43B /* ApiReviewModelsDecodingTests.swift in Sources */, + 5550A874409B7C4CBCB2E738 /* AppContentCacheTests.swift in Sources */, + 85EA52253BABA327EC07CFF0 /* AuthFormattersTests.swift in Sources */, + A115420D6854B7C9DCF339DE /* CartStateTests.swift in Sources */, + 918BCBC1EAD7EE134DB9742D /* CheckoutTypesTests.swift in Sources */, + 3DDE6E72A5F656A287AA15C5 /* CheckoutViewLogicTests.swift in Sources */, + 14A36A6DCCE57F0BBF3A539D /* DeepLinkDestinationTests.swift in Sources */, + 666B7A97829B0EE6176DC2F3 /* DeepLinkRouteEffectTests.swift in Sources */, + 01028EA36060E97C0CCF0CB0 /* FavoritesDecodingTests.swift in Sources */, + 0B8F7AFF495964AE89EE3442 /* FeatureControlServiceTests.swift in Sources */, + 44EE48E618C2DB3881FF5B13 /* FeatureFlagsStateTests.swift in Sources */, + 3CD9864DF9CEC59AC37631A3 /* GuestLocationStoreTests.swift in Sources */, + D48148B155BA2F76C41C0787 /* HomeFiltersStateTests.swift in Sources */, + 63AB40542AABCC3CB2390B87 /* HomeViewFilteringTests.swift in Sources */, + 22E3BF78B8C2B22D14EABE8B /* ImageSourceResolverTests.swift in Sources */, + 2FCB3403A764AD74AC3BD47C /* LegalDocumentTests.swift in Sources */, + F760557D906B860D0EA277DE /* SessionStateStoreTests.swift in Sources */, + B24B898508C8A8EF4191EC89 /* SmokeTests.swift in Sources */, + 7CC171B83BC4CDEC38B25F8C /* SnackbarCenterTests.swift in Sources */, + 7D7BF04765D6CA6AC4F11DDD /* StoreCatalogNormalizerTests.swift in Sources */, + CD96980F3D7B603126FDE6AC /* URLProtocolStub.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 53BF00BD7CE0E4B89C9331A9 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = B3B5CFF77A8ED019D1299ABB /* PediFoods */; + targetProxy = D6AA2F252EF50A52AB1ABBBE /* PBXContainerItemProxy */; + }; + AAC14B07DC67365B47105A54 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = B3B5CFF77A8ED019D1299ABB /* PediFoods */; + targetProxy = E46E9EB8CF662687FE324B43 /* PBXContainerItemProxy */; + }; + CD678F54B353CC2352FB6A88 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33EB2407F0F758EB98A769D3 /* NotificationServiceExtension */; + targetProxy = 775AB248D5E4D152D79FB294 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 19957F80B508525C239F6ECE /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 1B0FD6ECBDF380B221D04884 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.br.pedifoods.PediFoodsTests; + SDKROOT = iphoneos; + SWIFT_VERSION = 6; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/PediFoods.app/PediFoods"; + }; + name = Release; + }; + 388EFAF707AD295240B9E951 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + APP_ATTEST_ENVIRONMENT = production; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = PediFoods/PediFoods.entitlements; + CODE_SIGN_IDENTITY = "Apple Distribution"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = K4E5BZMM4V; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = K4E5BZMM4V; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = PediFoods/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "Pedi Foods"; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "PediFoods usa sua localização para mostrar lojas perto de você"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = NO; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDefault; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 0.0.1; + PRODUCT_BUNDLE_IDENTIFIER = com.br.pedifoods.app; + PRODUCT_NAME = PediFoods; + PROVISIONING_PROFILE_SPECIFIER = "com.br.pedifoods.app AppStore"; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "PediFoods Dist XC Prov Prof"; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 6; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; + 4424276379B7A6A98892CFBC /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + APP_ATTEST_ENVIRONMENT = development; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = PediFoods/PediFoods.entitlements; + CODE_SIGN_IDENTITY = "iPhone Developer"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = K4E5BZMM4V; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = PediFoods/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "Pedi Foods"; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "PediFoods usa sua localização para mostrar lojas perto de você"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = NO; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDefault; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 0.0.1; + PRODUCT_BUNDLE_IDENTIFIER = com.br.pedifoods.app; + PRODUCT_NAME = PediFoods; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "PediFoods Dev Prov Prof"; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 6; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + 4B6F424406B4E07BA68EBDA0 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.br.pedifoods.PediFoodsTests; + SDKROOT = iphoneos; + SWIFT_VERSION = 6; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/PediFoods.app/PediFoods"; + }; + name = Debug; + }; + 5C8EBA3956A48ACD6358F9D4 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = K4E5BZMM4V; + INFOPLIST_FILE = NotificationServiceExtension/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 0.0.1; + PRODUCT_BUNDLE_IDENTIFIER = com.br.pedifoods.app.NotificationService; + PRODUCT_NAME = NotificationService; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_VERSION = 6; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + 6278624D046E5E4FF59B68A4 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.br.pedifoods.PediFoodsUITests; + SDKROOT = iphoneos; + SWIFT_VERSION = 6; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = PediFoods; + }; + name = Debug; + }; + 7D063AB641D83F547C366166 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 88F09024654CA4508956FE72 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.br.pedifoods.PediFoodsUITests; + SDKROOT = iphoneos; + SWIFT_VERSION = 6; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = PediFoods; + }; + name = Release; + }; + D3488E7A218AD39E1390063A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = K4E5BZMM4V; + INFOPLIST_FILE = NotificationServiceExtension/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 0.0.1; + PRODUCT_BUNDLE_IDENTIFIER = com.br.pedifoods.app.NotificationService; + PRODUCT_NAME = NotificationService; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_VERSION = 6; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 160F7992C53AEB34ACB61511 /* Build configuration list for PBXProject "PediFoods" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 19957F80B508525C239F6ECE /* Debug */, + 7D063AB641D83F547C366166 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 3C7E6B873103305E7332898E /* Build configuration list for PBXNativeTarget "NotificationServiceExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 5C8EBA3956A48ACD6358F9D4 /* Debug */, + D3488E7A218AD39E1390063A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 84B4B4EE400A4F6AAE7F0658 /* Build configuration list for PBXNativeTarget "PediFoods" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 4424276379B7A6A98892CFBC /* Debug */, + 388EFAF707AD295240B9E951 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 8E3A7D85484057CE9D881564 /* Build configuration list for PBXNativeTarget "PediFoodsTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 4B6F424406B4E07BA68EBDA0 /* Debug */, + 1B0FD6ECBDF380B221D04884 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + F4CA25A7E783E9F0B961A2E3 /* Build configuration list for PBXNativeTarget "PediFoodsUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 6278624D046E5E4FF59B68A4 /* Debug */, + 88F09024654CA4508956FE72 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + A90709E0CE6A667BD0C0FF25 /* XCRemoteSwiftPackageReference "LCEssentials" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://git.loverde.com.br/Loverde-Company-LTDA/LCEssentials.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.0.11; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + B8D5E8821C95A331C3A19DA1 /* LCEssentials */ = { + isa = XCSwiftPackageProductDependency; + package = A90709E0CE6A667BD0C0FF25 /* XCRemoteSwiftPackageReference "LCEssentials" */; + productName = LCEssentials; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 01350B58D2BE5935489326DB /* Project object */; +} diff --git a/Project.xcworkspace/contents.xcworkspacedata b/PediFoods.xcodeproj/project.xcworkspace/contents.xcworkspacedata similarity index 59% rename from Project.xcworkspace/contents.xcworkspacedata rename to PediFoods.xcodeproj/project.xcworkspace/contents.xcworkspacedata index 9b1558e..919434a 100644 --- a/Project.xcworkspace/contents.xcworkspacedata +++ b/PediFoods.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -2,6 +2,6 @@ + location = "self:"> - \ No newline at end of file + diff --git a/PediFoods.xcodeproj/xcshareddata/xcschemes/PediFoods.xcscheme b/PediFoods.xcodeproj/xcshareddata/xcschemes/PediFoods.xcscheme new file mode 100644 index 0000000..42a8a10 --- /dev/null +++ b/PediFoods.xcodeproj/xcshareddata/xcschemes/PediFoods.xcscheme @@ -0,0 +1,143 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Darwin/Assets.xcassets/AccentColor.colorset/Contents.json b/PediFoods/Assets.xcassets/AccentColor.colorset/Contents.json similarity index 100% rename from Darwin/Assets.xcassets/AccentColor.colorset/Contents.json rename to PediFoods/Assets.xcassets/AccentColor.colorset/Contents.json diff --git a/Darwin/Assets.xcassets/AppIcon.appiconset/Contents.json b/PediFoods/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 100% rename from Darwin/Assets.xcassets/AppIcon.appiconset/Contents.json rename to PediFoods/Assets.xcassets/AppIcon.appiconset/Contents.json diff --git a/Darwin/Assets.xcassets/AppIcon.appiconset/icon_pattern_1024x1024 1.png b/PediFoods/Assets.xcassets/AppIcon.appiconset/icon_pattern_1024x1024 1.png similarity index 100% rename from Darwin/Assets.xcassets/AppIcon.appiconset/icon_pattern_1024x1024 1.png rename to PediFoods/Assets.xcassets/AppIcon.appiconset/icon_pattern_1024x1024 1.png diff --git a/Darwin/Assets.xcassets/AppIcon.appiconset/icon_pattern_1024x1024.png b/PediFoods/Assets.xcassets/AppIcon.appiconset/icon_pattern_1024x1024.png similarity index 100% rename from Darwin/Assets.xcassets/AppIcon.appiconset/icon_pattern_1024x1024.png rename to PediFoods/Assets.xcassets/AppIcon.appiconset/icon_pattern_1024x1024.png diff --git a/Darwin/Assets.xcassets/AppIcon.appiconset/icon_pattern_dark_1024x1024.png b/PediFoods/Assets.xcassets/AppIcon.appiconset/icon_pattern_dark_1024x1024.png similarity index 100% rename from Darwin/Assets.xcassets/AppIcon.appiconset/icon_pattern_dark_1024x1024.png rename to PediFoods/Assets.xcassets/AppIcon.appiconset/icon_pattern_dark_1024x1024.png diff --git a/Darwin/Assets.xcassets/Contents.json b/PediFoods/Assets.xcassets/Contents.json similarity index 100% rename from Darwin/Assets.xcassets/Contents.json rename to PediFoods/Assets.xcassets/Contents.json diff --git a/Darwin/Assets.xcassets/LaunchBackground.colorset/Contents.json b/PediFoods/Assets.xcassets/LaunchBackground.colorset/Contents.json similarity index 100% rename from Darwin/Assets.xcassets/LaunchBackground.colorset/Contents.json rename to PediFoods/Assets.xcassets/LaunchBackground.colorset/Contents.json diff --git a/Darwin/Assets.xcassets/alelocard_logo.imageset/Contents.json b/PediFoods/Assets.xcassets/alelocard_logo.imageset/Contents.json similarity index 100% rename from Darwin/Assets.xcassets/alelocard_logo.imageset/Contents.json rename to PediFoods/Assets.xcassets/alelocard_logo.imageset/Contents.json diff --git a/Darwin/Assets.xcassets/alelocard_logo.imageset/alelocard_logo.png b/PediFoods/Assets.xcassets/alelocard_logo.imageset/alelocard_logo.png similarity index 100% rename from Darwin/Assets.xcassets/alelocard_logo.imageset/alelocard_logo.png rename to PediFoods/Assets.xcassets/alelocard_logo.imageset/alelocard_logo.png diff --git a/Darwin/Assets.xcassets/amexcard_logo.imageset/Contents.json b/PediFoods/Assets.xcassets/amexcard_logo.imageset/Contents.json similarity index 100% rename from Darwin/Assets.xcassets/amexcard_logo.imageset/Contents.json rename to PediFoods/Assets.xcassets/amexcard_logo.imageset/Contents.json diff --git a/Darwin/Assets.xcassets/amexcard_logo.imageset/amexcard_logo.png b/PediFoods/Assets.xcassets/amexcard_logo.imageset/amexcard_logo.png similarity index 100% rename from Darwin/Assets.xcassets/amexcard_logo.imageset/amexcard_logo.png rename to PediFoods/Assets.xcassets/amexcard_logo.imageset/amexcard_logo.png diff --git a/Darwin/Assets.xcassets/hipercard_logo.imageset/Contents.json b/PediFoods/Assets.xcassets/hipercard_logo.imageset/Contents.json similarity index 100% rename from Darwin/Assets.xcassets/hipercard_logo.imageset/Contents.json rename to PediFoods/Assets.xcassets/hipercard_logo.imageset/Contents.json diff --git a/Darwin/Assets.xcassets/hipercard_logo.imageset/hipercard_logo.png b/PediFoods/Assets.xcassets/hipercard_logo.imageset/hipercard_logo.png similarity index 100% rename from Darwin/Assets.xcassets/hipercard_logo.imageset/hipercard_logo.png rename to PediFoods/Assets.xcassets/hipercard_logo.imageset/hipercard_logo.png diff --git a/Darwin/Assets.xcassets/icon_pix.imageset/Contents.json b/PediFoods/Assets.xcassets/icon_pix.imageset/Contents.json similarity index 100% rename from Darwin/Assets.xcassets/icon_pix.imageset/Contents.json rename to PediFoods/Assets.xcassets/icon_pix.imageset/Contents.json diff --git a/Darwin/Assets.xcassets/icon_pix.imageset/icon_pix.png b/PediFoods/Assets.xcassets/icon_pix.imageset/icon_pix.png similarity index 100% rename from Darwin/Assets.xcassets/icon_pix.imageset/icon_pix.png rename to PediFoods/Assets.xcassets/icon_pix.imageset/icon_pix.png diff --git a/Darwin/Assets.xcassets/mastercard_logo.imageset/Contents.json b/PediFoods/Assets.xcassets/mastercard_logo.imageset/Contents.json similarity index 100% rename from Darwin/Assets.xcassets/mastercard_logo.imageset/Contents.json rename to PediFoods/Assets.xcassets/mastercard_logo.imageset/Contents.json diff --git a/Darwin/Assets.xcassets/mastercard_logo.imageset/mastercard_logo.png b/PediFoods/Assets.xcassets/mastercard_logo.imageset/mastercard_logo.png similarity index 100% rename from Darwin/Assets.xcassets/mastercard_logo.imageset/mastercard_logo.png rename to PediFoods/Assets.xcassets/mastercard_logo.imageset/mastercard_logo.png diff --git a/Darwin/Assets.xcassets/pedifoods.imageset/Contents.json b/PediFoods/Assets.xcassets/pedifoods.imageset/Contents.json similarity index 100% rename from Darwin/Assets.xcassets/pedifoods.imageset/Contents.json rename to PediFoods/Assets.xcassets/pedifoods.imageset/Contents.json diff --git a/Darwin/Assets.xcassets/pedifoods.imageset/pedifoods.png b/PediFoods/Assets.xcassets/pedifoods.imageset/pedifoods.png similarity index 100% rename from Darwin/Assets.xcassets/pedifoods.imageset/pedifoods.png rename to PediFoods/Assets.xcassets/pedifoods.imageset/pedifoods.png diff --git a/Darwin/Assets.xcassets/pedifoods.imageset/pedifoods_dark.png b/PediFoods/Assets.xcassets/pedifoods.imageset/pedifoods_dark.png similarity index 100% rename from Darwin/Assets.xcassets/pedifoods.imageset/pedifoods_dark.png rename to PediFoods/Assets.xcassets/pedifoods.imageset/pedifoods_dark.png diff --git a/Darwin/Assets.xcassets/pin_image_app.imageset/Contents.json b/PediFoods/Assets.xcassets/pin_image_app.imageset/Contents.json similarity index 100% rename from Darwin/Assets.xcassets/pin_image_app.imageset/Contents.json rename to PediFoods/Assets.xcassets/pin_image_app.imageset/Contents.json diff --git a/Darwin/Assets.xcassets/pin_image_app.imageset/pin_image_app.png b/PediFoods/Assets.xcassets/pin_image_app.imageset/pin_image_app.png similarity index 100% rename from Darwin/Assets.xcassets/pin_image_app.imageset/pin_image_app.png rename to PediFoods/Assets.xcassets/pin_image_app.imageset/pin_image_app.png diff --git a/Darwin/Assets.xcassets/pin_image_app.imageset/pin_image_app_dark.png b/PediFoods/Assets.xcassets/pin_image_app.imageset/pin_image_app_dark.png similarity index 100% rename from Darwin/Assets.xcassets/pin_image_app.imageset/pin_image_app_dark.png rename to PediFoods/Assets.xcassets/pin_image_app.imageset/pin_image_app_dark.png diff --git a/Darwin/Assets.xcassets/placeholder-pizza.imageset/Contents.json b/PediFoods/Assets.xcassets/placeholder-pizza.imageset/Contents.json similarity index 100% rename from Darwin/Assets.xcassets/placeholder-pizza.imageset/Contents.json rename to PediFoods/Assets.xcassets/placeholder-pizza.imageset/Contents.json diff --git a/Darwin/Assets.xcassets/placeholder-pizza.imageset/placeholder-pizza.png b/PediFoods/Assets.xcassets/placeholder-pizza.imageset/placeholder-pizza.png similarity index 100% rename from Darwin/Assets.xcassets/placeholder-pizza.imageset/placeholder-pizza.png rename to PediFoods/Assets.xcassets/placeholder-pizza.imageset/placeholder-pizza.png diff --git a/Darwin/Assets.xcassets/placeholder-product.imageset/Contents.json b/PediFoods/Assets.xcassets/placeholder-product.imageset/Contents.json similarity index 100% rename from Darwin/Assets.xcassets/placeholder-product.imageset/Contents.json rename to PediFoods/Assets.xcassets/placeholder-product.imageset/Contents.json diff --git a/Darwin/Assets.xcassets/placeholder-product.imageset/placeholder-product.png b/PediFoods/Assets.xcassets/placeholder-product.imageset/placeholder-product.png similarity index 100% rename from Darwin/Assets.xcassets/placeholder-product.imageset/placeholder-product.png rename to PediFoods/Assets.xcassets/placeholder-product.imageset/placeholder-product.png diff --git a/Darwin/Assets.xcassets/sodexo_logo.imageset/Contents.json b/PediFoods/Assets.xcassets/sodexo_logo.imageset/Contents.json similarity index 100% rename from Darwin/Assets.xcassets/sodexo_logo.imageset/Contents.json rename to PediFoods/Assets.xcassets/sodexo_logo.imageset/Contents.json diff --git a/Darwin/Assets.xcassets/sodexo_logo.imageset/sodexo_logo.png b/PediFoods/Assets.xcassets/sodexo_logo.imageset/sodexo_logo.png similarity index 100% rename from Darwin/Assets.xcassets/sodexo_logo.imageset/sodexo_logo.png rename to PediFoods/Assets.xcassets/sodexo_logo.imageset/sodexo_logo.png diff --git a/Darwin/Assets.xcassets/tracking-canceled.imageset/Contents.json b/PediFoods/Assets.xcassets/tracking-canceled.imageset/Contents.json similarity index 100% rename from Darwin/Assets.xcassets/tracking-canceled.imageset/Contents.json rename to PediFoods/Assets.xcassets/tracking-canceled.imageset/Contents.json diff --git a/Darwin/Assets.xcassets/tracking-canceled.imageset/tracking-canceled.png b/PediFoods/Assets.xcassets/tracking-canceled.imageset/tracking-canceled.png similarity index 100% rename from Darwin/Assets.xcassets/tracking-canceled.imageset/tracking-canceled.png rename to PediFoods/Assets.xcassets/tracking-canceled.imageset/tracking-canceled.png diff --git a/Darwin/Assets.xcassets/tracking-completed.imageset/Contents.json b/PediFoods/Assets.xcassets/tracking-completed.imageset/Contents.json similarity index 100% rename from Darwin/Assets.xcassets/tracking-completed.imageset/Contents.json rename to PediFoods/Assets.xcassets/tracking-completed.imageset/Contents.json diff --git a/Darwin/Assets.xcassets/tracking-completed.imageset/tracking-completed.png b/PediFoods/Assets.xcassets/tracking-completed.imageset/tracking-completed.png similarity index 100% rename from Darwin/Assets.xcassets/tracking-completed.imageset/tracking-completed.png rename to PediFoods/Assets.xcassets/tracking-completed.imageset/tracking-completed.png diff --git a/Darwin/Assets.xcassets/tracking-delivering.imageset/Contents.json b/PediFoods/Assets.xcassets/tracking-delivering.imageset/Contents.json similarity index 100% rename from Darwin/Assets.xcassets/tracking-delivering.imageset/Contents.json rename to PediFoods/Assets.xcassets/tracking-delivering.imageset/Contents.json diff --git a/Darwin/Assets.xcassets/tracking-delivering.imageset/tracking-delivering.png b/PediFoods/Assets.xcassets/tracking-delivering.imageset/tracking-delivering.png similarity index 100% rename from Darwin/Assets.xcassets/tracking-delivering.imageset/tracking-delivering.png rename to PediFoods/Assets.xcassets/tracking-delivering.imageset/tracking-delivering.png diff --git a/Darwin/Assets.xcassets/tracking-pending.imageset/Contents.json b/PediFoods/Assets.xcassets/tracking-pending.imageset/Contents.json similarity index 100% rename from Darwin/Assets.xcassets/tracking-pending.imageset/Contents.json rename to PediFoods/Assets.xcassets/tracking-pending.imageset/Contents.json diff --git a/Darwin/Assets.xcassets/tracking-pending.imageset/tracking-pending.png b/PediFoods/Assets.xcassets/tracking-pending.imageset/tracking-pending.png similarity index 100% rename from Darwin/Assets.xcassets/tracking-pending.imageset/tracking-pending.png rename to PediFoods/Assets.xcassets/tracking-pending.imageset/tracking-pending.png diff --git a/Darwin/Assets.xcassets/tracking-preparing.imageset/Contents.json b/PediFoods/Assets.xcassets/tracking-preparing.imageset/Contents.json similarity index 100% rename from Darwin/Assets.xcassets/tracking-preparing.imageset/Contents.json rename to PediFoods/Assets.xcassets/tracking-preparing.imageset/Contents.json diff --git a/Darwin/Assets.xcassets/tracking-preparing.imageset/tracking-preparing.png b/PediFoods/Assets.xcassets/tracking-preparing.imageset/tracking-preparing.png similarity index 100% rename from Darwin/Assets.xcassets/tracking-preparing.imageset/tracking-preparing.png rename to PediFoods/Assets.xcassets/tracking-preparing.imageset/tracking-preparing.png diff --git a/Darwin/Assets.xcassets/tracking-ready.imageset/Contents.json b/PediFoods/Assets.xcassets/tracking-ready.imageset/Contents.json similarity index 100% rename from Darwin/Assets.xcassets/tracking-ready.imageset/Contents.json rename to PediFoods/Assets.xcassets/tracking-ready.imageset/Contents.json diff --git a/Darwin/Assets.xcassets/tracking-ready.imageset/tracking-ready.png b/PediFoods/Assets.xcassets/tracking-ready.imageset/tracking-ready.png similarity index 100% rename from Darwin/Assets.xcassets/tracking-ready.imageset/tracking-ready.png rename to PediFoods/Assets.xcassets/tracking-ready.imageset/tracking-ready.png diff --git a/Darwin/Assets.xcassets/visacard_logo.imageset/Contents.json b/PediFoods/Assets.xcassets/visacard_logo.imageset/Contents.json similarity index 100% rename from Darwin/Assets.xcassets/visacard_logo.imageset/Contents.json rename to PediFoods/Assets.xcassets/visacard_logo.imageset/Contents.json diff --git a/Darwin/Assets.xcassets/visacard_logo.imageset/visacard_logo.png b/PediFoods/Assets.xcassets/visacard_logo.imageset/visacard_logo.png similarity index 100% rename from Darwin/Assets.xcassets/visacard_logo.imageset/visacard_logo.png rename to PediFoods/Assets.xcassets/visacard_logo.imageset/visacard_logo.png diff --git a/Sources/PediFoods/Components/Buttons.swift b/PediFoods/Components/Buttons.swift similarity index 100% rename from Sources/PediFoods/Components/Buttons.swift rename to PediFoods/Components/Buttons.swift diff --git a/Sources/PediFoods/Components/CachedRemoteImage.swift b/PediFoods/Components/CachedRemoteImage.swift similarity index 72% rename from Sources/PediFoods/Components/CachedRemoteImage.swift rename to PediFoods/Components/CachedRemoteImage.swift index 6a1461d..84eefb6 100644 --- a/Sources/PediFoods/Components/CachedRemoteImage.swift +++ b/PediFoods/Components/CachedRemoteImage.swift @@ -1,10 +1,6 @@ import Foundation import SwiftUI -#if canImport(UIKit) import UIKit -#elseif canImport(AppKit) -import AppKit -#endif enum ImageFitMode { /// Scale to cover the whole box, cropping whichever axis overflows. @@ -22,9 +18,7 @@ struct CachedRemoteImage: View { let fitMode: ImageFitMode let placeholder: Placeholder -#if canImport(UIKit) || canImport(AppKit) @StateObject var loader = CachedRemoteImageLoader() -#endif init( imageURL: String?, @@ -41,52 +35,23 @@ struct CachedRemoteImage: View { var body: some View { GeometryReader { geometry in Group { -#if canImport(UIKit) if let uiImage = loader.uiImage { rendered(image: Image(uiImage: uiImage), pixelSize: uiImage.size, in: geometry.size) } else { placeholder } -#elseif canImport(AppKit) - if let nsImage = loader.nsImage { - rendered(image: Image(nsImage: nsImage), pixelSize: nsImage.size, in: geometry.size) - } else { - placeholder - } -#else - if let imageURL, - let url = URL(string: imageURL) { - AsyncImage(url: url) { phase in - switch phase { - case .success(let image): - image - .resizable() - .scaledToFill() - default: - placeholder - } - } - } else { - placeholder - } -#endif } .frame(width: geometry.size.width, height: geometry.size.height) .clipped() } .onAppear { -#if canImport(UIKit) || canImport(AppKit) loader.load(imageURL: imageURL, ttl: ttl) -#endif } .onChange(of: imageURL) { _, newValue in -#if canImport(UIKit) || canImport(AppKit) loader.load(imageURL: newValue, ttl: ttl) -#endif } } -#if canImport(UIKit) || canImport(AppKit) @ViewBuilder private func rendered(image: Image, pixelSize: CGSize, in containerSize: CGSize) -> some View { switch fitMode { @@ -103,17 +68,11 @@ struct CachedRemoteImage: View { .frame(width: containerSize.width, height: containerSize.height) } } -#endif } -#if canImport(UIKit) || canImport(AppKit) @MainActor final class CachedRemoteImageLoader: ObservableObject { -#if canImport(UIKit) @Published var uiImage: UIImage? -#elseif canImport(AppKit) - @Published var nsImage: NSImage? -#endif private var currentKey: String? private var task: Task? @@ -130,50 +89,30 @@ final class CachedRemoteImageLoader: ObservableObject { task?.cancel() -#if canImport(UIKit) uiImage = nil -#elseif canImport(AppKit) - nsImage = nil -#endif guard let normalized, normalized.isEmpty == false else { return } - #if canImport(UIKit) || canImport(AppKit) let dataCacheKey = Self.dataURLCacheKey(normalized) if let cachedDataImage: PlatformImage = AppContentCache.shared.value(for: dataCacheKey, as: PlatformImage.self) { -#if canImport(UIKit) uiImage = cachedDataImage -#elseif canImport(AppKit) - nsImage = cachedDataImage -#endif return } if let image = Self.imageFromDataURL(normalized) { AppContentCache.shared.set(image, for: dataCacheKey, ttl: ttl) -#if canImport(UIKit) uiImage = image -#elseif canImport(AppKit) - nsImage = image -#endif return } - #endif guard let url = URL(string: normalized) else { return } task = Task { [weak self] in -#if canImport(UIKit) || canImport(AppKit) let image = await AppImageCache.shared.image(for: url, ttl: ttl) guard Task.isCancelled == false else { return } await MainActor.run { -#if canImport(UIKit) self?.uiImage = image -#elseif canImport(AppKit) - self?.nsImage = image -#endif } -#endif } } @@ -187,7 +126,6 @@ final class CachedRemoteImageLoader: ObservableObject { return "data-image:\(source.count):\(head):\(tail)" } - #if canImport(UIKit) || canImport(AppKit) private static func imageFromDataURL(_ source: String) -> PlatformImage? { let lower = source.lowercased() guard lower.hasPrefix("data:image"), let commaIndex = source.firstIndex(of: ",") else { return nil } @@ -203,14 +141,6 @@ final class CachedRemoteImageLoader: ObservableObject { .replacingOccurrences(of: " ", with: "") guard let data = Data(base64Encoded: payload, options: [.ignoreUnknownCharacters]) else { return nil } -#if canImport(UIKit) return UIImage(data: data) -#elseif canImport(AppKit) - return NSImage(data: data) -#else - return nil -#endif } - #endif } -#endif diff --git a/Sources/PediFoods/Components/Inputs.swift b/PediFoods/Components/Inputs.swift similarity index 100% rename from Sources/PediFoods/Components/Inputs.swift rename to PediFoods/Components/Inputs.swift diff --git a/Sources/PediFoods/Components/SnackbarOverlay.swift b/PediFoods/Components/SnackbarOverlay.swift similarity index 100% rename from Sources/PediFoods/Components/SnackbarOverlay.swift rename to PediFoods/Components/SnackbarOverlay.swift diff --git a/Sources/PediFoods/Components/StoreCard.swift b/PediFoods/Components/StoreCard.swift similarity index 100% rename from Sources/PediFoods/Components/StoreCard.swift rename to PediFoods/Components/StoreCard.swift diff --git a/Sources/PediFoods/Components/TextFieldCompat.swift b/PediFoods/Components/TextFieldCompat.swift similarity index 100% rename from Sources/PediFoods/Components/TextFieldCompat.swift rename to PediFoods/Components/TextFieldCompat.swift diff --git a/Sources/PediFoods/ContentView.swift b/PediFoods/ContentView.swift similarity index 82% rename from Sources/PediFoods/ContentView.swift rename to PediFoods/ContentView.swift index 809c4de..3d77f86 100644 --- a/Sources/PediFoods/ContentView.swift +++ b/PediFoods/ContentView.swift @@ -1,6 +1,11 @@ import Foundation import SwiftUI +enum DeepLinkRouteEffect: Equatable { + case navigateToOrder(OrderRouteContext, tab: MainTab) + case none +} + struct ContentView: View { @State var root: RootFlow = .main @State var selectedTab: MainTab = .home @@ -15,6 +20,7 @@ struct ContentView: View { @State private var sessionExpiredObserver: NSObjectProtocol? @State var cartResetObserver: Any? @State var appResumeObserver: Any? + @State var pushDeepLinkObserver: Any? @StateObject var snackbarCenter = SnackbarCenter.shared var body: some View { @@ -34,7 +40,7 @@ struct ContentView: View { if isBootstrappingSession { sessionBootstrapLoadingView } else { - MainTabView(selectedTab: $selectedTab, root: $root, tokenStore: tokenStore, appState: $appState) + MainTabView(selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState, enterAuth: enterAuthFlow) } } } @@ -46,7 +52,7 @@ struct ContentView: View { .transition(.opacity) } } - .sheet(item: $appState.activeModal) { modal in + .sheet(item: $appState.activeModal, onDismiss: { appState.activeModal = nil }) { modal in switch modal { case .addressPicker: AddressPickerModalView(appState: $appState, selectedTab: $selectedTab) @@ -90,11 +96,13 @@ struct ContentView: View { attachCartResetObserverIfNeeded() attachAppResumeObserverIfNeeded() attachSessionExpiredObserverIfNeeded() + attachPushDeepLinkObserverIfNeeded() } .onDisappear { detachCartResetObserver() detachAppResumeObserver() detachSessionExpiredObserver() + detachPushDeepLinkObserver() } } @@ -241,6 +249,69 @@ struct ContentView: View { self.appResumeObserver = nil } + /// §6 of the push notifications guide, generalized: any tapped push that + /// resolves to a `DeepLinkDestination` (reported by + /// `PushNotificationCoordinator`) lands here. Adding a future promo/coupon + /// screen means adding one case to `route(to:)` below — this observer, + /// the notification name, and the AppState plumbing for it stay put. + private func attachPushDeepLinkObserverIfNeeded() { + guard pushDeepLinkObserver == nil else { return } + pushDeepLinkObserver = NotificationCenter.default.addObserver( + forName: .pushDeepLinkReceived, + object: nil, + queue: nil + ) { notification in + guard let destination = notification.userInfo?["destination"] as? DeepLinkDestination else { return } + Task { @MainActor in + route(to: destination) + } + } + } + + private func detachPushDeepLinkObserver() { + guard let pushDeepLinkObserver else { return } + NotificationCenter.default.removeObserver(pushDeepLinkObserver) + self.pushDeepLinkObserver = nil + } + + /// Pure mapping from a deep-link destination to what `route(to:)` should + /// do - split out so it's testable without a live `ContentView` + /// instance (this reads no `@State`, `route(to:)` is what applies the + /// result to `appState`/`selectedTab`). + func routeEffect(for destination: DeepLinkDestination) -> DeepLinkRouteEffect { + switch destination { + case .orderTracking(let orderId, let shortId): + return .navigateToOrder( + OrderRouteContext( + orderId: orderId, + shortId: shortId, + paymentMethod: nil, + total: nil, + intent: .auto + ), + tab: .profile + ) + case .screen(let name, let params): + // Promo/coupon screens (participating stores/products, §6's + // `targetScreen` convention) land here once they exist — see + // decisions/2026-08-04-push-deeplink-routing-contract.md. + logger.debug("Unhandled deep-link screen: \(name, privacy: .public) params=\(params, privacy: .public)") + return .none + } + } + + @MainActor + private func route(to destination: DeepLinkDestination) { + guard root == .main else { return } + switch routeEffect(for: destination) { + case .navigateToOrder(let context, let tab): + appState.pendingOrderDeepLink = context + selectedTab = tab + case .none: + break + } + } + @MainActor private func hydrateAppState(with customer: CustomerProfile) { appState.profile.id = customer.id @@ -336,6 +407,16 @@ struct ContentView: View { isBootstrappingSession = false appState = AppState() selectedTab = .home + enterAuthFlow() + } + + /// The only correct way to transition `root` to `.auth`: pairs the + /// switch with the token bump `LoginView` needs to run its entry + /// reveal animation. A bare `root = .auth` leaves the login screen's + /// hero/text/button stuck hidden - see + /// decisions/2026-08-06-login-entry-animation-bug.md. + @MainActor + private func enterAuthFlow() { shouldPrepareAuthEntryAnimation = true root = .auth authEntryAnimationToken += 1 diff --git a/Sources/PediFoods/Coordinators/AppCoordinator.swift b/PediFoods/Coordinators/AppCoordinator.swift similarity index 100% rename from Sources/PediFoods/Coordinators/AppCoordinator.swift rename to PediFoods/Coordinators/AppCoordinator.swift diff --git a/PediFoods/Coordinators/DeepLinkDestination.swift b/PediFoods/Coordinators/DeepLinkDestination.swift new file mode 100644 index 0000000..1a1b489 --- /dev/null +++ b/PediFoods/Coordinators/DeepLinkDestination.swift @@ -0,0 +1,49 @@ +import Foundation + +/// Single decode target for anything that can deep-link into the app — push +/// notification taps today (docs/api/push-notifications-integration-guide.md +/// §6), in-app promo/discount banner taps tomorrow (same shape: a string-keyed +/// dictionary naming a screen + its params). Adding a new destination means +/// adding one case here + one branch in `ContentView.route(to:)` — never a new +/// `NotificationCenter` name, `AppState` field, or observer pair. +enum DeepLinkDestination: Equatable { + /// `type: "order_status"` — the only push type Atomenta fixes today + /// (§6), so it gets its own case instead of routing through `targetScreen`. + case orderTracking(orderId: String, shortId: String?) + + /// Everything else, keyed by `targetScreen` — our own convention (§6 + /// leaves this to us). Once a real screen exists for a given name (e.g. + /// future promo/coupon "list of participating stores/products"), give it + /// its own case; until then this carries the raw params so nothing is + /// silently dropped. + case screen(name: String, params: [String: String]) +} + +/// Resolves the *navigation* half of a tapped push's payload. Pure and +/// side-effect-free on purpose: easy to unit test, and reusable as-is for +/// in-app banner taps once those exist. Campaign-open reporting (§6a) is a +/// separate side effect, not a navigation target, and is not decided here — +/// see `PushNotificationCoordinator.handleTap`. +struct PushDeepLinkParser { + static func parse(_ userInfo: [AnyHashable: Any]) -> DeepLinkDestination? { + if let type = userInfo["type"] as? String, type == "order_status" { + // Atomenta's real order-status push (PushNotificationService. + // notifyOrderStatusUpdate) sends only `shortId`, never `orderId` — + // `GET /api/public/orders/:orderId` accepts either as the lookup + // key server-side, so `shortId` doubles as the id here too. + let shortId = userInfo["shortId"] as? String + guard let orderId = (userInfo["orderId"] as? String) ?? shortId else { return nil } + return .orderTracking(orderId: orderId, shortId: shortId) + } + + guard let targetScreen = userInfo["targetScreen"] as? String else { return nil } + var params: [String: String] = [:] + for (key, value) in userInfo { + guard let key = key as? String, key != "type", key != "targetScreen" else { continue } + if let stringValue = value as? String { + params[key] = stringValue + } + } + return .screen(name: targetScreen, params: params) + } +} diff --git a/Sources/PediFoods/DesignSystem/DesignSystem.swift b/PediFoods/DesignSystem/DesignSystem.swift similarity index 93% rename from Sources/PediFoods/DesignSystem/DesignSystem.swift rename to PediFoods/DesignSystem/DesignSystem.swift index 0d0f26c..7ea032c 100644 --- a/Sources/PediFoods/DesignSystem/DesignSystem.swift +++ b/PediFoods/DesignSystem/DesignSystem.swift @@ -1,9 +1,5 @@ import SwiftUI -#if canImport(UIKit) import UIKit -#elseif canImport(AppKit) -import AppKit -#endif // MARK: - Colors @@ -66,13 +62,7 @@ struct AppTypography { } private static var isBrandFontAvailable: Bool { - #if canImport(UIKit) - return UIFont(name: fontFamily, size: 16) != nil - #elseif canImport(AppKit) - return NSFont(name: fontFamily, size: 16) != nil - #else - return false - #endif + UIFont(name: fontFamily, size: 16) != nil } } diff --git a/Darwin/Info.plist b/PediFoods/Info.plist similarity index 100% rename from Darwin/Info.plist rename to PediFoods/Info.plist diff --git a/Darwin/Sources/LaunchScreen.storyboard b/PediFoods/LaunchScreen.storyboard similarity index 100% rename from Darwin/Sources/LaunchScreen.storyboard rename to PediFoods/LaunchScreen.storyboard diff --git a/Darwin/Entitlements.plist b/PediFoods/PediFoods.entitlements similarity index 100% rename from Darwin/Entitlements.plist rename to PediFoods/PediFoods.entitlements diff --git a/PediFoods/PediFoodsApp.swift b/PediFoods/PediFoodsApp.swift new file mode 100644 index 0000000..0cdbe44 --- /dev/null +++ b/PediFoods/PediFoodsApp.swift @@ -0,0 +1,63 @@ +import OSLog +import SwiftUI + +let logger: Logger = Logger(subsystem: "com.br.pedifoods.app", category: "PediFoods") + +@main +struct PediFoodsApp: App { + @UIApplicationDelegateAdaptor(PediFoodsAppDelegate.self) private var appDelegate + @Environment(\.scenePhase) private var scenePhase + + var body: some Scene { + WindowGroup { + ContentView() + } + .onChange(of: scenePhase) { oldPhase, newPhase in + switch newPhase { + case .active: + logger.debug("onResume") + NotificationCenter.default.post(name: .appDidResume, object: nil) + case .inactive: + logger.debug("onPause") + case .background: + logger.debug("onStop") + @unknown default: + logger.debug("unknown scene phase: \(String(describing: newPhase))") + } + } + } +} + +final class PediFoodsAppDelegate: NSObject, UIApplicationDelegate { + func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { + logger.debug("onInit") + return true + } + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { + logger.debug("onLaunch") + Task { @MainActor in + PushNotificationCoordinator.shared.startObservingDeviceToken() + PushNotificationCoordinator.shared.becomeNotificationCenterDelegate() + await PushNotificationCoordinator.shared.refreshRegistrationIfAuthorized() + await PushNotificationCoordinator.shared.syncCustomerAttributes() + } + return true + } + + func applicationWillTerminate(_ application: UIApplication) { + logger.debug("onDestroy") + } + + func applicationDidReceiveMemoryWarning(_ application: UIApplication) { + logger.debug("onLowMemory") + } + + func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + NotificationCenter.default.post(name: NSNotification.Name("didRegisterForRemoteNotificationsWithDeviceToken"), object: application, userInfo: ["deviceToken": deviceToken]) + } + + func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: any Error) { + NotificationCenter.default.post(name: NSNotification.Name("didFailToRegisterForRemoteNotificationsWithError"), object: application, userInfo: ["error": error]) + } +} diff --git a/Sources/PediFoods/Resources/Localizable.xcstrings b/PediFoods/Resources/Localizable.xcstrings similarity index 92% rename from Sources/PediFoods/Resources/Localizable.xcstrings rename to PediFoods/Resources/Localizable.xcstrings index 35a37c3..962a0b6 100644 --- a/Sources/PediFoods/Resources/Localizable.xcstrings +++ b/PediFoods/Resources/Localizable.xcstrings @@ -3,10 +3,6 @@ "strings" : { "" : { - }, - " " : { - "comment" : "A placeholder text used to maintain vertical spacing in the UI.", - "isCommentAutoGenerated" : true }, "- %@" : { "comment" : "A text element displaying a negative monetary value, indicating a discount.", @@ -89,6 +85,9 @@ "10km" : { "comment" : "A label displayed next to the far end of the distance slider in the filters modal.", "isCommentAutoGenerated" : true + }, + "Abrir Ajustes" : { + }, "Acompanhamento em tempo real" : { @@ -148,6 +147,9 @@ "Adicione produtos para continuar." : { "comment" : "A message displayed when the cart is empty, encouraging the user to add products.", "isCommentAutoGenerated" : true + }, + "Agora não" : { + }, "AGUARDANDO PAGAMENTO" : { "comment" : "A status text indicating that the payment is pending.", @@ -212,6 +214,15 @@ "Até %lldkm" : { "comment" : "A label displaying the maximum distance filter value in kilometers. The argument is the current maximum distance filter value in kilometers.", "isCommentAutoGenerated" : true + }, + "Ativar" : { + + }, + "Ative as notificações" : { + + }, + "Ative as notificações para acompanhar em tempo real as atualizações do seu pedido." : { + }, "Atualizando status do pedido..." : { @@ -296,10 +307,6 @@ "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 - }, "Cartão de Crédito" : { }, @@ -438,9 +445,6 @@ "Do que você gostou?" : { "comment" : "A prompt asking users to mention what they liked about a review.", "isCommentAutoGenerated" : true - }, - "Editar endereço" : { - }, "Em uma escala de 0 a 10, qual é a chance de você indicar o Pedi Foods para um amigo?" : { "comment" : "A description under the NPS scale question in the order review view.", @@ -551,38 +555,9 @@ "comment" : "The title of the checkout screen.", "isCommentAutoGenerated" : true }, - "Hello [%@](https://skip.tools)!" : { - "comment" : "Welcome tab contents", - "extractionState" : "stale", - "localizations" : { - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "¡Hola [%@](https://skip.tools)!" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bonjour [%@](https://skip.tools)!" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "こんにちは [%@](https://skip.tools)" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "你好 [%@](https://skip.tools)" - } - } - } - }, "Home" : { "comment" : "Tab bar item title for the Home tab", + "extractionState" : "stale", "localizations" : { "es" : { "stringUnit" : { @@ -700,13 +675,6 @@ }, "Meus Cartões" : { - }, - "Meus Endereços" : { - "comment" : "A label displayed at the top of the view.", - "isCommentAutoGenerated" : true - }, - "Meus Pedidos" : { - }, "Minhas Avaliações" : { "comment" : "A button that links to their reviews.", @@ -809,16 +777,15 @@ } } } + }, + "Notificações desativadas" : { + }, "Novo cartão" : { }, "Novo Cartão" : { - }, - "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.", @@ -870,6 +837,9 @@ }, "Pague mais rápido nas próximas compras" : { + }, + "Para receber notificações sobre seus pedidos, ative a permissão nos Ajustes do iPhone." : { + }, "Pedido #%@" : { @@ -886,19 +856,11 @@ }, "Pedido %@" : { - }, - "PediFoods" : { - "comment" : "The name of the app.", - "isCommentAutoGenerated" : true }, "Pedir Novamente" : { "comment" : "A button that allows a user to order the same items again.", "isCommentAutoGenerated" : true }, - "Perfil" : { - "comment" : "A label for the \"Profile\" tab in the main tab view.", - "isCommentAutoGenerated" : true - }, "Pizza de varios sabores" : { "comment" : "A title displayed above the main content of the view.", "extractionState" : "stale", @@ -916,36 +878,6 @@ "comment" : "The title of the privacy policy section.", "isCommentAutoGenerated" : true }, - "Powered by [Skip](https://skip.tools)" : { - "comment" : "Link markdown text for the Powered by… label", - "extractionState" : "stale", - "localizations" : { - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Impulsado por [Skip](https://skip.tools)" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Fonctionnalités offertes par [Skip](https://skip.tools)" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "[Skip](https://skip.tools) を使って動かしています" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "由[Skip](https://skip.tools)提供支持" - } - } - } - }, "Precisa de ajuda com esse pedido?" : { }, @@ -964,10 +896,6 @@ "comment" : "A label indicating that a particular address is the user's primary address.", "isCommentAutoGenerated" : true }, - "Privacidade" : { - "comment" : "The title of the privacy policy screen.", - "isCommentAutoGenerated" : true - }, "Progresso do Pedido" : { }, @@ -975,24 +903,12 @@ "comment" : "The text for a button that allows a user to re-evaluate an order.", "isCommentAutoGenerated" : true }, - "Receber Código" : { - "comment" : "A button label that says \"Receive Code\".", - "isCommentAutoGenerated" : true - }, - "Reenviar código" : { - "comment" : "A button that allows a user to request a new OTP code.", - "isCommentAutoGenerated" : true - }, "Remover" : { "comment" : "A button that deletes the user's profile picture.", "isCommentAutoGenerated" : true }, "Remover loja dos favoritos" : { - }, - "Resposta da loja" : { - "comment" : "A heading for the reply from the store in an order review.", - "isCommentAutoGenerated" : true }, "RESTAURANT" : { @@ -1123,9 +1039,6 @@ "Seu endereço não é atendido por esta loja.\nDeseja trocar mesmo assim?" : { "comment" : "An alert that appears when a user tries to place an order from an address that is not served by the store. The alert offers the user the option to either keep the current address or to switch to a different one", "isCommentAutoGenerated" : true - }, - "Seu pedido está em andamento" : { - }, "Sim" : { "comment" : "The text for a button that confirms an action. In this case, it confirms the user's choice to continue with the order despite the address not being served by the store.", diff --git a/Sources/PediFoods/Services/ApiCardModels.swift b/PediFoods/Services/ApiCardModels.swift similarity index 100% rename from Sources/PediFoods/Services/ApiCardModels.swift rename to PediFoods/Services/ApiCardModels.swift diff --git a/Sources/PediFoods/Services/ApiClient.swift b/PediFoods/Services/ApiClient.swift similarity index 98% rename from Sources/PediFoods/Services/ApiClient.swift rename to PediFoods/Services/ApiClient.swift index 0fcae62..cfc38c9 100644 --- a/Sources/PediFoods/Services/ApiClient.swift +++ b/PediFoods/Services/ApiClient.swift @@ -1,8 +1,5 @@ import Foundation -#if canImport(FoundationNetworking) -import FoundationNetworking -#endif -#if canImport(LCEssentials) && os(iOS) +#if canImport(LCEssentials) import LCEssentials #endif @@ -45,7 +42,7 @@ enum NetworkError: Error, LocalizedError { } } -private struct ApiErrorDescriptor { +struct ApiErrorDescriptor { let code: String? let message: String? } @@ -102,7 +99,7 @@ final class ApiClient: @unchecked Sendable { // resolving — no response, no error, nothing — the UI must still // get an answer so it can stop showing "nothing happened." try await withTimeout(seconds: 20) { [self] in -#if canImport(LCEssentials) && os(iOS) +#if canImport(LCEssentials) try await sendWithLCEssentials(request) #else try await sendWithURLSession(request) @@ -128,8 +125,8 @@ final class ApiClient: @unchecked Sendable { } } -private extension ApiClient { -#if canImport(LCEssentials) && os(iOS) +extension ApiClient { +#if canImport(LCEssentials) func sendWithLCEssentials(_ request: ApiRequest) async throws -> T { let urlString = try buildURL(path: request.path, query: request.queryItems, baseURL: request.baseURLOverride ?? ApiConfig.baseURL).absoluteString let method = request.method @@ -402,7 +399,7 @@ private extension ApiClient { return false } -#if canImport(LCEssentials) && os(iOS) +#if canImport(LCEssentials) func serverMessage(from error: NSError) -> String? { for key in [NSLocalizedFailureReasonErrorKey, "body", "responseBody", "data", "message"] { if let value = error.userInfo[key] as? String, diff --git a/Sources/PediFoods/Services/ApiConfig.swift b/PediFoods/Services/ApiConfig.swift similarity index 100% rename from Sources/PediFoods/Services/ApiConfig.swift rename to PediFoods/Services/ApiConfig.swift diff --git a/Sources/PediFoods/Services/ApiCustomerPayloadModels.swift b/PediFoods/Services/ApiCustomerPayloadModels.swift similarity index 55% rename from Sources/PediFoods/Services/ApiCustomerPayloadModels.swift rename to PediFoods/Services/ApiCustomerPayloadModels.swift index fc119c8..3dc5fa9 100644 --- a/Sources/PediFoods/Services/ApiCustomerPayloadModels.swift +++ b/PediFoods/Services/ApiCustomerPayloadModels.swift @@ -38,6 +38,53 @@ struct CustomerIdentityUpdatePayload: Encodable { } } +/// `POST /api/customer/:id` — partial update, sibling of `CustomerProfileUpdatePayload`. +/// See docs/api/push-notifications-integration-guide.md §2b. +struct CustomerNotificationsUpdatePayload: Encodable { + let notificationsEnabled: Bool +} + +/// `POST /api/customer/:id` — biometric-login preference. Persistence only for +/// now; the actual Face ID/Touch ID unlock flow is a separate, later plan. +struct CustomerFaceIdUpdatePayload: Encodable { + let faceIdEnabled: Bool +} + +/// `PUT /api/customer/:id/push-token` — see docs/api/push-notifications-integration-guide.md §2. +struct CustomerPushTokenPayload: Encodable { + let pushToken: String + let deviceId: String + let deviceOS: String +} + +/// `PUT /api/customer/:id/attributes` — wholesale replace, not a merge. +/// See docs/api/push-notifications-integration-guide.md §2a. +struct CustomerAttributesUpdatePayload: Encodable { + let appVersion: String? + let attributes: [String: String]? + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(appVersion, forKey: .appVersion) + try container.encodeIfPresent(attributes, forKey: .attributes) + } + + enum CodingKeys: String, CodingKey { + case appVersion + case attributes + } +} + +/// `POST /api/customer/:id/push-campaigns/opened` — see +/// docs/api/push-notifications-integration-guide.md §6a. +struct PushCampaignOpenedPayload: Encodable { + let campaignId: String +} + +struct PushCampaignOpenedResult: Decodable { + let recorded: Bool +} + struct CustomerAddressPayload: Encodable { let label: String? let address: String? diff --git a/Sources/PediFoods/Services/ApiFavoriteModels.swift b/PediFoods/Services/ApiFavoriteModels.swift similarity index 100% rename from Sources/PediFoods/Services/ApiFavoriteModels.swift rename to PediFoods/Services/ApiFavoriteModels.swift diff --git a/Sources/PediFoods/Services/ApiModels.swift b/PediFoods/Services/ApiModels.swift similarity index 99% rename from Sources/PediFoods/Services/ApiModels.swift rename to PediFoods/Services/ApiModels.swift index dc73a2e..a156139 100644 --- a/Sources/PediFoods/Services/ApiModels.swift +++ b/PediFoods/Services/ApiModels.swift @@ -30,6 +30,8 @@ struct CustomerProfile: Decodable { let profilePicture: String? let favorites: [String]? let addressBook: [CustomerAddress]? + let notificationsEnabled: Bool? + let faceIdEnabled: Bool? enum CodingKeys: String, CodingKey { case id @@ -39,6 +41,8 @@ struct CustomerProfile: Decodable { case profilePicture case favorites case addressBook = "address_book" + case notificationsEnabled + case faceIdEnabled } } diff --git a/Sources/PediFoods/Services/ApiOrderModels.swift b/PediFoods/Services/ApiOrderModels.swift similarity index 100% rename from Sources/PediFoods/Services/ApiOrderModels.swift rename to PediFoods/Services/ApiOrderModels.swift diff --git a/Sources/PediFoods/Services/ApiOrderTrackingModels.swift b/PediFoods/Services/ApiOrderTrackingModels.swift similarity index 100% rename from Sources/PediFoods/Services/ApiOrderTrackingModels.swift rename to PediFoods/Services/ApiOrderTrackingModels.swift diff --git a/Sources/PediFoods/Services/ApiPizzaModels.swift b/PediFoods/Services/ApiPizzaModels.swift similarity index 100% rename from Sources/PediFoods/Services/ApiPizzaModels.swift rename to PediFoods/Services/ApiPizzaModels.swift diff --git a/Sources/PediFoods/Services/ApiReviewModels.swift b/PediFoods/Services/ApiReviewModels.swift similarity index 100% rename from Sources/PediFoods/Services/ApiReviewModels.swift rename to PediFoods/Services/ApiReviewModels.swift diff --git a/Sources/PediFoods/Services/ApiService.swift b/PediFoods/Services/ApiService.swift similarity index 82% rename from Sources/PediFoods/Services/ApiService.swift rename to PediFoods/Services/ApiService.swift index 2eba839..19aa0e7 100644 --- a/Sources/PediFoods/Services/ApiService.swift +++ b/PediFoods/Services/ApiService.swift @@ -88,6 +88,17 @@ final class ApiService { AppContentCache.shared.invalidate(prefix: favoritesCachePrefix) } + /// Every successful profile mutation must leave `profile()`'s cache + /// holding the server's authoritative post-mutation state — never + /// patched locally from a write-response of possibly different shape, + /// and never left merely invalidated for some future caller to lazily + /// refetch (which may never happen, leaving stale data visible + /// indefinitely within the TTL). Always does a real GET. + @discardableResult + private func refreshProfileCache() async -> ApiEnvelope? { + try? await profile(forceRefresh: true) + } + private func scopedCacheSuffix() -> String { let jwt = tokenStore.jwt ?? "anonymous" if jwt.count <= 16 { return jwt } @@ -184,10 +195,90 @@ final class ApiService { let body = try JSONEncoder().encode(payload) let req = ApiRequest(path: "/api/customer/profile", method: "PATCH", module: .customer, requiresAuth: true, body: body) let envelope: ProfilePatchEnvelope = try await send(req) - AppContentCache.shared.invalidate(prefix: profileCachePrefix) + if envelope.error == false { + await refreshProfileCache() + } return envelope } + /// See docs/api/push-notifications-integration-guide.md §2b — only reachable + /// via `POST /api/customer/:id` today, not `PATCH /profile`. + func updateNotificationsEnabled(_ enabled: Bool) async throws -> ApiEnvelope { + let currentProfile = try await profile(forceRefresh: true) + guard currentProfile.error == false, let customer = currentProfile.result else { + throw NetworkError.invalidResponse + } + + let payload = CustomerNotificationsUpdatePayload(notificationsEnabled: enabled) + let body = try JSONEncoder().encode(payload) + let req = ApiRequest(path: "/api/customer/\(customer.id)", method: "POST", module: .customer, requiresAuth: true, body: body) + let envelope: ApiEnvelope = try await sendEnvelope(req) + if envelope.error == false { + await refreshProfileCache() + } + return envelope + } + + /// Persists the biometric-login preference only — no LocalAuthentication + /// wiring yet, that's a separate later plan. + func updateFaceIdEnabled(_ enabled: Bool) async throws -> ApiEnvelope { + let currentProfile = try await profile(forceRefresh: true) + guard currentProfile.error == false, let customer = currentProfile.result else { + throw NetworkError.invalidResponse + } + + let payload = CustomerFaceIdUpdatePayload(faceIdEnabled: enabled) + let body = try JSONEncoder().encode(payload) + let req = ApiRequest(path: "/api/customer/\(customer.id)", method: "POST", module: .customer, requiresAuth: true, body: body) + let envelope: ApiEnvelope = try await sendEnvelope(req) + if envelope.error == false { + await refreshProfileCache() + } + return envelope + } + + /// See docs/api/push-notifications-integration-guide.md §2. + func registerPushToken(_ token: String, deviceId: String, deviceOS: String) async throws -> ApiEnvelope { + let currentProfile = try await profile(forceRefresh: true) + guard currentProfile.error == false, let customer = currentProfile.result else { + throw NetworkError.invalidResponse + } + + let payload = CustomerPushTokenPayload(pushToken: token, deviceId: deviceId, deviceOS: deviceOS) + let body = try JSONEncoder().encode(payload) + let req = ApiRequest(path: "/api/customer/\(customer.id)/push-token", method: "PUT", module: .customer, requiresAuth: true, body: body) + return try await sendEnvelope(req) + } + + /// See docs/api/push-notifications-integration-guide.md §2a. Wholesale + /// replace, not a merge — callers must pass every `attributes` key they + /// still want kept, not just the changed ones. + func updateCustomerAttributes(appVersion: String?, attributes: [String: String]?) async throws -> ApiEnvelope { + let currentProfile = try await profile(forceRefresh: true) + guard currentProfile.error == false, let customer = currentProfile.result else { + throw NetworkError.invalidResponse + } + + let payload = CustomerAttributesUpdatePayload(appVersion: appVersion, attributes: attributes) + let body = try JSONEncoder().encode(payload) + let req = ApiRequest(path: "/api/customer/\(customer.id)/attributes", method: "PUT", module: .customer, requiresAuth: true, body: body) + return try await sendEnvelope(req) + } + + /// See docs/api/push-notifications-integration-guide.md §6a. Fire on tap + /// only, for `type: "campaign"` pushes — idempotent server-side. + func reportPushCampaignOpened(campaignId: String) async throws -> ApiEnvelope { + let currentProfile = try await profile(forceRefresh: true) + guard currentProfile.error == false, let customer = currentProfile.result else { + throw NetworkError.invalidResponse + } + + let payload = PushCampaignOpenedPayload(campaignId: campaignId) + let body = try JSONEncoder().encode(payload) + let req = ApiRequest(path: "/api/customer/\(customer.id)/push-campaigns/opened", method: "POST", module: .customer, requiresAuth: true, body: body) + return try await sendEnvelope(req) + } + func addCustomerAddress(_ address: CustomerAddress) async throws -> ApiEnvelope { return try await saveCustomerAddress(address, replacingAddressId: nil) } @@ -234,7 +325,9 @@ final class ApiService { func setDefaultAddress(addressId: String) async throws -> ApiEnvelope { let req = ApiRequest(path: "/api/customer/addresses/\(addressId)/default", method: "PATCH", module: .customer, requiresAuth: true) let envelope: ApiEnvelope = try await sendEnvelope(req) - AppContentCache.shared.invalidate(prefix: profileCachePrefix) + if envelope.error == false { + await refreshProfileCache() + } return envelope } @@ -259,9 +352,8 @@ final class ApiService { let body = try JSONEncoder().encode(payload) let req = ApiRequest(path: "/api/customer/\(customerId)", method: "POST", module: .customer, requiresAuth: true, body: body) let envelope: ApiEnvelope = try await sendEnvelope(req) - let scopedProfileKey = "\(profileCachePrefix)\(scopedCacheSuffix())" - if envelope.error == false, envelope.result != nil { - AppContentCache.shared.set(envelope, for: scopedProfileKey, ttl: AppCacheTTL.twoHours) + if envelope.error == false { + await refreshProfileCache() } else { invalidateFavoritesCache() } @@ -488,7 +580,7 @@ final class ApiService { let req = ApiRequest(path: "/api/customer/profile", method: "PATCH", module: .customer, requiresAuth: true, body: body) let result: ApiEnvelope = try await sendEnvelope(req) if result.error == false { - AppContentCache.shared.invalidate(prefix: profileCachePrefix) + await refreshProfileCache() } return result } diff --git a/Sources/PediFoods/Services/AppCache.swift b/PediFoods/Services/AppCache.swift similarity index 88% rename from Sources/PediFoods/Services/AppCache.swift rename to PediFoods/Services/AppCache.swift index b990e60..afd4f3f 100644 --- a/Sources/PediFoods/Services/AppCache.swift +++ b/PediFoods/Services/AppCache.swift @@ -1,11 +1,6 @@ import Foundation -#if canImport(UIKit) import UIKit typealias PlatformImage = UIImage -#elseif canImport(AppKit) -import AppKit -typealias PlatformImage = NSImage -#endif enum AppCacheTTL { static let twoHours: TimeInterval = 2 * 60 * 60 @@ -61,7 +56,6 @@ final class AppContentCache: @unchecked Sendable { } } -#if canImport(UIKit) || canImport(AppKit) final class AppImageCache: @unchecked Sendable { static let shared = AppImageCache() @@ -132,23 +126,6 @@ final class AppImageCache: @unchecked Sendable { } private func platformImage(from data: Data) -> PlatformImage? { -#if canImport(UIKit) - return UIImage(data: data) -#elseif canImport(AppKit) - return NSImage(data: data) -#else - return nil -#endif + UIImage(data: data) } } -#endif - -#if !(canImport(UIKit) || canImport(AppKit)) -final class AppImageCache: @unchecked Sendable { - static let shared = AppImageCache() - - private init() {} - - func invalidateAll() {} -} -#endif diff --git a/Sources/PediFoods/Services/FeatureControlService.swift b/PediFoods/Services/FeatureControlService.swift similarity index 98% rename from Sources/PediFoods/Services/FeatureControlService.swift rename to PediFoods/Services/FeatureControlService.swift index 09f12bf..cc756b8 100644 --- a/Sources/PediFoods/Services/FeatureControlService.swift +++ b/PediFoods/Services/FeatureControlService.swift @@ -1,7 +1,4 @@ import Foundation -#if canImport(FoundationNetworking) -import FoundationNetworking -#endif struct FeatureControlRawFlag: Codable, Equatable { let enabled: Bool @@ -271,13 +268,11 @@ final class FeatureControlService { } private func appVersion() -> String { - #if canImport(UIKit) || canImport(AppKit) let version = (Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String)? .trimmingCharacters(in: .whitespacesAndNewlines) if let version, version.isEmpty == false { return version } - #endif return "0.0.0" } } diff --git a/Sources/PediFoods/Services/GuestLocationStore.swift b/PediFoods/Services/GuestLocationStore.swift similarity index 100% rename from Sources/PediFoods/Services/GuestLocationStore.swift rename to PediFoods/Services/GuestLocationStore.swift diff --git a/Sources/PediFoods/Services/GuestSessionService.swift b/PediFoods/Services/GuestSessionService.swift similarity index 100% rename from Sources/PediFoods/Services/GuestSessionService.swift rename to PediFoods/Services/GuestSessionService.swift diff --git a/Sources/PediFoods/Services/ImageSourceResolver.swift b/PediFoods/Services/ImageSourceResolver.swift similarity index 100% rename from Sources/PediFoods/Services/ImageSourceResolver.swift rename to PediFoods/Services/ImageSourceResolver.swift diff --git a/Sources/PediFoods/Services/KeychainStore.swift b/PediFoods/Services/KeychainStore.swift similarity index 100% rename from Sources/PediFoods/Services/KeychainStore.swift rename to PediFoods/Services/KeychainStore.swift diff --git a/Sources/PediFoods/Services/LocationService.swift b/PediFoods/Services/LocationService.swift similarity index 85% rename from Sources/PediFoods/Services/LocationService.swift rename to PediFoods/Services/LocationService.swift index 43d177e..4083d80 100644 --- a/Sources/PediFoods/Services/LocationService.swift +++ b/PediFoods/Services/LocationService.swift @@ -2,6 +2,25 @@ import Foundation #if os(iOS) import CoreLocation + +/// The subset of `CLLocationManager` `LocationService` needs — lets tests +/// substitute a fake instead of touching real hardware/OS permission state. +@MainActor +protocol LocationManaging: AnyObject { + var locationManagingDelegate: CLLocationManagerDelegate? { get set } + var desiredAccuracy: CLLocationAccuracy { get set } + var authorizationStatus: CLAuthorizationStatus { get } + var location: CLLocation? { get } + func requestWhenInUseAuthorization() + func requestLocation() +} + +extension CLLocationManager: LocationManaging { + var locationManagingDelegate: CLLocationManagerDelegate? { + get { delegate } + set { delegate = newValue } + } +} #endif @MainActor @@ -23,17 +42,22 @@ final class LocationService: NSObject { #endif #if os(iOS) - private let manager = CLLocationManager() + private let manager: LocationManaging private var completion: ((LocationResult) -> Void)? #endif +#if os(iOS) + init(manager: LocationManaging = CLLocationManager()) { + self.manager = manager + super.init() + manager.locationManagingDelegate = self + manager.desiredAccuracy = kCLLocationAccuracyHundredMeters + } +#else override init() { super.init() -#if os(iOS) - manager.delegate = self - manager.desiredAccuracy = kCLLocationAccuracyHundredMeters -#endif } +#endif func requestLocation(_ completion: @escaping (LocationResult) -> Void) { #if os(iOS) diff --git a/Sources/PediFoods/Services/OrderRealtimeTracker.swift b/PediFoods/Services/OrderRealtimeTracker.swift similarity index 99% rename from Sources/PediFoods/Services/OrderRealtimeTracker.swift rename to PediFoods/Services/OrderRealtimeTracker.swift index 5866add..bfb6bd7 100644 --- a/Sources/PediFoods/Services/OrderRealtimeTracker.swift +++ b/PediFoods/Services/OrderRealtimeTracker.swift @@ -1,7 +1,4 @@ import Foundation -#if canImport(FoundationNetworking) -import FoundationNetworking -#endif @MainActor final class OrderRealtimeTracker { diff --git a/Sources/PediFoods/Services/PublicLocationModels.swift b/PediFoods/Services/PublicLocationModels.swift similarity index 100% rename from Sources/PediFoods/Services/PublicLocationModels.swift rename to PediFoods/Services/PublicLocationModels.swift diff --git a/Sources/PediFoods/Services/PublicLocationService.swift b/PediFoods/Services/PublicLocationService.swift similarity index 100% rename from Sources/PediFoods/Services/PublicLocationService.swift rename to PediFoods/Services/PublicLocationService.swift diff --git a/PediFoods/Services/PushNotificationCoordinator.swift b/PediFoods/Services/PushNotificationCoordinator.swift new file mode 100644 index 0000000..b319dc1 --- /dev/null +++ b/PediFoods/Services/PushNotificationCoordinator.swift @@ -0,0 +1,220 @@ +import Foundation + +/// Client-side half of docs/api/push-notifications-integration-guide.md. +/// Owns OS permission state, APNs device-token registration, the +/// "enable notifications" action shared by the profile toggle and the +/// order-tracking fallback prompt (§2b), foreground/tap notification +/// handling (§6), and campaign open tracking (§6a). +enum PushAuthorizationState { + case authorized + case denied + case notDetermined +} + +/// Posted when a tapped push resolves to a navigable `DeepLinkDestination` +/// (§6) so `ContentView` can route without this service depending on +/// `AppState`. One name for every destination, present and future — see +/// `DeepLinkDestination`. +extension Notification.Name { + static let pushDeepLinkReceived = Notification.Name("pushDeepLinkReceived") +} + +#if os(iOS) +import UIKit +import UserNotifications + +@MainActor +final class PushNotificationCoordinator: NSObject { + static let shared = PushNotificationCoordinator() + + private var deviceTokenObserver: NSObjectProtocol? + private var didBecomeDelegate = false + + private override init() {} + + /// Call once at app launch. Listens for the device token `PediFoodsAppDelegate` + /// posts after `registerForRemoteNotifications()` resolves, and forwards it + /// to Atomenta (§2). + func startObservingDeviceToken() { + guard deviceTokenObserver == nil else { return } + deviceTokenObserver = NotificationCenter.default.addObserver( + forName: NSNotification.Name("didRegisterForRemoteNotificationsWithDeviceToken"), + object: nil, + queue: .main + ) { notification in + guard let data = notification.userInfo?["deviceToken"] as? Data else { return } + let hexToken = data.map { String(format: "%02x", $0) }.joined() + Task { await PushNotificationCoordinator.shared.sendTokenToBackend(hexToken) } + } + } + + /// Call once at app launch, before the first notification could possibly + /// arrive — makes this the `UNUserNotificationCenterDelegate` so foreground + /// pushes actually display (§6) and taps get routed/tracked (§6, §6a). + func becomeNotificationCenterDelegate() { + guard didBecomeDelegate == false else { return } + didBecomeDelegate = true + UNUserNotificationCenter.current().delegate = self + } + + /// Re-registers silently (no OS prompt) if the user already granted + /// authorization in a previous session — tokens aren't guaranteed stable + /// across launches (§2, §3.3/§4.2 of the guide). Safe to call before login. + func refreshRegistrationIfAuthorized() async { + guard await currentAuthorizationState() == .authorized else { return } + UIApplication.shared.registerForRemoteNotifications() + } + + func currentAuthorizationState() async -> PushAuthorizationState { + let settings = await UNUserNotificationCenter.current().notificationSettings() + switch settings.authorizationStatus { + case .authorized, .provisional, .ephemeral: + return .authorized + case .denied: + return .denied + case .notDetermined: + return .notDetermined + @unknown default: + return .notDetermined + } + } + + /// Shows the OS permission dialog only if it hasn't been answered yet. + /// Always calls `registerForRemoteNotifications()` when authorized — + /// including when authorization was already granted in a past session — + /// so "enable" reliably produces a fresh device token this run instead of + /// relying solely on the once-per-launch refresh. + @discardableResult + private func requestAuthorizationIfNeeded() async -> Bool { + switch await currentAuthorizationState() { + case .authorized: + UIApplication.shared.registerForRemoteNotifications() + return true + case .denied: + return false + case .notDetermined: + let granted = (try? await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound])) ?? false + if granted { + UIApplication.shared.registerForRemoteNotifications() + } + return granted + } + } + + /// Shared body of §2b's two touchpoints: request OS permission (if + /// undetermined), then flip Atomenta's `notificationsEnabled` flag. + /// Returns the server's authoritative post-update profile — callers must + /// reflect `result.notificationsEnabled` from this, not assume `true` + /// just because the request succeeded. + func enableNotifications() async -> CustomerProfile? { + guard await requestAuthorizationIfNeeded() else { return nil } + do { + let response = try await ApiService().updateNotificationsEnabled(true) + return response.error == false ? response.result : nil + } catch { + logger.error("Failed to enable push notifications: \(error.localizedDescription)") + return nil + } + } + + /// See docs/api/push-notifications-integration-guide.md §2a. Best-effort, + /// silent on failure — Campaign `appVersion`/`attributes` targeting just + /// won't match this user until the next successful call. Call right after + /// login and once per app launch (covers an app update since last launch). + func syncCustomerAttributes() async { + guard DefaultTokenStore().jwt != nil else { return } + do { + _ = try await ApiService().updateCustomerAttributes(appVersion: currentAppVersion(), attributes: nil) + } catch { + logger.error("Failed to sync customer attributes: \(error.localizedDescription)") + } + } + + private func currentAppVersion() -> String { + (Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "0.0.0" + } + + private func sendTokenToBackend(_ hexToken: String) async { + guard DefaultTokenStore().jwt != nil else { return } + do { + _ = try await ApiService().registerPushToken(hexToken, deviceId: GuestLocationStore.shared.deviceId, deviceOS: "ios") + } catch { + logger.error("Push token registration failed: \(error.localizedDescription)") + } + } + + /// §6a — fire on tap only, for `type: "campaign"` pushes. Idempotent + /// server-side, so no client-side "already reported" guard needed. + private func reportCampaignOpened(campaignId: String) async { + guard DefaultTokenStore().jwt != nil else { return } + do { + _ = try await ApiService().reportPushCampaignOpened(campaignId: campaignId) + } catch { + logger.error("Failed to report campaign open: \(error.localizedDescription)") + } + } + + /// §6 — routes on the tapped push's `data` payload. Campaign-open + /// reporting (§6a, a side effect, not a navigation target) is decided + /// directly on `type` here; navigation is delegated to + /// `PushDeepLinkParser` and forwarded to `ContentView` via + /// `NotificationCenter` (this service has no `AppState` binding of its + /// own). A push can do both — e.g. a future campaign that also sets + /// `targetScreen` reports its open *and* navigates. + fileprivate func handleTap(userInfo: [AnyHashable: Any]) { + if let type = userInfo["type"] as? String, type == "campaign", + let campaignId = userInfo["campaignId"] as? String { + Task { await reportCampaignOpened(campaignId: campaignId) } + } + + guard let destination = PushDeepLinkParser.parse(userInfo) else { return } + NotificationCenter.default.post(name: .pushDeepLinkReceived, object: nil, userInfo: ["destination": destination]) + } +} + +/// `UNNotification.userInfo` is `[AnyHashable: Any]`, which the compiler +/// can't prove `Sendable` — but it's an immutable payload handed to us +/// once by the OS, so crossing the actor boundary with it is safe in +/// practice. +private struct UncheckedSendableBox: @unchecked Sendable { + let value: Value +} + +extension PushNotificationCoordinator: UNUserNotificationCenterDelegate { + /// Without a delegate, iOS silently drops push notifications while the + /// app is foregrounded — this is what makes them display as a banner too. + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + completionHandler([.banner, .list, .sound, .badge]) + } + + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + let userInfo = UncheckedSendableBox(value: response.notification.request.content.userInfo) + Task { @MainActor in + PushNotificationCoordinator.shared.handleTap(userInfo: userInfo.value) + } + completionHandler() + } +} +#else +@MainActor +final class PushNotificationCoordinator { + static let shared = PushNotificationCoordinator() + private init() {} + + func startObservingDeviceToken() {} + func becomeNotificationCenterDelegate() {} + func refreshRegistrationIfAuthorized() async {} + func currentAuthorizationState() async -> PushAuthorizationState { .denied } + func enableNotifications() async -> CustomerProfile? { nil } + func syncCustomerAttributes() async {} +} +#endif diff --git a/Sources/PediFoods/Services/SessionEvents.swift b/PediFoods/Services/SessionEvents.swift similarity index 100% rename from Sources/PediFoods/Services/SessionEvents.swift rename to PediFoods/Services/SessionEvents.swift diff --git a/Sources/PediFoods/Services/SessionStateStore.swift b/PediFoods/Services/SessionStateStore.swift similarity index 87% rename from Sources/PediFoods/Services/SessionStateStore.swift rename to PediFoods/Services/SessionStateStore.swift index b1457da..f61d99f 100644 --- a/Sources/PediFoods/Services/SessionStateStore.swift +++ b/PediFoods/Services/SessionStateStore.swift @@ -80,6 +80,12 @@ private struct PersistedOrderReviewsState: Codable { } enum SessionStateStore { + /// Swappable for tests (isolated `UserDefaults(suiteName:)`), defaults + /// to the real app defaults in production. `UserDefaults` is thread-safe + /// on its own; `nonisolated(unsafe)` only opts out of Swift 6's static- + /// mutable-state check for the var itself, tests set it once up front. + nonisolated(unsafe) static var defaults: UserDefaults = .standard + private static let legacyAddressKey = "session.address.state.v1" private static let addressKeyPrefix = "session.address.state.v2." private static let activeUserKey = "session.active.user.v1" @@ -88,6 +94,19 @@ enum SessionStateStore { private static let pendingCartOrderKeyPrefix = "session.cart.pending-order.v1." private static let orderReviewsKeyPrefix = "session.orders.reviews.v1." private static let orderReviewDraftKeyPrefix = "session.orders.review-draft.v1." + private static let pushOptInPromptKey = "session.push.opt-in.last-prompted.v1" + private static let pushOptInCooldown: TimeInterval = 60 * 60 * 24 + + /// See docs/api/push-notifications-integration-guide.md §2b — avoid + /// re-prompting the order-tracking fallback alert on every screen visit. + static func shouldPromptPushOptIn() -> Bool { + guard let last = defaults.object(forKey: pushOptInPromptKey) as? Date else { return true } + return Date().timeIntervalSince(last) > pushOptInCooldown + } + + static func recordPushOptInPrompted() { + defaults.set(Date(), forKey: pushOptInPromptKey) + } static func makeUserKey(profileId: String?, email: String?) -> String? { let id = (profileId ?? "") @@ -110,14 +129,14 @@ enum SessionStateStore { static func setActiveUserKey(_ userKey: String?) { let trimmed = (userKey ?? "").trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { - UserDefaults.standard.removeObject(forKey: activeUserKey) + defaults.removeObject(forKey: activeUserKey) } else { - UserDefaults.standard.set(trimmed, forKey: activeUserKey) + defaults.set(trimmed, forKey: activeUserKey) } } static func loadActiveUserKey() -> String? { - let value = UserDefaults.standard.string(forKey: activeUserKey)? + let value = defaults.string(forKey: activeUserKey)? .trimmingCharacters(in: .whitespacesAndNewlines) if let value, value.isEmpty == false { return value @@ -133,7 +152,6 @@ enum SessionStateStore { } static func loadAddress() -> AddressState? { - let defaults = UserDefaults.standard let activeKey = loadActiveUserKey() let scopedKey = addressStorageKey(for: activeKey) @@ -201,17 +219,16 @@ enum SessionStateStore { longitude: state.longitude ) guard let data = try? JSONEncoder().encode(payload) else { return } - UserDefaults.standard.set(data, forKey: addressStorageKey(for: nil)) + defaults.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) + defaults.removeObject(forKey: activeUserKey) } private static func cartStorageKey(for userKey: String?) -> String { @@ -222,7 +239,6 @@ enum SessionStateStore { } static func loadCart() -> CartState? { - let defaults = UserDefaults.standard let key = cartStorageKey(for: nil) if let data = defaults.data(forKey: key), @@ -284,11 +300,11 @@ enum SessionStateStore { total: state.total ) guard let data = try? JSONEncoder().encode(payload) else { return } - UserDefaults.standard.set(data, forKey: cartStorageKey(for: nil)) + defaults.set(data, forKey: cartStorageKey(for: nil)) } static func clearCart() { - UserDefaults.standard.removeObject(forKey: cartStorageKey(for: nil)) + defaults.removeObject(forKey: cartStorageKey(for: nil)) } private static func trackedOrdersStorageKey(for userKey: String?) -> String { @@ -323,7 +339,6 @@ enum SessionStateStore { } static func loadTrackedOrders() -> [PublicOrderResult] { - let defaults = UserDefaults.standard let key = trackedOrdersStorageKey(for: nil) guard let data = defaults.data(forKey: key), let decoded = try? JSONDecoder().decode(PersistedTrackedOrdersState.self, from: data) else { @@ -348,11 +363,11 @@ enum SessionStateStore { } let payload = PersistedTrackedOrdersState(orders: orders) guard let data = try? JSONEncoder().encode(payload) else { return } - UserDefaults.standard.set(data, forKey: trackedOrdersStorageKey(for: nil)) + defaults.set(data, forKey: trackedOrdersStorageKey(for: nil)) } static func clearTrackedOrders() { - UserDefaults.standard.removeObject(forKey: trackedOrdersStorageKey(for: nil)) + defaults.removeObject(forKey: trackedOrdersStorageKey(for: nil)) } static func savePendingCartOrderId(_ orderId: String) { @@ -361,11 +376,11 @@ enum SessionStateStore { clearPendingCartOrder() return } - UserDefaults.standard.set(clean, forKey: pendingCartOrderStorageKey(for: nil)) + defaults.set(clean, forKey: pendingCartOrderStorageKey(for: nil)) } static func loadPendingCartOrderId() -> String? { - let value = UserDefaults.standard.string(forKey: pendingCartOrderStorageKey(for: nil))? + let value = defaults.string(forKey: pendingCartOrderStorageKey(for: nil))? .trimmingCharacters(in: .whitespacesAndNewlines) if let value, value.isEmpty == false { return value @@ -374,12 +389,12 @@ enum SessionStateStore { } static func clearPendingCartOrder() { - UserDefaults.standard.removeObject(forKey: pendingCartOrderStorageKey(for: nil)) + defaults.removeObject(forKey: pendingCartOrderStorageKey(for: nil)) } static func loadOrderReviews() -> [OrderReviewRecord] { let key = orderReviewsStorageKey(for: nil) - guard let data = UserDefaults.standard.data(forKey: key), + guard let data = defaults.data(forKey: key), let decoded = try? JSONDecoder().decode(PersistedOrderReviewsState.self, from: data) else { return [] } @@ -416,12 +431,12 @@ enum SessionStateStore { let payload = PersistedOrderReviewsState(reviews: reviews) guard let data = try? JSONEncoder().encode(payload) else { return } - UserDefaults.standard.set(data, forKey: orderReviewsStorageKey(for: nil)) + defaults.set(data, forKey: orderReviewsStorageKey(for: nil)) } static func loadOrderReviewDraft(orderId: String) -> OrderReviewDraftState? { let key = orderReviewDraftStorageKey(for: orderId, userKey: nil) - guard let data = UserDefaults.standard.data(forKey: key), + guard let data = defaults.data(forKey: key), let draft = try? JSONDecoder().decode(OrderReviewDraftState.self, from: data) else { return nil } @@ -431,11 +446,11 @@ enum SessionStateStore { static func saveOrderReviewDraft(_ draft: OrderReviewDraftState) { let key = orderReviewDraftStorageKey(for: draft.orderId, userKey: nil) guard let data = try? JSONEncoder().encode(draft) else { return } - UserDefaults.standard.set(data, forKey: key) + defaults.set(data, forKey: key) } static func clearOrderReviewDraft(orderId: String) { let key = orderReviewDraftStorageKey(for: orderId, userKey: nil) - UserDefaults.standard.removeObject(forKey: key) + defaults.removeObject(forKey: key) } } diff --git a/Sources/PediFoods/Services/StoreCatalogNormalizer.swift b/PediFoods/Services/StoreCatalogNormalizer.swift similarity index 100% rename from Sources/PediFoods/Services/StoreCatalogNormalizer.swift rename to PediFoods/Services/StoreCatalogNormalizer.swift diff --git a/Sources/PediFoods/Services/TokenStore.swift b/PediFoods/Services/TokenStore.swift similarity index 100% rename from Sources/PediFoods/Services/TokenStore.swift rename to PediFoods/Services/TokenStore.swift diff --git a/Sources/PediFoods/State/AppState.swift b/PediFoods/State/AppState.swift similarity index 95% rename from Sources/PediFoods/State/AppState.swift rename to PediFoods/State/AppState.swift index 3c117bf..1cd767b 100644 --- a/Sources/PediFoods/State/AppState.swift +++ b/PediFoods/State/AppState.swift @@ -10,6 +10,10 @@ struct AppState { var homeFilters = HomeFiltersState() var activeModal: AppModal? = nil var shouldNavigateToOrders: Bool = false + /// Set when a push tap (§6 of the push notifications guide) targets a + /// specific order — consumed once by `OrdersView`, which routes to it via + /// `OrderEntryDestinationView` and clears it. + var pendingOrderDeepLink: OrderRouteContext? = nil } enum FeatureFlagValue: Codable, Equatable { @@ -94,6 +98,8 @@ struct ProfileState { var phone: String = "" var profilePicture: String = "" var cpf: String = "" + var notificationsEnabled: Bool = false + var faceIdEnabled: Bool = false } struct AddressState { diff --git a/Sources/PediFoods/State/SnackbarCenter.swift b/PediFoods/State/SnackbarCenter.swift similarity index 98% rename from Sources/PediFoods/State/SnackbarCenter.swift rename to PediFoods/State/SnackbarCenter.swift index 8c7a9ca..24583ba 100644 --- a/Sources/PediFoods/State/SnackbarCenter.swift +++ b/PediFoods/State/SnackbarCenter.swift @@ -67,7 +67,7 @@ final class SnackbarCenter: ObservableObject { } } -enum SnackbarStyle: Sendable { +enum SnackbarStyle: Sendable, Equatable { case info case success case warning diff --git a/Sources/PediFoods/Support/PlatformCompat.swift b/PediFoods/Support/PlatformCompat.swift similarity index 67% rename from Sources/PediFoods/Support/PlatformCompat.swift rename to PediFoods/Support/PlatformCompat.swift index 3169da4..beb355f 100644 --- a/Sources/PediFoods/Support/PlatformCompat.swift +++ b/PediFoods/Support/PlatformCompat.swift @@ -1,21 +1,8 @@ import Foundation -#if canImport(SwiftUI) import SwiftUI -#endif - -#if canImport(CoreGraphics) import CoreGraphics -#endif - -#if canImport(UIKit) import UIKit -#endif -#if canImport(AppKit) -import AppKit -#endif - -#if canImport(UIKit) extension UIDevice { static var appSafeAreaTop: CGFloat { let scenes = UIApplication.shared.connectedScenes @@ -30,96 +17,37 @@ extension UIDevice { return 0 } } -#else -struct UIDevice { - static let topNotch: CGFloat = 0.0 - static let bottomNotch: CGFloat = 0.0 - static let appSafeAreaTop: CGFloat = 0.0 - var modelName: String { "mac" } -} - -enum UIKeyboardType: Int { - case `default` = 0 - case asciiCapable = 1 - case numbersAndPunctuation = 2 - case URL = 3 - case numberPad = 4 - case phonePad = 10 - case namePhonePad = 9 - case emailAddress = 7 - case decimalPad = 8 - case twitter = 12 - case webSearch = 13 - case asciiCapableNumberPad = 14 -} - -extension View { - @ViewBuilder - func keyboardType(_ type: UIKeyboardType) -> some View { - self - } -} -#endif func appReadClipboardText() -> String? { -#if canImport(UIKit) - return UIPasteboard.general.string -#elseif canImport(AppKit) - return NSPasteboard.general.string(forType: .string) -#else - return nil -#endif + UIPasteboard.general.string } func appWriteClipboardText(_ value: String) { -#if canImport(UIKit) UIPasteboard.general.string = value -#elseif canImport(AppKit) - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(value, forType: .string) -#else - _ = value -#endif } extension View { @ViewBuilder func appInlineNavigationTitle() -> some View { -#if os(macOS) - self -#else self .navigationBarTitleDisplayMode(.inline) .modifier(AppRoundedBackButtonModifier()) -#endif } @ViewBuilder func appHiddenNavigationBar() -> some View { -#if os(macOS) - self -#else self .toolbar(.hidden, for: .navigationBar) .toolbarBackground(.hidden, for: .navigationBar) -#endif } @ViewBuilder func appTopBarTrailingToolbar(@ViewBuilder content: () -> Content) -> some View { -#if os(macOS) - self.toolbar { - ToolbarItem { - content() - } - } -#else self.toolbar { ToolbarItem(placement: .topBarTrailing) { content() } } -#endif } @ViewBuilder @@ -145,7 +73,6 @@ extension View { } } -#if !os(macOS) private struct AppRoundedBackButtonModifier: ViewModifier { @Environment(\.dismiss) private var dismiss @@ -168,4 +95,3 @@ private struct AppRoundedBackButtonModifier: ViewModifier { } } } -#endif diff --git a/Sources/PediFoods/Views/Auth/AuthFlowView.swift b/PediFoods/Views/Auth/AuthFlowView.swift similarity index 100% rename from Sources/PediFoods/Views/Auth/AuthFlowView.swift rename to PediFoods/Views/Auth/AuthFlowView.swift diff --git a/Sources/PediFoods/Views/Auth/AuthFormatters.swift b/PediFoods/Views/Auth/AuthFormatters.swift similarity index 100% rename from Sources/PediFoods/Views/Auth/AuthFormatters.swift rename to PediFoods/Views/Auth/AuthFormatters.swift diff --git a/Sources/PediFoods/Views/Auth/LegalViews.swift b/PediFoods/Views/Auth/LegalViews.swift similarity index 86% rename from Sources/PediFoods/Views/Auth/LegalViews.swift rename to PediFoods/Views/Auth/LegalViews.swift index a504b53..58e53b4 100644 --- a/Sources/PediFoods/Views/Auth/LegalViews.swift +++ b/PediFoods/Views/Auth/LegalViews.swift @@ -11,7 +11,6 @@ private struct RemotePDFView: View { } } -#if canImport(UIKit) private struct RemotePDFRepresentable: UIViewRepresentable { let url: URL @Binding var isLoading: Bool @@ -30,26 +29,6 @@ private struct RemotePDFRepresentable: UIViewRepresentable { RemotePDFCoordinator(isLoading: $isLoading, hasError: $hasError) } } -#elseif canImport(AppKit) -private struct RemotePDFRepresentable: NSViewRepresentable { - let url: URL - @Binding var isLoading: Bool - @Binding var hasError: Bool - - func makeNSView(context: Context) -> WKWebView { - let webView = WKWebView() - webView.navigationDelegate = context.coordinator - webView.load(URLRequest(url: url)) - return webView - } - - func updateNSView(_ nsView: WKWebView, context: Context) {} - - func makeCoordinator() -> RemotePDFCoordinator { - RemotePDFCoordinator(isLoading: $isLoading, hasError: $hasError) - } -} -#endif private final class RemotePDFCoordinator: NSObject, WKNavigationDelegate { @Binding var isLoading: Bool diff --git a/Sources/PediFoods/Views/Auth/LoginEmailView.swift b/PediFoods/Views/Auth/LoginEmailView.swift similarity index 100% rename from Sources/PediFoods/Views/Auth/LoginEmailView.swift rename to PediFoods/Views/Auth/LoginEmailView.swift diff --git a/Sources/PediFoods/Views/Auth/LoginView.swift b/PediFoods/Views/Auth/LoginView.swift similarity index 100% rename from Sources/PediFoods/Views/Auth/LoginView.swift rename to PediFoods/Views/Auth/LoginView.swift diff --git a/Sources/PediFoods/Views/Auth/OtpView.swift b/PediFoods/Views/Auth/OtpView.swift similarity index 98% rename from Sources/PediFoods/Views/Auth/OtpView.swift rename to PediFoods/Views/Auth/OtpView.swift index 5808611..bcf93ce 100644 --- a/Sources/PediFoods/Views/Auth/OtpView.swift +++ b/PediFoods/Views/Auth/OtpView.swift @@ -1,7 +1,5 @@ import SwiftUI -#if canImport(UIKit) import UIKit -#endif struct OtpView: View { private let resendDelaySeconds = 45 @@ -250,6 +248,7 @@ struct OtpView: View { appState.session.jwt = response.result?.token let hasServerAddress = hydrateUserState(from: response.result?.customer) routeAfterLogin(hasServerAddress: hasServerAddress) + Task { await PushNotificationCoordinator.shared.syncCustomerAttributes() } } } catch { await MainActor.run { @@ -281,6 +280,8 @@ struct OtpView: View { appState.profile.email = customer.email appState.profile.phone = customer.phoneNumber ?? "" appState.profile.profilePicture = customer.profilePicture ?? "" + appState.profile.notificationsEnabled = customer.notificationsEnabled ?? false + appState.profile.faceIdEnabled = customer.faceIdEnabled ?? false appState.favorites.storeIds = Set(customer.favorites ?? []) SessionStateStore.setActiveUserKey( SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email) diff --git a/Sources/PediFoods/Views/Auth/RegistrationView.swift b/PediFoods/Views/Auth/RegistrationView.swift similarity index 100% rename from Sources/PediFoods/Views/Auth/RegistrationView.swift rename to PediFoods/Views/Auth/RegistrationView.swift diff --git a/Sources/PediFoods/Views/Launch/LaunchSplashView.swift b/PediFoods/Views/Launch/LaunchSplashView.swift similarity index 100% rename from Sources/PediFoods/Views/Launch/LaunchSplashView.swift rename to PediFoods/Views/Launch/LaunchSplashView.swift diff --git a/Sources/PediFoods/Views/Main/AddAddressFormView.swift b/PediFoods/Views/Main/AddAddressFormView.swift similarity index 100% rename from Sources/PediFoods/Views/Main/AddAddressFormView.swift rename to PediFoods/Views/Main/AddAddressFormView.swift diff --git a/Sources/PediFoods/Views/Main/AddCardFormView.swift b/PediFoods/Views/Main/AddCardFormView.swift similarity index 100% rename from Sources/PediFoods/Views/Main/AddCardFormView.swift rename to PediFoods/Views/Main/AddCardFormView.swift diff --git a/Sources/PediFoods/Views/Main/AddressComponents.swift b/PediFoods/Views/Main/AddressComponents.swift similarity index 98% rename from Sources/PediFoods/Views/Main/AddressComponents.swift rename to PediFoods/Views/Main/AddressComponents.swift index 04d2817..b5f4248 100644 --- a/Sources/PediFoods/Views/Main/AddressComponents.swift +++ b/PediFoods/Views/Main/AddressComponents.swift @@ -1,7 +1,5 @@ import SwiftUI -#if canImport(UIKit) import UIKit -#endif func formatZipCodeBR(_ input: String) -> String { let digits = input.filter(\.isNumber) @@ -19,15 +17,11 @@ func normalizeZipCodeForAPI(_ input: String) -> String { } func triggerLightHaptic() { -#if canImport(UIKit) UIImpactFeedbackGenerator(style: .light).impactOccurred() -#endif } func triggerSelectionHaptic() { -#if canImport(UIKit) UISelectionFeedbackGenerator().selectionChanged() -#endif } struct SwipeToDeleteAddressRow: View { diff --git a/Sources/PediFoods/Views/Main/AddressesView.swift b/PediFoods/Views/Main/AddressesView.swift similarity index 99% rename from Sources/PediFoods/Views/Main/AddressesView.swift rename to PediFoods/Views/Main/AddressesView.swift index 9817781..2a19559 100644 --- a/Sources/PediFoods/Views/Main/AddressesView.swift +++ b/PediFoods/Views/Main/AddressesView.swift @@ -386,6 +386,8 @@ struct AddressesView: View { appState.profile.email = customer.email appState.profile.phone = customer.phoneNumber ?? appState.profile.phone appState.profile.profilePicture = customer.profilePicture ?? "" + appState.profile.notificationsEnabled = customer.notificationsEnabled ?? false + appState.profile.faceIdEnabled = customer.faceIdEnabled ?? false SessionStateStore.setActiveUserKey( SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email) ) diff --git a/Sources/PediFoods/Views/Main/CartView.swift b/PediFoods/Views/Main/CartView.swift similarity index 99% rename from Sources/PediFoods/Views/Main/CartView.swift rename to PediFoods/Views/Main/CartView.swift index 9f64b4b..ac11fa7 100644 --- a/Sources/PediFoods/Views/Main/CartView.swift +++ b/PediFoods/Views/Main/CartView.swift @@ -4,7 +4,7 @@ import SwiftUI struct CartView: View { @Binding var appState: AppState @Binding var selectedTab: MainTab - @Binding var root: RootFlow + let enterAuth: () -> Void @State var openCheckout = false @State var couponCode = "" @State var appliedCouponCode: String? = nil @@ -150,7 +150,7 @@ struct CartView: View { Button { guard appState.session.isAuthenticated else { - root = .auth + enterAuth() return } openCheckout = true diff --git a/Sources/PediFoods/Views/Main/CheckoutTypes.swift b/PediFoods/Views/Main/CheckoutTypes.swift similarity index 100% rename from Sources/PediFoods/Views/Main/CheckoutTypes.swift rename to PediFoods/Views/Main/CheckoutTypes.swift diff --git a/Sources/PediFoods/Views/Main/CheckoutView+Logic.swift b/PediFoods/Views/Main/CheckoutView+Logic.swift similarity index 99% rename from Sources/PediFoods/Views/Main/CheckoutView+Logic.swift rename to PediFoods/Views/Main/CheckoutView+Logic.swift index 21151f3..9d34469 100644 --- a/Sources/PediFoods/Views/Main/CheckoutView+Logic.swift +++ b/PediFoods/Views/Main/CheckoutView+Logic.swift @@ -80,6 +80,8 @@ extension CheckoutView { appState.profile.phone = phoneNumber } appState.profile.profilePicture = customer.profilePicture ?? "" + appState.profile.notificationsEnabled = customer.notificationsEnabled ?? false + appState.profile.faceIdEnabled = customer.faceIdEnabled ?? false SessionStateStore.setActiveUserKey( SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email) ) diff --git a/Sources/PediFoods/Views/Main/CheckoutView.swift b/PediFoods/Views/Main/CheckoutView.swift similarity index 99% rename from Sources/PediFoods/Views/Main/CheckoutView.swift rename to PediFoods/Views/Main/CheckoutView.swift index 0c692bd..0bd4df7 100644 --- a/Sources/PediFoods/Views/Main/CheckoutView.swift +++ b/PediFoods/Views/Main/CheckoutView.swift @@ -1,9 +1,5 @@ import SwiftUI -#if canImport(UIKit) import UIKit -#elseif canImport(AppKit) -import AppKit -#endif struct CheckoutView: View { @Binding var appState: AppState diff --git a/Sources/PediFoods/Views/Main/FiltersModalView.swift b/PediFoods/Views/Main/FiltersModalView.swift similarity index 100% rename from Sources/PediFoods/Views/Main/FiltersModalView.swift rename to PediFoods/Views/Main/FiltersModalView.swift diff --git a/Sources/PediFoods/Views/Main/HomeScrollOffsetObserver.swift b/PediFoods/Views/Main/HomeScrollOffsetObserver.swift similarity index 100% rename from Sources/PediFoods/Views/Main/HomeScrollOffsetObserver.swift rename to PediFoods/Views/Main/HomeScrollOffsetObserver.swift diff --git a/Sources/PediFoods/Views/Main/HomeView+Data.swift b/PediFoods/Views/Main/HomeView+Data.swift similarity index 100% rename from Sources/PediFoods/Views/Main/HomeView+Data.swift rename to PediFoods/Views/Main/HomeView+Data.swift diff --git a/Sources/PediFoods/Views/Main/HomeView+Favorites.swift b/PediFoods/Views/Main/HomeView+Favorites.swift similarity index 100% rename from Sources/PediFoods/Views/Main/HomeView+Favorites.swift rename to PediFoods/Views/Main/HomeView+Favorites.swift diff --git a/Sources/PediFoods/Views/Main/HomeView+Filtering.swift b/PediFoods/Views/Main/HomeView+Filtering.swift similarity index 100% rename from Sources/PediFoods/Views/Main/HomeView+Filtering.swift rename to PediFoods/Views/Main/HomeView+Filtering.swift diff --git a/Sources/PediFoods/Views/Main/HomeView.swift b/PediFoods/Views/Main/HomeView.swift similarity index 100% rename from Sources/PediFoods/Views/Main/HomeView.swift rename to PediFoods/Views/Main/HomeView.swift diff --git a/Sources/PediFoods/Views/Main/HomeViewComponents.swift b/PediFoods/Views/Main/HomeViewComponents.swift similarity index 100% rename from Sources/PediFoods/Views/Main/HomeViewComponents.swift rename to PediFoods/Views/Main/HomeViewComponents.swift diff --git a/Sources/PediFoods/Views/Main/MainTabView.swift b/PediFoods/Views/Main/MainTabView.swift similarity index 86% rename from Sources/PediFoods/Views/Main/MainTabView.swift rename to PediFoods/Views/Main/MainTabView.swift index cdbb57f..acb2036 100644 --- a/Sources/PediFoods/Views/Main/MainTabView.swift +++ b/PediFoods/Views/Main/MainTabView.swift @@ -2,9 +2,9 @@ import SwiftUI struct MainTabView: View { @Binding var selectedTab: MainTab - @Binding var root: RootFlow let tokenStore: TokenStore @Binding var appState: AppState + let enterAuth: () -> Void var body: some View { ZStack(alignment: .bottom) { @@ -16,15 +16,15 @@ struct MainTabView: View { } case .cart: NavigationStack { - CartView(appState: $appState, selectedTab: $selectedTab, root: $root) + CartView(appState: $appState, selectedTab: $selectedTab, enterAuth: enterAuth) } case .profile: - if appState.session.isAuthenticated { - NavigationStack { - ProfileView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState) + NavigationStack { + if appState.session.isAuthenticated { + ProfileView(selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState, enterAuth: enterAuth) + } else { + ProfileLoggedOutView(enterAuth: enterAuth) } - } else { - ProfileLoggedOutView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState) } } } diff --git a/Sources/PediFoods/Views/Main/OrderDetailsView.swift b/PediFoods/Views/Main/OrderDetailsView.swift similarity index 100% rename from Sources/PediFoods/Views/Main/OrderDetailsView.swift rename to PediFoods/Views/Main/OrderDetailsView.swift diff --git a/Sources/PediFoods/Views/Main/OrderTrackingView.swift b/PediFoods/Views/Main/OrderTrackingView.swift similarity index 94% rename from Sources/PediFoods/Views/Main/OrderTrackingView.swift rename to PediFoods/Views/Main/OrderTrackingView.swift index 5a4fcfc..4311f2f 100644 --- a/Sources/PediFoods/Views/Main/OrderTrackingView.swift +++ b/PediFoods/Views/Main/OrderTrackingView.swift @@ -1,4 +1,5 @@ import SwiftUI +import UIKit private struct TrackingStep: Identifiable { let id: String @@ -25,6 +26,7 @@ struct OrderTrackingView: View { @State var reviewDraft: ReviewDraft? = nil @State var didSaveReviewForCurrentOrder = false @State var reviewSavedObserver: Any? + @State var showPushOptInAlert = false var body: some View { ScrollView(showsIndicators: false) { @@ -53,8 +55,15 @@ struct OrderTrackingView: View { } message: { Text(cancellationReasonText) } + .alert("Ative as notificações", isPresented: $showPushOptInAlert) { + Button("Agora não", role: .cancel) {} + Button("Ativar") { Task { await enablePushNotifications() } } + } message: { + Text("Ative as notificações para acompanhar em tempo real as atualizações do seu pedido.") + } .task { await loadInitialOrder() + await maybePromptPushOptIn() tracker.onOrderUpdated = { updated in order = updated if let inlinePhone = updated.storePhone, inlinePhone.isEmpty == false { @@ -62,6 +71,7 @@ struct OrderTrackingView: View { } isLoading = false errorMessage = nil + Task { await maybePromptPushOptIn() } } tracker.start(orderId: orderId, jwt: DefaultTokenStore().jwt) } @@ -731,16 +741,7 @@ struct OrderTrackingView: View { } private func imageResourceExists(_ name: String) -> Bool { - let exts = ["png", "jpg", "jpeg", "webp"] - for ext in exts { - if Bundle.main.url(forResource: name, withExtension: ext) != nil { - return true - } - if Bundle.module.url(forResource: name, withExtension: ext) != nil { - return true - } - } - return false + UIImage(named: name) != nil } private var trackingImageName: String { @@ -827,6 +828,38 @@ struct OrderTrackingView: View { isLoading = false } + /// Touchpoint 2 of docs/api/push-notifications-integration-guide.md §2b — + /// last practical moment to recover an opted-out user before order-status + /// push (§6, `type: "order_status"`) goes silent for them for this order. + @MainActor + private func maybePromptPushOptIn() async { + guard showPushOptInAlert == false, isWaitingPayment == false, isCanceled == false else { return } + guard SessionStateStore.shouldPromptPushOptIn() else { return } + + let osAuthorized = await PushNotificationCoordinator.shared.currentAuthorizationState() == .authorized + var serverEnabled = false + if let profileResponse = try? await ApiService().profile(), profileResponse.error == false { + serverEnabled = profileResponse.result?.notificationsEnabled ?? false + } + guard osAuthorized == false || serverEnabled == false else { return } + + SessionStateStore.recordPushOptInPrompted() + showPushOptInAlert = true + } + + @MainActor + private func enablePushNotifications() async { + let profile = await PushNotificationCoordinator.shared.enableNotifications() + if profile?.notificationsEnabled != true { + SnackbarCenter.shared.show( + title: "Ative notificações nos Ajustes do iPhone para acompanhar seu pedido.", + style: .warning, + icon: "bell.slash.fill", + duration: 3.5 + ) + } + } + @MainActor private func refreshStoreContactPhone(for order: PublicOrderResult) async { guard let storeId = order.storeId?.trimmingCharacters(in: .whitespacesAndNewlines), storeId.isEmpty == false else { diff --git a/Sources/PediFoods/Views/Main/OrdersView.swift b/PediFoods/Views/Main/OrdersView.swift similarity index 99% rename from Sources/PediFoods/Views/Main/OrdersView.swift rename to PediFoods/Views/Main/OrdersView.swift index 4c0b62b..9f6d8b0 100644 --- a/Sources/PediFoods/Views/Main/OrdersView.swift +++ b/PediFoods/Views/Main/OrdersView.swift @@ -51,6 +51,12 @@ struct OrdersView: View { await loadOrdersIfNeeded() await refreshStoreRatings() } + .onAppear { + if let pending = appState.pendingOrderDeepLink { + appState.pendingOrderDeepLink = nil + selectedOrderRoute = pending + } + } .refreshable { // Decoupled from .refreshable's own cancellable wrapping Task — // see StoreDetailView's .refreshable for why. diff --git a/Sources/PediFoods/Views/Main/PizzaFlavorAddonsSheet.swift b/PediFoods/Views/Main/PizzaFlavorAddonsSheet.swift similarity index 100% rename from Sources/PediFoods/Views/Main/PizzaFlavorAddonsSheet.swift rename to PediFoods/Views/Main/PizzaFlavorAddonsSheet.swift diff --git a/Sources/PediFoods/Views/Main/PizzaProductDetailSheet+Flow.swift b/PediFoods/Views/Main/PizzaProductDetailSheet+Flow.swift similarity index 100% rename from Sources/PediFoods/Views/Main/PizzaProductDetailSheet+Flow.swift rename to PediFoods/Views/Main/PizzaProductDetailSheet+Flow.swift diff --git a/Sources/PediFoods/Views/Main/PizzaProductDetailSheet.swift b/PediFoods/Views/Main/PizzaProductDetailSheet.swift similarity index 100% rename from Sources/PediFoods/Views/Main/PizzaProductDetailSheet.swift rename to PediFoods/Views/Main/PizzaProductDetailSheet.swift diff --git a/Sources/PediFoods/Views/Main/ProductDetailSheet.swift b/PediFoods/Views/Main/ProductDetailSheet.swift similarity index 100% rename from Sources/PediFoods/Views/Main/ProductDetailSheet.swift rename to PediFoods/Views/Main/ProductDetailSheet.swift diff --git a/Sources/PediFoods/Views/Main/ProfileView.swift b/PediFoods/Views/Main/ProfileView.swift similarity index 89% rename from Sources/PediFoods/Views/Main/ProfileView.swift rename to PediFoods/Views/Main/ProfileView.swift index cb00395..7c74e9e 100644 --- a/Sources/PediFoods/Views/Main/ProfileView.swift +++ b/PediFoods/Views/Main/ProfileView.swift @@ -1,16 +1,14 @@ import SwiftUI -#if canImport(LCEssentials) && os(iOS) +#if canImport(LCEssentials) import LCEssentials #endif -#if canImport(UIKit) import UIKit -#endif struct ProfileView: View { - @Binding var root: RootFlow @Binding var selectedTab: MainTab let tokenStore: TokenStore @Binding var appState: AppState + let enterAuth: () -> Void @State var openAddressesOnboarding = false @State var onboardingMessage: String? = nil @State var showLogoutAlert = false @@ -181,6 +179,11 @@ struct ProfileView: View { openOrders = true } } + .onChange(of: appState.pendingOrderDeepLink) { _, val in + if val != nil { + openOrders = true + } + } } private var header: some View { @@ -278,7 +281,7 @@ struct ProfileView: View { AppContentCache.shared.invalidate() AppImageCache.shared.invalidateAll() appState = AppState() - root = .auth + enterAuth() } @MainActor @@ -311,40 +314,46 @@ struct ProfileView: View { } struct ProfileLoggedOutView: View { - @Binding var root: RootFlow - @Binding var selectedTab: MainTab - let tokenStore: TokenStore - @Binding var appState: AppState - @State private var path: [Route] = [] - @State private var entryAnimationToken = 0 + let enterAuth: () -> Void var body: some View { - NavigationStack(path: $path) { - LoginView( - root: $root, - selectedTab: $selectedTab, - tokenStore: tokenStore, - appState: $appState, - shouldPrepareEntryAnimation: true, - authEntryAnimationToken: entryAnimationToken, - navigate: { route in path.append(route) } - ) - .onAppear { entryAnimationToken += 1 } - .navigationDestination(for: Route.self) { route in - switch route { - case .terms: - TermsOfUseView() - case .policy: - PrivacyPolicyView() - case .registration: - RegistrationView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState, navigate: { route in path.append(route) }) - case .loginEmail: - LoginEmailView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState, navigate: { route in path.append(route) }) - case .otp(let email, let phoneNumber): - OtpView(email: email, phoneNumber: phoneNumber, root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState) - } + VStack(spacing: 18) { + Spacer() + + Image(systemName: "person.crop.circle.badge.questionmark") + .font(.system(size: 56, weight: .regular)) + .foregroundStyle(AppColors.textMuted) + + Text("Entre na sua conta") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + + Text("Faça login ou cadastre-se para ver seu perfil, pedidos e endereços.") + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + + Button { + enterAuth() + } label: { + Text("Entrar ou Cadastrar") + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textInverse) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(AppColors.primary) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) } + .buttonStyle(.plain) + .padding(.horizontal, 32) + .padding(.top, 8) + + Spacer() + Spacer() } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(AppColors.backgroundLight) } } diff --git a/Sources/PediFoods/Views/Main/PublicLocationPickerView.swift b/PediFoods/Views/Main/PublicLocationPickerView.swift similarity index 100% rename from Sources/PediFoods/Views/Main/PublicLocationPickerView.swift rename to PediFoods/Views/Main/PublicLocationPickerView.swift diff --git a/Sources/PediFoods/Views/Main/ReviewsView.swift b/PediFoods/Views/Main/ReviewsView.swift similarity index 100% rename from Sources/PediFoods/Views/Main/ReviewsView.swift rename to PediFoods/Views/Main/ReviewsView.swift diff --git a/Sources/PediFoods/Views/Main/SavedCardsView.swift b/PediFoods/Views/Main/SavedCardsView.swift similarity index 100% rename from Sources/PediFoods/Views/Main/SavedCardsView.swift rename to PediFoods/Views/Main/SavedCardsView.swift diff --git a/Sources/PediFoods/Views/Main/StoreDetailPizzaSupport.swift b/PediFoods/Views/Main/StoreDetailPizzaSupport.swift similarity index 100% rename from Sources/PediFoods/Views/Main/StoreDetailPizzaSupport.swift rename to PediFoods/Views/Main/StoreDetailPizzaSupport.swift diff --git a/Sources/PediFoods/Views/Main/StoreDetailSupport.swift b/PediFoods/Views/Main/StoreDetailSupport.swift similarity index 100% rename from Sources/PediFoods/Views/Main/StoreDetailSupport.swift rename to PediFoods/Views/Main/StoreDetailSupport.swift diff --git a/Sources/PediFoods/Views/Main/StoreDetailView+Components.swift b/PediFoods/Views/Main/StoreDetailView+Components.swift similarity index 99% rename from Sources/PediFoods/Views/Main/StoreDetailView+Components.swift rename to PediFoods/Views/Main/StoreDetailView+Components.swift index 8186947..ade35f9 100644 --- a/Sources/PediFoods/Views/Main/StoreDetailView+Components.swift +++ b/PediFoods/Views/Main/StoreDetailView+Components.swift @@ -209,11 +209,7 @@ extension StoreDetailView { func categoryTabs(proxy: ScrollViewProxy, isPinned: Bool = false) -> some View { let safeTop: CGFloat = { guard isPinned else { return 0 } - #if canImport(UIKit) return UIDevice.appSafeAreaTop - #else - return 0 - #endif }() return ScrollView(.horizontal, showsIndicators: false) { diff --git a/Sources/PediFoods/Views/Main/StoreDetailView+Logic.swift b/PediFoods/Views/Main/StoreDetailView+Logic.swift similarity index 100% rename from Sources/PediFoods/Views/Main/StoreDetailView+Logic.swift rename to PediFoods/Views/Main/StoreDetailView+Logic.swift diff --git a/Sources/PediFoods/Views/Main/StoreDetailView.swift b/PediFoods/Views/Main/StoreDetailView.swift similarity index 97% rename from Sources/PediFoods/Views/Main/StoreDetailView.swift rename to PediFoods/Views/Main/StoreDetailView.swift index 91fe354..b8415ae 100644 --- a/Sources/PediFoods/Views/Main/StoreDetailView.swift +++ b/PediFoods/Views/Main/StoreDetailView.swift @@ -1,10 +1,8 @@ import SwiftUI -#if canImport(LCEssentials) && os(iOS) +#if canImport(LCEssentials) import LCEssentials #endif -#if canImport(UIKit) import UIKit -#endif struct StoreDetailView: View { let storeId: String @@ -47,11 +45,7 @@ struct StoreDetailView: View { let storeLogoSize: CGFloat = 84 var safeAreaTop: CGFloat { - #if canImport(UIKit) - return UIDevice.appSafeAreaTop - #else - return 0 - #endif + UIDevice.appSafeAreaTop } var body: some View { diff --git a/Sources/PediFoods/Views/Main/UserProfileView.swift b/PediFoods/Views/Main/UserProfileView.swift similarity index 69% rename from Sources/PediFoods/Views/Main/UserProfileView.swift rename to PediFoods/Views/Main/UserProfileView.swift index 59853ee..ad6560c 100644 --- a/Sources/PediFoods/Views/Main/UserProfileView.swift +++ b/PediFoods/Views/Main/UserProfileView.swift @@ -7,6 +7,7 @@ import UIKit struct UserProfileView: View { @Binding var appState: AppState @Environment(\.dismiss) var dismiss + @Environment(\.openURL) private var openURL @State var name: String = "" @State var email: String = "" @@ -14,6 +15,11 @@ struct UserProfileView: View { @State var cpf: String = "" @State var profilePicture: String = "" @State var isSaving = false + @State private var notificationsEnabled = false + @State private var isTogglingNotifications = false + @State private var showNotificationsDeniedAlert = false + @State private var faceIdEnabled = false + @State private var isTogglingFaceId = false #if os(iOS) @State private var selectedPhotoItem: PhotosPickerItem? @@ -25,6 +31,7 @@ struct UserProfileView: View { screenHeader avatarSection formSection + preferencesSection saveButton } .padding(.horizontal, 20) @@ -34,6 +41,12 @@ struct UserProfileView: View { .background(AppColors.backgroundLight) .appHiddenNavigationBar() .navigationBarBackButtonHidden(true) + .alert("Notificações desativadas", isPresented: $showNotificationsDeniedAlert) { + Button("Agora não", role: .cancel) {} + Button("Abrir Ajustes") { openSystemSettings() } + } message: { + Text("Para receber notificações sobre seus pedidos, ative a permissão nos Ajustes do iPhone.") + } .onAppear { hydrateFromAppState() } @@ -135,6 +148,54 @@ struct UserProfileView: View { .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) } + private var preferencesSection: some View { + VStack(alignment: .leading, spacing: 14) { + toggleRow( + icon: "bell.fill", + title: "Notificações", + isOn: $notificationsEnabled, + isDisabled: isTogglingNotifications + ) + .onChange(of: notificationsEnabled) { _, newValue in + Task { await handleNotificationsToggle(newValue) } + } + + Divider() + + toggleRow( + icon: "faceid", + title: "Login com biometria", + isOn: $faceIdEnabled, + isDisabled: isTogglingFaceId + ) + .onChange(of: faceIdEnabled) { _, newValue in + Task { await handleFaceIdToggle(newValue) } + } + } + .padding(16) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + + private func toggleRow(icon: String, title: String, isOn: Binding, isDisabled: Bool) -> some View { + HStack(spacing: 12) { + Image(systemName: icon) + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(AppColors.primary) + .frame(width: 28) + + Text(title) + .font(AppTypography.body) + .foregroundStyle(AppColors.textPrimary) + + Spacer(minLength: 10) + + Toggle("", isOn: isOn) + .labelsHidden() + .disabled(isDisabled) + } + } + private var saveButton: some View { Button(isSaving ? "Salvando..." : "Salvar Alterações") { Task { await saveProfile() } @@ -180,6 +241,95 @@ struct UserProfileView: View { phone = formatPhoneForDisplay(appState.profile.phone) profilePicture = appState.profile.profilePicture cpf = formatCPF(appState.profile.cpf.filter(\.isNumber)) + notificationsEnabled = appState.profile.notificationsEnabled + faceIdEnabled = appState.profile.faceIdEnabled + } + + /// Touchpoint 1 of docs/api/push-notifications-integration-guide.md §2b — + /// request OS permission (if needed) before flipping the server-side flag; + /// revert the toggle and explain why if the OS denies it. + @MainActor + private func handleNotificationsToggle(_ enabled: Bool) async { + guard isTogglingNotifications == false, enabled != appState.profile.notificationsEnabled else { return } + isTogglingNotifications = true + defer { isTogglingNotifications = false } + + if enabled { + if let profile = await PushNotificationCoordinator.shared.enableNotifications() { + let serverValue = profile.notificationsEnabled ?? false + appState.profile.notificationsEnabled = serverValue + notificationsEnabled = serverValue + } else { + notificationsEnabled = false + showNotificationsDeniedAlert = true + } + return + } + + do { + let response = try await ApiService().updateNotificationsEnabled(false) + if response.error { + notificationsEnabled = true + SnackbarCenter.shared.show( + title: response.message ?? "Não foi possível atualizar suas notificações.", + style: .error, + icon: "xmark.octagon.fill", + duration: 3.0 + ) + } else { + let serverValue = response.result?.notificationsEnabled ?? false + appState.profile.notificationsEnabled = serverValue + notificationsEnabled = serverValue + } + } catch { + notificationsEnabled = true + SnackbarCenter.shared.show( + title: "Não foi possível atualizar suas notificações.", + style: .error, + icon: "xmark.octagon.fill", + duration: 3.0 + ) + } + } + + private func openSystemSettings() { + if let url = URL(string: UIApplication.openSettingsURLString) { + openURL(url) + } + } + + /// Preference only for now — the actual Face ID/Touch ID unlock flow + /// (LocalAuthentication) is a separate, later plan. + @MainActor + private func handleFaceIdToggle(_ enabled: Bool) async { + guard isTogglingFaceId == false, enabled != appState.profile.faceIdEnabled else { return } + isTogglingFaceId = true + defer { isTogglingFaceId = false } + + do { + let response = try await ApiService().updateFaceIdEnabled(enabled) + if response.error { + faceIdEnabled = appState.profile.faceIdEnabled + SnackbarCenter.shared.show( + title: response.message ?? "Não foi possível atualizar essa preferência.", + style: .error, + icon: "xmark.octagon.fill", + duration: 3.0 + ) + } else { + let serverValue = response.result?.faceIdEnabled ?? enabled + appState.profile.faceIdEnabled = serverValue + faceIdEnabled = serverValue + } + } catch { + faceIdEnabled = appState.profile.faceIdEnabled + SnackbarCenter.shared.show( + title: "Não foi possível atualizar essa preferência.", + style: .error, + icon: "xmark.octagon.fill", + duration: 3.0 + ) + } } private func formatCPF(_ digits: String) -> String { diff --git a/PediFoodsTests/ApiCardModelsTests.swift b/PediFoodsTests/ApiCardModelsTests.swift new file mode 100644 index 0000000..685c100 --- /dev/null +++ b/PediFoodsTests/ApiCardModelsTests.swift @@ -0,0 +1,42 @@ +import Foundation +import Testing +@testable import PediFoods + +private func makeCard(nickname: String? = nil, brand: String? = "visa") -> SavedCard { + let json = """ + {"id":"1","nickname":\(nickname.map { "\"\($0)\"" } ?? "null"),"holderName":"Jane Doe", + "last4":"4242","brand":\(brand.map { "\"\($0)\"" } ?? "null"), + "expiryMonth":"09","expiryYear":"2027","isDefault":false} + """ + return try! JSONDecoder().decode(SavedCard.self, from: Data(json.utf8)) +} + +@Test("SavedCard.displayLabel uses the nickname when present") +func savedCardDisplayLabelUsesNickname() { + let card = makeCard(nickname: "Meu cartão") + #expect(card.displayLabel == "Meu cartão") +} + +@Test("SavedCard.displayLabel falls back to capitalized brand + last4 when nickname is absent") +func savedCardDisplayLabelFallsBackToBrand() { + let card = makeCard(nickname: nil, brand: "visa") + #expect(card.displayLabel == "Visa •••• 4242") +} + +@Test("SavedCard.displayLabel falls back to a generic label when both nickname and brand are absent") +func savedCardDisplayLabelFallsBackToGeneric() { + let card = makeCard(nickname: nil, brand: nil) + #expect(card.displayLabel == "Cartão •••• 4242") +} + +@Test("SavedCard.displayLabel ignores an empty-string nickname") +func savedCardDisplayLabelIgnoresEmptyNickname() { + let card = makeCard(nickname: "", brand: "mastercard") + #expect(card.displayLabel == "Mastercard •••• 4242") +} + +@Test("SavedCard.expiryLabel joins month and year with a slash") +func savedCardExpiryLabel() { + let card = makeCard() + #expect(card.expiryLabel == "09/2027") +} diff --git a/PediFoodsTests/ApiClientTests.swift b/PediFoodsTests/ApiClientTests.swift new file mode 100644 index 0000000..fac0d78 --- /dev/null +++ b/PediFoodsTests/ApiClientTests.swift @@ -0,0 +1,182 @@ +import Foundation +import Testing +@testable import PediFoods + +private func makeClient() -> ApiClient { + ApiClient(session: URLProtocolStub.makeSession(), tokenStore: FakeTokenStore()) +} + +private final class FakeTokenStore: TokenStore { + var jwt: String? + func clear() { jwt = nil } +} + +// MARK: - shouldRetry + +@Test("shouldRetry is true for rate limiting and transport errors") +func shouldRetryTrueForRateLimitAndTransport() { + let client = makeClient() + #expect(client.shouldRetry(.rateLimited(nil))) + #expect(client.shouldRetry(.transportError("timeout"))) +} + +@Test("shouldRetry is true only for 5xx http errors, not 4xx") +func shouldRetryOnlyFor5xx() { + let client = makeClient() + #expect(client.shouldRetry(.httpError(500, nil))) + #expect(client.shouldRetry(.httpError(503, nil))) + #expect(client.shouldRetry(.httpError(404, nil)) == false) + #expect(client.shouldRetry(.httpError(400, nil)) == false) +} + +@Test("shouldRetry is false for terminal client-side errors") +func shouldRetryFalseForTerminalErrors() { + let client = makeClient() + #expect(client.shouldRetry(.invalidURL) == false) + #expect(client.shouldRetry(.unauthorized(nil)) == false) + #expect(client.shouldRetry(.cancelled) == false) + #expect(client.shouldRetry(.decodeError(nil)) == false) +} + +// MARK: - backoff + +@Test("backoff honors a rate-limit response's Retry-After value in nanoseconds") +func backoffHonorsRetryAfter() { + let client = makeClient() + #expect(client.backoff(for: 1, error: .rateLimited(2)) == 2_000_000_000) +} + +@Test("backoff scales with attempt number and is capped at 2 seconds") +func backoffScalesAndCaps() { + let client = makeClient() + let first = client.backoff(for: 1, error: .transportError("x")) + let second = client.backoff(for: 2, error: .transportError("x")) + #expect(second > first) + #expect(client.backoff(for: 100, error: .transportError("x")) == 2_000_000_000) +} + +// MARK: - buildURL + +@Test("buildURL appends the path to the base URL and preserves query items") +func buildURLAppendsPathAndQuery() throws { + let client = makeClient() + let base = URL(string: "https://api.example.com")! + let url = try client.buildURL(path: "/stores/1", query: [URLQueryItem(name: "lang", value: "pt")], baseURL: base) + #expect(url.absoluteString == "https://api.example.com/stores/1?lang=pt") +} + +@Test("buildURL omits the query string entirely when there are no query items") +func buildURLOmitsEmptyQuery() throws { + let client = makeClient() + let url = try client.buildURL(path: "/stores/1", query: [], baseURL: URL(string: "https://api.example.com")!) + #expect(url.absoluteString == "https://api.example.com/stores/1") +} + +// MARK: - buildHeaders + +@Test("buildHeaders sends the customer JWT as a Bearer token when the request requires auth") +func buildHeadersUsesCustomerJWT() { + let store = FakeTokenStore() + store.jwt = "customer-jwt" + let client = ApiClient(session: URLProtocolStub.makeSession(), tokenStore: store) + let request = ApiRequest(path: "/me", requiresAuth: true) + #expect(client.buildHeaders(for: request)["Authorization"] == "Bearer customer-jwt") +} + +@Test("buildHeaders prefers an explicit customBearerToken over the customer JWT") +func buildHeadersPrefersCustomBearerToken() { + let store = FakeTokenStore() + store.jwt = "customer-jwt" + let client = ApiClient(session: URLProtocolStub.makeSession(), tokenStore: store) + let request = ApiRequest(path: "/guest", requiresAuth: true, customBearerToken: "guest-token") + #expect(client.buildHeaders(for: request)["Authorization"] == "Bearer guest-token") +} + +@Test("buildHeaders omits Authorization when the request doesn't require auth and has no custom token") +func buildHeadersOmitsAuthWhenNotRequired() { + let client = makeClient() + let request = ApiRequest(path: "/public", requiresAuth: false) + #expect(client.buildHeaders(for: request)["Authorization"] == nil) +} + +@Test("buildHeaders adds the module-specific Atomenta-Token when the module has one") +func buildHeadersAddsModuleToken() { + let client = makeClient() + let request = ApiRequest(path: "/stores", module: .store) + #expect(client.buildHeaders(for: request)["Atomenta-Token"] == ApiConfig.storeToken) +} + +// MARK: - isSessionExpiredPayload + +@Test("isSessionExpiredPayload recognizes auth/token/unauthorized error codes") +func isSessionExpiredPayloadRecognizesCodes() { + let client = makeClient() + #expect(client.isSessionExpiredPayload(code: "AUTH_INVALID", message: nil)) + #expect(client.isSessionExpiredPayload(code: "token_expired", message: nil)) + #expect(client.isSessionExpiredPayload(code: "UNAUTHORIZED", message: nil)) + #expect(client.isSessionExpiredPayload(code: "NOT_FOUND", message: nil) == false) +} + +@Test("isSessionExpiredPayload recognizes a message mentioning both token and expired/invalid/session") +func isSessionExpiredPayloadRecognizesMessages() { + let client = makeClient() + #expect(client.isSessionExpiredPayload(code: nil, message: "Token expired, please log in again")) + #expect(client.isSessionExpiredPayload(code: nil, message: "Invalid token")) + #expect(client.isSessionExpiredPayload(code: nil, message: "Token session invalid")) + #expect(client.isSessionExpiredPayload(code: nil, message: "Something else went wrong") == false) + #expect(client.isSessionExpiredPayload(code: nil, message: "Token is fine, no issues here") == false) +} + +// MARK: - sanitizedMessage + +@Test("sanitizedMessage replaces a data:image payload with a generic message") +func sanitizedMessageReplacesDataImage() { + let client = makeClient() + #expect(client.sanitizedMessage("data:image/png;base64,abc123") == "Erro ao processar imagem.") +} + +@Test("sanitizedMessage replaces any base64 payload with a generic message") +func sanitizedMessageReplacesBase64Payload() { + let client = makeClient() + #expect(client.sanitizedMessage("field: base64,abc123def") == "Resposta do servidor inválida.") +} + +@Test("sanitizedMessage truncates long strings to 300 characters with an ellipsis") +func sanitizedMessageTruncatesLongStrings() { + let client = makeClient() + let long = String(repeating: "x", count: 500) + let sanitized = client.sanitizedMessage(long) + #expect(sanitized?.count == 301) // 300 chars + the ellipsis character + #expect(sanitized?.hasSuffix("…") == true) +} + +@Test("sanitizedMessage returns nil for an empty or whitespace-only string") +func sanitizedMessageNilForEmptyString() { + let client = makeClient() + #expect(client.sanitizedMessage(" ") == nil) +} + +// MARK: - serverPayload(from: Data) + +@Test("serverPayload extracts code and message from an ApiEnvelope-shaped error response") +func serverPayloadExtractsFromEnvelope() { + let client = makeClient() + let data = Data(#"{"error":true,"code":"NOT_FOUND","message":"Store not found"}"#.utf8) + let payload = client.serverPayload(from: data) + #expect(payload?.code == "NOT_FOUND") + #expect(payload?.message == "Store not found") +} + +@Test("serverPayload falls back to a loose JSON object when it isn't a full envelope") +func serverPayloadFallsBackToLooseObject() { + let client = makeClient() + let data = Data(#"{"msg":"Something failed"}"#.utf8) + #expect(client.serverPayload(from: data)?.message == "Something failed") +} + +@Test("serverPayload returns nil for a response with no error signal at all") +func serverPayloadNilForNoErrorSignal() { + let client = makeClient() + let data = Data(#"{"id":"1","name":"OK"}"#.utf8) + #expect(client.serverPayload(from: data) == nil) +} diff --git a/PediFoodsTests/ApiCustomerPayloadModelsTests.swift b/PediFoodsTests/ApiCustomerPayloadModelsTests.swift new file mode 100644 index 0000000..5bf88e1 --- /dev/null +++ b/PediFoodsTests/ApiCustomerPayloadModelsTests.swift @@ -0,0 +1,77 @@ +import Foundation +import Testing +@testable import PediFoods + +private func encodedKeys(_ value: T) throws -> Set { + let data = try JSONEncoder().encode(value) + let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] + return Set((object ?? [:]).keys) +} + +@Test("CustomerIdentityUpdatePayload omits profilePicture when it's an empty string") +func customerIdentityUpdatePayloadOmitsEmptyProfilePicture() throws { + let payload = CustomerIdentityUpdatePayload(name: "Jane", email: nil, phoneNumber: nil, profilePicture: "") + let keys = try encodedKeys(payload) + #expect(keys.contains("profilePicture") == false) + #expect(keys.contains("name")) +} + +@Test("CustomerIdentityUpdatePayload includes profilePicture when it's non-empty") +func customerIdentityUpdatePayloadIncludesNonEmptyProfilePicture() throws { + let payload = CustomerIdentityUpdatePayload(name: nil, email: nil, phoneNumber: nil, profilePicture: "https://cdn/pic.png") + let keys = try encodedKeys(payload) + #expect(keys.contains("profilePicture")) +} + +@Test("CustomerIdentityUpdatePayload omits fields that are nil") +func customerIdentityUpdatePayloadOmitsNilFields() throws { + let payload = CustomerIdentityUpdatePayload(name: "Jane", email: nil, phoneNumber: nil, profilePicture: nil) + let keys = try encodedKeys(payload) + #expect(keys == ["name"]) +} + +@Test("CustomerAddressPayload maps every field from a CustomerAddress") +func customerAddressPayloadMapsFromCustomerAddress() { + let address = CustomerAddress( + id: "addr-1", label: "Casa", address: "Rua A", number: "100", + complement: "Apto 2", neighborhood: "Centro", city: "SP", + state: "SP", zipCode: "01000-000", latLong: [-23.5, -46.6], isDefault: true + ) + let payload = CustomerAddressPayload(from: address) + #expect(payload.label == "Casa") + #expect(payload.address == "Rua A") + #expect(payload.number == "100") + #expect(payload.complement == "Apto 2") + #expect(payload.neighborhood == "Centro") + #expect(payload.city == "SP") + #expect(payload.state == "SP") + #expect(payload.zipCode == "01000-000") + #expect(payload.latLong == [-23.5, -46.6]) +} + +@Test("CustomerAddressPayload encodes latLong under the lat_long snake_case key") +func customerAddressPayloadEncodesLatLongSnakeCase() throws { + let address = CustomerAddress( + id: nil, label: nil, address: nil, number: nil, complement: nil, + neighborhood: nil, city: nil, state: nil, zipCode: nil, + latLong: [1.0, 2.0], isDefault: nil + ) + let keys = try encodedKeys(CustomerAddressPayload(from: address)) + #expect(keys.contains("lat_long")) + #expect(keys.contains("latLong") == false) +} + +@Test("CustomerAttributesUpdatePayload omits attributes when nil") +func customerAttributesUpdatePayloadOmitsNilAttributes() throws { + let payload = CustomerAttributesUpdatePayload(appVersion: "1.0.0", attributes: nil) + let keys = try encodedKeys(payload) + #expect(keys == ["appVersion"]) +} + +@Test("CustomerFavoritesMutationResult decodes favorites and an optional store") +func customerFavoritesMutationResultDecodes() throws { + let json = #"{"favorites":["s1","s2"],"store":{"id":"s1","name":"Loja A"}}"# + let result = try JSONDecoder().decode(CustomerFavoritesMutationResult.self, from: Data(json.utf8)) + #expect(result.favorites == ["s1", "s2"]) + #expect(result.store?.name == "Loja A") +} diff --git a/PediFoodsTests/ApiModelsDecodingTests.swift b/PediFoodsTests/ApiModelsDecodingTests.swift new file mode 100644 index 0000000..c2ade8a --- /dev/null +++ b/PediFoodsTests/ApiModelsDecodingTests.swift @@ -0,0 +1,161 @@ +import Foundation +import Testing +@testable import PediFoods + +private func decode(_ type: T.Type, _ json: String) throws -> T { + try JSONDecoder().decode(T.self, from: Data(json.utf8)) +} + +@Test("StoreSummary falls back to placeholder id/name when missing") +func storeSummaryDefaultsWhenFieldsMissing() throws { + let store = try decode(StoreSummary.self, "{}") + #expect(store.name == "Loja") + #expect(store.id.isEmpty == false) + #expect(store.rating == nil) +} + +@Test("StoreSummary reads reviewsCount from any of its historical key names") +func storeSummaryReviewsCountKeyFallback() throws { + let viaReviews = try decode(StoreSummary.self, #"{"id":"1","name":"A","reviews":7}"#) + #expect(viaReviews.reviewsCount == 7) + + let viaTotalReviews = try decode(StoreSummary.self, #"{"id":"1","name":"A","totalReviews":9}"#) + #expect(viaTotalReviews.reviewsCount == 9) + + let viaReviewsCount = try decode(StoreSummary.self, #"{"id":"1","name":"A","reviewsCount":3}"#) + #expect(viaReviewsCount.reviewsCount == 3) +} + +@Test("StoreSummary coerces rating/deliveryFee/distance from string or int payloads") +func storeSummaryFlexibleNumberCoercion() throws { + let asString = try decode(StoreSummary.self, #"{"id":"1","name":"A","rating":"4,5","deliveryFee":"7,90","distance":"1.2"}"#) + #expect(asString.rating == 4.5) + #expect(asString.deliveryFee == 7.90) + #expect(asString.distance == 1.2) + + let asInt = try decode(StoreSummary.self, #"{"id":"1","name":"A","rating":5,"deliveryFee":0}"#) + #expect(asInt.rating == 5.0) + #expect(asInt.deliveryFee == 0.0) +} + +@Test("StoreAddressInfo accepts either zipCode or lowercase zipcode key") +func storeAddressInfoZipCodeKeyFallback() throws { + let viaZipCode = try decode(StoreAddressInfo.self, #"{"zipCode":"01000-000"}"#) + #expect(viaZipCode.zipCode == "01000-000") + + let viaZipcode = try decode(StoreAddressInfo.self, #"{"zipcode":"02000-000"}"#) + #expect(viaZipcode.zipCode == "02000-000") +} + +@Test("StorePaymentMethodsInfo computed hasAny* flags are false when nothing accepted") +func paymentMethodsHasAnyFlagsDefaultFalse() throws { + let none = try decode(StorePaymentMethodsInfo.self, "{}") + #expect(none.hasAnyCreditCard == false) + #expect(none.hasAnyDebitCard == false) + #expect(none.hasAnyVoucher == false) +} + +@Test("StorePaymentMethodsInfo computed hasAny* flags are true if any one brand is accepted") +func paymentMethodsHasAnyFlagsTrueOnSingleBrand() throws { + let visaOnly = try decode(StorePaymentMethodsInfo.self, #"{"acceptCreditVisa":true}"#) + #expect(visaOnly.hasAnyCreditCard == true) + #expect(visaOnly.hasAnyDebitCard == false) + + let voucherOnly = try decode(StorePaymentMethodsInfo.self, #"{"acceptVoucherSodexo":true}"#) + #expect(voucherOnly.hasAnyVoucher == true) +} + +@Test("StoreCatalogProduct decodes pizzaPrices as Double, Int, or comma-decimal String") +func storeCatalogProductPizzaPricesMultiType() throws { + let asDouble = try decode(StoreCatalogProduct.self, #"{"id":"1","name":"P","pizzaPrices":{"M":29.9}}"#) + #expect(asDouble.pizzaPrices["M"] == 29.9) + + let asInt = try decode(StoreCatalogProduct.self, #"{"id":"1","name":"P","pizzaPrices":{"M":30}}"#) + #expect(asInt.pizzaPrices["M"] == 30.0) + + let asString = try decode(StoreCatalogProduct.self, #"{"id":"1","name":"P","pizzaPrices":{"M":"29,90"}}"#) + #expect(asString.pizzaPrices["M"] == 29.90) + + let malformedString = try decode(StoreCatalogProduct.self, #"{"id":"1","name":"P","pizzaPrices":{"M":"not-a-number"}}"#) + #expect(malformedString.pizzaPrices.isEmpty) +} + +@Test("StoreCatalogProduct falls back through image/cover/photo and description/desc key names") +func storeCatalogProductImageAndDescriptionFallback() throws { + let viaCover = try decode(StoreCatalogProduct.self, #"{"id":"1","name":"P","cover":"cover.png"}"#) + #expect(viaCover.image == "cover.png") + + let viaPhoto = try decode(StoreCatalogProduct.self, #"{"id":"1","name":"P","photo":"photo.png"}"#) + #expect(viaPhoto.image == "photo.png") + + let viaDesc = try decode(StoreCatalogProduct.self, #"{"id":"1","name":"P","desc":"short desc"}"#) + #expect(viaDesc.description == "short desc") +} + +@Test("StoreCatalogProduct falls back from addonGroups to legacy addons key") +func storeCatalogProductAddonGroupsFallback() throws { + let json = #"{"id":"1","name":"P","addons":[{"id":"g1","name":"Extras","items":[{"id":"i1","name":"Cheese","price":2.5}]}]}"# + let product = try decode(StoreCatalogProduct.self, json) + #expect(product.addonGroups.count == 1) + #expect(product.addonGroups[0].items[0].price == 2.5) +} + +@Test("StoreAddonGroup and StoreAddonItem fall back to placeholder id/name when missing") +func addonGroupAndItemDefaults() throws { + let group = try decode(StoreAddonGroup.self, "{}") + #expect(group.name == "Adicionais") + #expect(group.items.isEmpty) + + let item = try decode(StoreAddonItem.self, "{}") + #expect(item.name == "Item") + #expect(item.price == nil) +} + +@Test("CepLookupResult prefers direct keys over normalized over raw") +func cepLookupResultPrefersDirectOverNormalizedOverRaw() throws { + let json = """ + { + "zipCode": "01000-000", + "normalized": { "cep": "02000-000", "logradouro": "Normalized St" }, + "raw": { "cep": "03000-000", "address": "Raw St" } + } + """ + let result = try decode(CepLookupResult.self, json) + #expect(result.zipCode == "01000-000") + #expect(result.street == "Normalized St") +} + +@Test("CepLookupResult falls back to raw block when direct and normalized are absent") +func cepLookupResultFallsBackToRawBlock() throws { + let json = #"{"raw":{"cep":"03000-000","address":"Raw St","lat":"-23,55","lng":-46.6}}"# + let result = try decode(CepLookupResult.self, json) + #expect(result.zipCode == "03000-000") + #expect(result.street == "Raw St") + #expect(result.latitude == -23.55) + #expect(result.longitude == -46.6) +} + +@Test("CepLookupResult resolves via alternate Portuguese field names (bairro/cidade/uf)") +func cepLookupResultPortugueseFieldNames() throws { + let json = #"{"bairro":"Centro","cidade":"São Paulo","uf":"SP"}"# + let result = try decode(CepLookupResult.self, json) + #expect(result.neighborhood == "Centro") + #expect(result.city == "São Paulo") + #expect(result.state == "SP") +} + +@Test("StoreCatalogCategory falls back to empty products array and placeholder id/name") +func storeCatalogCategoryDefaults() throws { + let category = try decode(StoreCatalogCategory.self, "{}") + #expect(category.name == "Categoria") + #expect(category.isPizzaCategory == false) + #expect(category.products.isEmpty) +} + +@Test("CustomerProfile decodes addressBook from the snake_case address_book key") +func customerProfileAddressBookSnakeCaseKey() throws { + let json = #"{"id":"1","name":"A","email":"a@a.com","address_book":[{"id":"addr1","city":"SP"}]}"# + let profile = try decode(CustomerProfile.self, json) + #expect(profile.addressBook?.count == 1) + #expect(profile.addressBook?.first?.city == "SP") +} diff --git a/PediFoodsTests/ApiOrderModelsDecodingTests.swift b/PediFoodsTests/ApiOrderModelsDecodingTests.swift new file mode 100644 index 0000000..7c82393 --- /dev/null +++ b/PediFoodsTests/ApiOrderModelsDecodingTests.swift @@ -0,0 +1,73 @@ +import Foundation +import Testing +@testable import PediFoods + +private func decode(_ type: T.Type, _ json: String) throws -> T { + try JSONDecoder().decode(T.self, from: Data(json.utf8)) +} + +@Test("CreateOrderResult decodes paymentPayload when it's a nested object") +func createOrderResultPaymentPayloadAsObject() throws { + let json = #"{"id":"1","paymentPayload":{"copyPaste":"00020126","qrCodeImage":"base64img","expirationDate":"2026-01-01"}}"# + let result = try decode(CreateOrderResult.self, json) + #expect(result.paymentPayload?.copyPaste == "00020126") + #expect(result.paymentPayload?.qrCodeImage == "base64img") +} + +@Test("CreateOrderResult wraps a bare-string paymentPayload into copyPaste") +func createOrderResultPaymentPayloadAsBareString() throws { + let json = #"{"id":"1","paymentPayload":"00020126"}"# + let result = try decode(CreateOrderResult.self, json) + #expect(result.paymentPayload?.copyPaste == "00020126") + #expect(result.paymentPayload?.qrCodeImage == nil) +} + +@Test("CreateOrderResult leaves paymentPayload nil when the field is absent") +func createOrderResultPaymentPayloadAbsent() throws { + let result = try decode(CreateOrderResult.self, #"{"id":"1"}"#) + #expect(result.paymentPayload == nil) +} + +@Test("CreateOrderPaymentPayload falls back from copyPaste to the legacy payload key") +func createOrderPaymentPayloadCopyPasteKeyFallback() throws { + let payload = try decode(CreateOrderPaymentPayload.self, #"{"payload":"00020126"}"#) + #expect(payload.copyPaste == "00020126") +} + +@Test("CreateOrderPaymentPayload falls back from qrCodeImage to the legacy encodedImage key") +func createOrderPaymentPayloadQrCodeImageKeyFallback() throws { + let payload = try decode(CreateOrderPaymentPayload.self, #"{"encodedImage":"base64img"}"#) + #expect(payload.qrCodeImage == "base64img") +} + +@Test("ValidateDeliveryAddressResult reads camelCase or snake_case keys interchangeably") +func validateDeliveryAddressResultKeyFallback() throws { + let camelCase = try decode(ValidateDeliveryAddressResult.self, #"{"deliveryAllowed":true,"reasonCode":"OUT_OF_RANGE"}"#) + #expect(camelCase.deliveryAllowed == true) + #expect(camelCase.reasonCode == "OUT_OF_RANGE") + + let snakeCase = try decode(ValidateDeliveryAddressResult.self, #"{"delivery_allowed":false,"reason_code":"OUT_OF_RANGE"}"#) + #expect(snakeCase.deliveryAllowed == false) + #expect(snakeCase.reasonCode == "OUT_OF_RANGE") +} + +@Test("ValidateDeliveryAddressResult resolves deliveryFee from any of its three key names") +func validateDeliveryAddressResultDeliveryFeeThreeWayFallback() throws { + let viaDeliveryFee = try decode(ValidateDeliveryAddressResult.self, #"{"deliveryFee":5.5}"#) + #expect(viaDeliveryFee.deliveryFee == 5.5) + + let viaFee = try decode(ValidateDeliveryAddressResult.self, #"{"fee":6.5}"#) + #expect(viaFee.deliveryFee == 6.5) + + let viaTaxa = try decode(ValidateDeliveryAddressResult.self, #"{"taxa":"7,50"}"#) + #expect(viaTaxa.deliveryFee == 7.5) +} + +@Test("ValidateDeliveryAddressResult defaults every field to nil on an empty payload") +func validateDeliveryAddressResultEmptyPayload() throws { + let result = try decode(ValidateDeliveryAddressResult.self, "{}") + #expect(result.deliveryAllowed == nil) + #expect(result.reasonCode == nil) + #expect(result.deliveryFee == nil) + #expect(result.sameCity == nil) +} diff --git a/PediFoodsTests/ApiOrderTrackingModelsTests.swift b/PediFoodsTests/ApiOrderTrackingModelsTests.swift new file mode 100644 index 0000000..20de95d --- /dev/null +++ b/PediFoodsTests/ApiOrderTrackingModelsTests.swift @@ -0,0 +1,151 @@ +import Foundation +import Testing +@testable import PediFoods + +private func decode(_ type: T.Type, _ json: String) throws -> T { + try JSONDecoder().decode(T.self, from: Data(json.utf8)) +} + +private func makeOrder( + status: String? = nil, + paymentStatus: String? = nil, + paymentConfirmed: Bool? = nil, + customerOtp: String? = nil, + otp: String? = nil, + confirmOtp: String? = nil, + timeline: [PublicOrderTimelineEvent] = [] +) -> PublicOrderResult { + PublicOrderResult( + id: "order-1", + status: status, + paymentStatus: paymentStatus, + paymentConfirmed: paymentConfirmed, + otp: otp, + customerOtp: customerOtp, + confirmOtp: confirmOtp, + timeline: timeline + ) +} + +// MARK: - displayOtpCode + +@Test("PublicOrderResult.displayOtpCode prefers customerOtp, then otp, then confirmOtp") +func displayOtpCodePriorityOrder() { + let allThree = makeOrder(customerOtp: "1111", otp: "2222", confirmOtp: "3333") + #expect(allThree.displayOtpCode == "1111") + + let otpAndConfirm = makeOrder(otp: "2222", confirmOtp: "3333") + #expect(otpAndConfirm.displayOtpCode == "2222") + + let confirmOnly = makeOrder(confirmOtp: "3333") + #expect(confirmOnly.displayOtpCode == "3333") +} + +@Test("PublicOrderResult.displayOtpCode skips blank/whitespace-only values") +func displayOtpCodeSkipsBlankValues() { + let order = makeOrder(customerOtp: " ", otp: "2222") + #expect(order.displayOtpCode == "2222") +} + +@Test("PublicOrderResult.displayOtpCode is nil when no OTP field is set") +func displayOtpCodeNilWhenAbsent() { + #expect(makeOrder().displayOtpCode == nil) +} + +// MARK: - isInDeliveryRoute / isFinalStatus + +@Test("PublicOrderResult.isInDeliveryRoute recognizes OUT_FOR_DELIVERY and Portuguese equivalents") +func isInDeliveryRouteRecognizesKnownStatuses() { + #expect(makeOrder(status: "OUT_FOR_DELIVERY").isInDeliveryRoute) + #expect(makeOrder(status: "em_rota").isInDeliveryRoute) + #expect(makeOrder(status: "ON_ROUTE").isInDeliveryRoute) + #expect(makeOrder(status: "PREPARING").isInDeliveryRoute == false) +} + +@Test("PublicOrderResult.isFinalStatus matches COMPLETED, CANCELED, and REFUNDED exactly") +func isFinalStatusMatchesTerminalStatuses() { + #expect(makeOrder(status: "COMPLETED").isFinalStatus) + #expect(makeOrder(status: "CANCELED").isFinalStatus) + #expect(makeOrder(status: "REFUNDED").isFinalStatus) + #expect(makeOrder(status: "PREPARING").isFinalStatus == false) +} + +// MARK: - isPaymentConfirmed + +@Test("PublicOrderResult.isPaymentConfirmed trusts the explicit paymentConfirmed flag first") +func isPaymentConfirmedTrustsExplicitFlag() { + #expect(makeOrder(paymentStatus: "PENDING", paymentConfirmed: true).isPaymentConfirmed) + #expect(makeOrder(paymentStatus: "PAID", paymentConfirmed: false).isPaymentConfirmed == false) +} + +@Test("PublicOrderResult.isPaymentConfirmed infers from a paymentStatus that looks confirmed") +func isPaymentConfirmedInfersFromPaymentStatus() { + #expect(makeOrder(paymentStatus: "CONFIRMED").isPaymentConfirmed) + #expect(makeOrder(paymentStatus: "approved").isPaymentConfirmed) + #expect(makeOrder(paymentStatus: "PENDING").isPaymentConfirmed == false) + #expect(makeOrder(paymentStatus: "PAYMENT_FAILED").isPaymentConfirmed == false) +} + +@Test("PublicOrderResult.isPaymentConfirmed falls back to the timeline when status fields don't confirm") +func isPaymentConfirmedFallsBackToTimeline() { + let confirmingEvent = PublicOrderTimelineEvent(status: "PAYMENT_APPROVED", message: nil, time: nil) + let order = makeOrder(status: "PROCESSING", timeline: [confirmingEvent]) + #expect(order.isPaymentConfirmed) +} + +@Test("PublicOrderResult.isPaymentConfirmed is false with no confirming signal anywhere") +func isPaymentConfirmedFalseWithNoSignal() { + #expect(makeOrder(status: "PROCESSING").isPaymentConfirmed == false) +} + +// MARK: - CreateOrderResult -> PublicOrderResult mapping + +@Test("CreateOrderResult.asPublicOrderResult maps every carried-over field") +func createOrderResultMapsToPublicOrderResult() throws { + let created = try decode(CreateOrderResult.self, #"{"id":"o1","shortId":"S1","status":"PENDING","paymentMethod":"PIX"}"#) + let mapped = created.asPublicOrderResult() + #expect(mapped.id == "o1") + #expect(mapped.shortId == "S1") + #expect(mapped.status == "PENDING") + #expect(mapped.paymentMethod == "PIX") +} + +// MARK: - Nested decode fallbacks + +@Test("PublicOrderDeliveryAddress falls back through street/address and zip/zipCode/zipcode key names") +func publicOrderDeliveryAddressKeyFallback() throws { + let viaAddress = try decode(PublicOrderDeliveryAddress.self, #"{"address":"Rua A","zipcode":"01000-000"}"#) + #expect(viaAddress.street == "Rua A") + #expect(viaAddress.zip == "01000-000") +} + +@Test("PublicOrderItem falls back from qty to the legacy quantity key") +func publicOrderItemQuantityKeyFallback() throws { + let item = try decode(PublicOrderItem.self, #"{"quantity":3,"price":9.9}"#) + #expect(item.qty == 3) +} + +@Test("PublicOrderTimelineEvent falls back from message to event, and from time to createdAt/updatedAt") +func publicOrderTimelineEventKeyFallback() throws { + let viaEvent = try decode(PublicOrderTimelineEvent.self, #"{"status":"S","event":"Order placed"}"#) + #expect(viaEvent.message == "Order placed") + + let viaCreatedAt = try decode(PublicOrderTimelineEvent.self, #"{"status":"S","createdAt":"2026-01-01T00:00:00Z"}"#) + #expect(viaCreatedAt.time == "2026-01-01T00:00:00Z") +} + +@Test("AppOrderSummary falls back through orderId/realId/id for its own id, and through many keys for storeLogoURL") +func appOrderSummaryIdAndLogoFallback() throws { + let viaRealId = try decode(AppOrderSummary.self, #"{"realId":"real-1"}"#) + #expect(viaRealId.id == "real-1") + + let viaLogo = try decode(AppOrderSummary.self, #"{"orderId":"o1","store_logo":"logo.png"}"#) + #expect(viaLogo.storeLogoURL == "logo.png") +} + +@Test("AppOrderSummary falls back to the shared date key when createdAt/updatedAt are absent") +func appOrderSummaryDateFallback() throws { + let summary = try decode(AppOrderSummary.self, #"{"orderId":"o1","date":"2026-01-01"}"#) + #expect(summary.createdAt == "2026-01-01") + #expect(summary.updatedAt == "2026-01-01") +} diff --git a/PediFoodsTests/ApiPizzaModelsDecodingTests.swift b/PediFoodsTests/ApiPizzaModelsDecodingTests.swift new file mode 100644 index 0000000..19dc78e --- /dev/null +++ b/PediFoodsTests/ApiPizzaModelsDecodingTests.swift @@ -0,0 +1,37 @@ +import Foundation +import Testing +@testable import PediFoods + +private func decode(_ type: T.Type, _ json: String) throws -> T { + try JSONDecoder().decode(T.self, from: Data(json.utf8)) +} + +@Test("StorePizzaConfig defaults every list to empty when fields are missing") +func storePizzaConfigDefaultsToEmptyLists() throws { + let config = try decode(StorePizzaConfig.self, "{}") + #expect(config.sizes.isEmpty) + #expect(config.doughs.isEmpty) + #expect(config.crusts.isEmpty) +} + +@Test("StorePizzaCrust falls back to a placeholder id when missing") +func storePizzaCrustPlaceholderId() throws { + let crust = try decode(StorePizzaCrust.self, "{}") + #expect(crust.id.isEmpty == false) + #expect(crust.priceModifier == nil) +} + +@Test("StorePizzaCrust coerces priceModifier from Double, Int, or comma-decimal String") +func storePizzaCrustPriceModifierCoercion() throws { + let asDouble = try decode(StorePizzaCrust.self, #"{"id":"1","priceModifier":3.5}"#) + #expect(asDouble.priceModifier == 3.5) + + let asInt = try decode(StorePizzaCrust.self, #"{"id":"1","priceModifier":4}"#) + #expect(asInt.priceModifier == 4.0) + + let asString = try decode(StorePizzaCrust.self, #"{"id":"1","priceModifier":"3,50"}"#) + #expect(asString.priceModifier == 3.5) + + let malformed = try decode(StorePizzaCrust.self, #"{"id":"1","priceModifier":"not-a-number"}"#) + #expect(malformed.priceModifier == nil) +} diff --git a/PediFoodsTests/ApiReviewModelsDecodingTests.swift b/PediFoodsTests/ApiReviewModelsDecodingTests.swift new file mode 100644 index 0000000..0b682fa --- /dev/null +++ b/PediFoodsTests/ApiReviewModelsDecodingTests.swift @@ -0,0 +1,102 @@ +import Foundation +import Testing +@testable import PediFoods + +private func decode(_ type: T.Type, _ json: String) throws -> T { + try JSONDecoder().decode(T.self, from: Data(json.utf8)) +} + +@Test("SubmitOrderReviewResult accepts orderPositiveTags as either an array or a single string") +func submitOrderReviewResultTagsArrayOrSingleString() throws { + let asArray = try decode(SubmitOrderReviewResult.self, #"{"orderPositiveTags":["fast","tasty"]}"#) + #expect(asArray.orderPositiveTags == ["fast", "tasty"]) + + let asSingleString = try decode(SubmitOrderReviewResult.self, #"{"orderPositiveTags":"fast"}"#) + #expect(asSingleString.orderPositiveTags == ["fast"]) +} + +@Test("SubmitOrderReviewResult falls back from orderPositiveTags to the legacy itemFeedback key") +func submitOrderReviewResultTagsLegacyKeyFallback() throws { + let result = try decode(SubmitOrderReviewResult.self, #"{"itemFeedback":["fresh"]}"#) + #expect(result.orderPositiveTags == ["fresh"]) +} + +@Test("SubmitOrderReviewResult coerces appNps from Int, Double, or comma-decimal String") +func submitOrderReviewResultAppNpsCoercion() throws { + let asInt = try decode(SubmitOrderReviewResult.self, #"{"appNps":9}"#) + #expect(asInt.appNps == 9) + + let asDouble = try decode(SubmitOrderReviewResult.self, #"{"appNps":8.6}"#) + #expect(asDouble.appNps == 9) + + let asString = try decode(SubmitOrderReviewResult.self, #"{"appNps":"7,4"}"#) + #expect(asString.appNps == 7) +} + +@Test("SubmitOrderReviewResult reads appNps from the legacy app_nps key") +func submitOrderReviewResultAppNpsLegacyKey() throws { + let result = try decode(SubmitOrderReviewResult.self, #"{"app_nps":10}"#) + #expect(result.appNps == 10) +} + +@Test("SubmitOrderReviewResult prefers the flat storeReplyMessage field over nested reply objects") +func submitOrderReviewResultStoreReplyMessagePrefersFlatField() throws { + let json = #"{"storeReplyMessage":"Obrigado!","storeReply":{"message":"ignored"}}"# + let result = try decode(SubmitOrderReviewResult.self, json) + #expect(result.storeReplyMessage == "Obrigado!") +} + +@Test("SubmitOrderReviewResult extracts a reply message from a nested object's candidate keys") +func submitOrderReviewResultStoreReplyMessageFromNestedObject() throws { + let viaMessage = try decode(SubmitOrderReviewResult.self, #"{"storeReply":{"message":"Obrigado!"}}"#) + #expect(viaMessage.storeReplyMessage == "Obrigado!") + + let viaText = try decode(SubmitOrderReviewResult.self, #"{"reply":{"text":"Valeu!"}}"#) + #expect(viaText.storeReplyMessage == "Valeu!") + + let viaStoreResponse = try decode(SubmitOrderReviewResult.self, #"{"store_response":{"content":"Obrigado pela visita!"}}"#) + #expect(viaStoreResponse.storeReplyMessage == "Obrigado pela visita!") +} + +@Test("SubmitOrderReviewResult extracts a reply date from a nested object's candidate keys") +func submitOrderReviewResultStoreReplyDateFromNestedObject() throws { + let result = try decode(SubmitOrderReviewResult.self, #"{"storeReply":{"repliedAt":"2026-01-05"}}"#) + #expect(result.storeReplyAt == "2026-01-05") +} + +@Test("SubmitOrderReviewResult leaves storeReplyMessage nil when there's no reply at all") +func submitOrderReviewResultNoReply() throws { + let result = try decode(SubmitOrderReviewResult.self, "{}") + #expect(result.storeReplyMessage == nil) + #expect(result.storeReplyAt == nil) +} + +@Test("PublicStoreReviewsResult decodes a bare top-level array of reviews") +func publicStoreReviewsResultBareArray() throws { + let json = #"[{"id":"1"},{"id":"2"}]"# + let result = try decode(PublicStoreReviewsResult.self, json) + #expect(result.reviews.count == 2) +} + +@Test("PublicStoreReviewsResult falls back through reviews, data, and items wrapper keys") +func publicStoreReviewsResultWrapperKeyFallback() throws { + let viaReviews = try decode(PublicStoreReviewsResult.self, #"{"reviews":[{"id":"1"}]}"#) + #expect(viaReviews.reviews.count == 1) + + let viaData = try decode(PublicStoreReviewsResult.self, #"{"data":[{"id":"1"},{"id":"2"}]}"#) + #expect(viaData.reviews.count == 2) + + let viaItems = try decode(PublicStoreReviewsResult.self, #"{"items":[{"id":"1"}]}"#) + #expect(viaItems.reviews.count == 1) +} + +@Test("PublicStoreReviewsResult defaults to an empty array when nothing matches") +func publicStoreReviewsResultDefaultsToEmpty() throws { + let result = try decode(PublicStoreReviewsResult.self, "{}") + #expect(result.reviews.isEmpty) +} + +@Test("ReviewPlatform.current is iOS on this platform") +func reviewPlatformCurrentIsIOS() { + #expect(ReviewPlatform.current == .ios) +} diff --git a/PediFoodsTests/AppContentCacheTests.swift b/PediFoodsTests/AppContentCacheTests.swift new file mode 100644 index 0000000..ebe7bdc --- /dev/null +++ b/PediFoodsTests/AppContentCacheTests.swift @@ -0,0 +1,50 @@ +import Foundation +import Testing +@testable import PediFoods + +/// `AppContentCache.shared` is a true singleton with no injectable +/// instance; `invalidate()` with no prefix wipes every key regardless of +/// which test wrote it. Serialized so tests can't stomp on each other. +@Suite(.serialized) +struct AppContentCacheTests { + @Test("value(for:) returns what was just set") + func setThenGetRoundTrips() { + let key = "test.\(UUID().uuidString)" + AppContentCache.shared.set("hello", for: key, ttl: 60) + #expect(AppContentCache.shared.value(for: key, as: String.self) == "hello") + } + + @Test("value(for:) returns nil once the TTL has elapsed") + func expiresAfterTTL() { + let key = "test.\(UUID().uuidString)" + AppContentCache.shared.set("hello", for: key, ttl: -1) // already expired + #expect(AppContentCache.shared.value(for: key, as: String.self) == nil) + } + + @Test("value(for:) returns nil for a key that was never set") + func missingKeyReturnsNil() { + let key = "test.\(UUID().uuidString)" + #expect(AppContentCache.shared.value(for: key, as: String.self) == nil) + } + + @Test("invalidate(prefix:) only removes keys matching that prefix") + func invalidateWithPrefixIsScoped() { + let prefix = "test.\(UUID().uuidString)." + AppContentCache.shared.set("a", for: "\(prefix)a", ttl: 60) + AppContentCache.shared.set("b", for: "\(prefix)b", ttl: 60) + AppContentCache.shared.set("unrelated", for: "unrelated.\(UUID().uuidString)", ttl: 60) + + AppContentCache.shared.invalidate(prefix: prefix) + + #expect(AppContentCache.shared.value(for: "\(prefix)a", as: String.self) == nil) + #expect(AppContentCache.shared.value(for: "\(prefix)b", as: String.self) == nil) + } + + @Test("invalidate() with no prefix clears every entry") + func invalidateWithNoPrefixClearsEverything() { + let key = "test.\(UUID().uuidString)" + AppContentCache.shared.set("hello", for: key, ttl: 60) + AppContentCache.shared.invalidate() + #expect(AppContentCache.shared.value(for: key, as: String.self) == nil) + } +} diff --git a/PediFoodsTests/AuthFormattersTests.swift b/PediFoodsTests/AuthFormattersTests.swift new file mode 100644 index 0000000..4905103 --- /dev/null +++ b/PediFoodsTests/AuthFormattersTests.swift @@ -0,0 +1,85 @@ +import Testing +@testable import PediFoods + +// MARK: - formatPhoneBR + +@Test("formatPhoneBR progressively formats as digits are typed") +func formatPhoneBRProgressiveFormatting() { + #expect(formatPhoneBR("") == "") + #expect(formatPhoneBR("1") == "(1") + #expect(formatPhoneBR("11") == "(11") + #expect(formatPhoneBR("119") == "(11) 9") + #expect(formatPhoneBR("1199999") == "(11) 99999") + #expect(formatPhoneBR("11999999999") == "(11) 99999-9999") +} + +@Test("formatPhoneBR strips non-digit characters before formatting") +func formatPhoneBRStripsNonDigits() { + #expect(formatPhoneBR("(11) 99999-9999") == "(11) 99999-9999") +} + +@Test("formatPhoneBR caps input at 11 digits") +func formatPhoneBRCapsAtElevenDigits() { + #expect(formatPhoneBR("119999999999999") == "(11) 99999-9999") +} + +// MARK: - normalizePhoneNumberForAPI + +@Test("normalizePhoneNumberForAPI returns empty for fewer than 10 digits") +func normalizePhoneNumberReturnsEmptyBelowTenDigits() { + #expect(normalizePhoneNumberForAPI("119999") == "") +} + +@Test("normalizePhoneNumberForAPI prepends +55 to a bare local number") +func normalizePhoneNumberPrependsCountryCode() { + #expect(normalizePhoneNumberForAPI("11999999999") == "+5511999999999") +} + +@Test("normalizePhoneNumberForAPI doesn't double the country code when it's already present") +func normalizePhoneNumberDoesNotDoubleCountryCode() { + #expect(normalizePhoneNumberForAPI("5511999999999") == "+5511999999999") +} + +// MARK: - userFacingAuthErrorMessage + +@Test("userFacingAuthErrorMessage uses ApiServiceError.sessionExpired's message when present") +func authErrorMessageUsesSessionExpiredMessage() { + let message = userFacingAuthErrorMessage(ApiServiceError.sessionExpired("Custom message")) + #expect(message == "Custom message") +} + +@Test("userFacingAuthErrorMessage falls back to a default for a blank sessionExpired message") +func authErrorMessageFallsBackForBlankSessionExpired() { + let message = userFacingAuthErrorMessage(ApiServiceError.sessionExpired(" ")) + #expect(message == "Sua sessão expirou. Faça login novamente.") +} + +@Test("userFacingAuthErrorMessage maps NetworkError.httpError codes to distinct Portuguese messages") +func authErrorMessageMapsHttpErrorCodes() { + #expect(userFacingAuthErrorMessage(NetworkError.httpError(400, nil)).contains("Revise as informações")) + #expect(userFacingAuthErrorMessage(NetworkError.httpError(404, nil)).contains("Não encontramos")) + #expect(userFacingAuthErrorMessage(NetworkError.httpError(429, nil)).contains("Muitas tentativas")) + #expect(userFacingAuthErrorMessage(NetworkError.httpError(503, nil)).contains("instáveis")) + #expect(userFacingAuthErrorMessage(NetworkError.httpError(418, nil)).contains("Não foi possível concluir")) +} + +@Test("userFacingAuthErrorMessage prefers the server's own message over the generic httpError mapping") +func authErrorMessagePrefersServerMessageOverGenericMapping() { + let message = userFacingAuthErrorMessage(NetworkError.httpError(400, "CPF já cadastrado")) + #expect(message == "CPF já cadastrado") +} + +@Test("userFacingAuthErrorMessage maps unauthorized, rateLimited, transportError, cancelled, and timedOut") +func authErrorMessageMapsOtherNetworkErrorCases() { + #expect(userFacingAuthErrorMessage(NetworkError.unauthorized(nil)).contains("acesso expirou")) + #expect(userFacingAuthErrorMessage(NetworkError.rateLimited(nil)).contains("Muitas tentativas")) + #expect(userFacingAuthErrorMessage(NetworkError.transportError("timeout")).contains("conectar ao servidor")) + #expect(userFacingAuthErrorMessage(NetworkError.cancelled) == "Cancelado") + #expect(userFacingAuthErrorMessage(NetworkError.timedOut).contains("demorou demais")) +} + +@Test("userFacingAuthErrorMessage falls back to a generic message for an unrecognized error type") +func authErrorMessageGenericFallbackForUnknownErrorType() { + struct SomeOtherError: Error {} + #expect(userFacingAuthErrorMessage(SomeOtherError()) == "Não foi possível concluir a operação. Tente novamente.") +} diff --git a/PediFoodsTests/CartStateTests.swift b/PediFoodsTests/CartStateTests.swift new file mode 100644 index 0000000..1465731 --- /dev/null +++ b/PediFoodsTests/CartStateTests.swift @@ -0,0 +1,127 @@ +import Foundation +import Testing +@testable import PediFoods + +private func makeItem(id: String, quantity: Int, unitPrice: Double, addons: [CartItemAddonState] = []) -> CartItemState { + CartItemState(id: id, productId: "p-\(id)", storeId: "s1", name: "Item \(id)", addons: addons, quantity: quantity, unitPrice: unitPrice) +} + +@Test("totalItems sums quantities across every item") +func totalItemsSumsQuantities() { + var cart = CartState() + cart.items = [makeItem(id: "1", quantity: 2, unitPrice: 10), makeItem(id: "2", quantity: 3, unitPrice: 5)] + #expect(cart.totalItems == 5) +} + +@Test("recalculateTotal sums quantity times unitPrice across every item") +func recalculateTotalSumsLineItems() { + var cart = CartState() + cart.items = [makeItem(id: "1", quantity: 2, unitPrice: 10), makeItem(id: "2", quantity: 1, unitPrice: 5)] + cart.recalculateTotal() + #expect(cart.total == 25) +} + +@Test("toOrderItemsPayload drops zero-quantity addons and nils out empty choices") +func toOrderItemsPayloadDropsZeroQuantityAddonsAndEmptyChoices() { + var cart = CartState() + let item = CartItemState( + id: "1", productId: "p1", storeId: "s1", name: "Pizza", choices: [], + addons: [ + CartItemAddonState(id: "a1", name: "Cheese", quantity: 1, unitPrice: 2), + CartItemAddonState(id: "a2", name: "Removed", quantity: 0, unitPrice: 3) + ], + quantity: 1, unitPrice: 20 + ) + cart.items = [item] + let payload = cart.toOrderItemsPayload() + #expect(payload.count == 1) + #expect(payload[0].addons.count == 1) + #expect(payload[0].addons[0].addonId == "a1") + #expect(payload[0].choices == nil) +} + +/// `CartState`'s mutating methods persist through `SessionStateStore`, +/// which shares one static `UserDefaults` swap point. `.serialized` only +/// orders tests *within* a suite, not across two separate suite types - +/// so these live as an extension of `SessionStateStorePersistenceTests` +/// (defined in SessionStateStoreTests.swift) rather than their own +/// `@Suite`, putting them in the same serialization domain instead of a +/// second one that could still race the first. +extension SessionStateStorePersistenceTests { + @Test("add appends a new item and recalculates the total") + func addAppendsNewItem() { + withIsolatedDefaults { + var cart = CartState(storeId: "s1", storeName: "Loja A") + cart.add(item: makeItem(id: "1", quantity: 2, unitPrice: 10)) + #expect(cart.items.count == 1) + #expect(cart.total == 20) + } + } + + @Test("add increments the quantity of an already-present item instead of duplicating it") + func addIncrementsExistingItem() { + withIsolatedDefaults { + var cart = CartState(storeId: "s1", storeName: "Loja A") + cart.add(item: makeItem(id: "1", quantity: 1, unitPrice: 10)) + cart.add(item: makeItem(id: "1", quantity: 2, unitPrice: 10)) + #expect(cart.items.count == 1) + #expect(cart.items[0].quantity == 3) + #expect(cart.total == 30) + } + } + + @Test("set removes the item when its quantity is zero or less") + func setRemovesItemAtZeroQuantity() { + withIsolatedDefaults { + var cart = CartState(storeId: "s1", storeName: "Loja A") + cart.add(item: makeItem(id: "1", quantity: 1, unitPrice: 10)) + cart.set(item: makeItem(id: "1", quantity: 0, unitPrice: 10)) + #expect(cart.items.isEmpty) + } + } + + @Test("set clears storeId/storeName once the cart becomes empty") + func setClearsStoreWhenCartBecomesEmpty() { + withIsolatedDefaults { + var cart = CartState(storeId: "s1", storeName: "Loja A") + cart.add(item: makeItem(id: "1", quantity: 1, unitPrice: 10)) + cart.set(item: makeItem(id: "1", quantity: 0, unitPrice: 10)) + #expect(cart.storeId == nil) + #expect(cart.storeName == nil) + } + } + + @Test("increment increases an existing item's quantity by one") + func incrementIncreasesQuantity() { + withIsolatedDefaults { + var cart = CartState(storeId: "s1", storeName: "Loja A") + cart.add(item: makeItem(id: "1", quantity: 1, unitPrice: 10)) + cart.increment(itemId: "1") + #expect(cart.items[0].quantity == 2) + #expect(cart.total == 20) + } + } + + @Test("decrement removes the item once its quantity reaches zero") + func decrementRemovesItemAtZero() { + withIsolatedDefaults { + var cart = CartState(storeId: "s1", storeName: "Loja A") + cart.add(item: makeItem(id: "1", quantity: 1, unitPrice: 10)) + cart.decrement(itemId: "1") + #expect(cart.items.isEmpty) + #expect(cart.total == 0) + } + } + + @Test("clear empties the cart and resets the total") + func clearEmptiesCart() { + withIsolatedDefaults { + var cart = CartState(storeId: "s1", storeName: "Loja A") + cart.add(item: makeItem(id: "1", quantity: 1, unitPrice: 10)) + cart.clear() + #expect(cart.items.isEmpty) + #expect(cart.storeId == nil) + #expect(cart.total == 0) + } + } +} diff --git a/PediFoodsTests/CheckoutTypesTests.swift b/PediFoodsTests/CheckoutTypesTests.swift new file mode 100644 index 0000000..15d88a1 --- /dev/null +++ b/PediFoodsTests/CheckoutTypesTests.swift @@ -0,0 +1,32 @@ +import Testing +@testable import PediFoods + +@Test("CheckoutPaymentMethod raw values match the backend's expected wire format") +func checkoutPaymentMethodRawValues() { + #expect(CheckoutPaymentMethod.pix.rawValue == "PIX") + #expect(CheckoutPaymentMethod.creditCard.rawValue == "CREDIT_CARD") + #expect(CheckoutPaymentMethod.debitCard.rawValue == "DEBIT_CARD") + #expect(CheckoutPaymentMethod.money.rawValue == "MONEY") + #expect(CheckoutPaymentMethod.voucher.rawValue == "VOUCHER") +} + +@Test("CheckoutPaymentMethod only pix and creditCard have a subtitle") +func checkoutPaymentMethodSubtitles() { + #expect(CheckoutPaymentMethod.pix.subtitle == "Aprovação imediata") + #expect(CheckoutPaymentMethod.creditCard.subtitle == "No app: rápido e seguro") + #expect(CheckoutPaymentMethod.debitCard.subtitle == nil) + #expect(CheckoutPaymentMethod.money.subtitle == nil) + #expect(CheckoutPaymentMethod.voucher.subtitle == nil) +} + +@Test("CheckoutPaymentMethod credit and debit cards share the same icon") +func checkoutPaymentMethodCardIconsShared() { + #expect(CheckoutPaymentMethod.creditCard.iconName == "creditcard.fill") + #expect(CheckoutPaymentMethod.debitCard.iconName == "creditcard.fill") +} + +@Test("CheckoutDeliveryType raw values match the backend's expected wire format") +func checkoutDeliveryTypeRawValues() { + #expect(CheckoutDeliveryType.delivery.rawValue == "DELIVERY") + #expect(CheckoutDeliveryType.pickup.rawValue == "PICKUP") +} diff --git a/PediFoodsTests/CheckoutViewLogicTests.swift b/PediFoodsTests/CheckoutViewLogicTests.swift new file mode 100644 index 0000000..63d452e --- /dev/null +++ b/PediFoodsTests/CheckoutViewLogicTests.swift @@ -0,0 +1,32 @@ +import SwiftUI +import Testing +@testable import PediFoods + +@MainActor +private func makeCheckoutView() -> CheckoutView { + var appState = AppState() + let appStateBinding = Binding(get: { appState }, set: { appState = $0 }) + var selectedTab = MainTab.cart + let tabBinding = Binding(get: { selectedTab }, set: { selectedTab = $0 }) + return CheckoutView(appState: appStateBinding, selectedTab: tabBinding) +} + +@Test("formatCurrency formats a Double as Brazilian Real with a comma decimal separator") +@MainActor +func formatCurrencyUsesCommaDecimalSeparator() { + let view = makeCheckoutView() + #expect(view.formatCurrency(29.9) == "R$ 29,90") + #expect(view.formatCurrency(0) == "R$ 0,00") + #expect(view.formatCurrency(1234.5) == "R$ 1234,50") +} + +@Test("CheckoutPayloadValidationError provides a distinct Portuguese message per case") +func checkoutPayloadValidationErrorMessages() { + #expect(CheckoutView.CheckoutPayloadValidationError.emptyCart.errorDescription == "Carrinho vazio.") + #expect(CheckoutView.CheckoutPayloadValidationError.missingCustomerName.errorDescription == "Nome do cliente não informado.") + #expect(CheckoutView.CheckoutPayloadValidationError.missingCustomerEmail.errorDescription == "Email do cliente não informado.") + #expect(CheckoutView.CheckoutPayloadValidationError.missingCustomerPhone.errorDescription == "Telefone do cliente não informado.") + #expect(CheckoutView.CheckoutPayloadValidationError.missingAddressStreet.errorDescription == "Rua do endereço não informada.") + #expect(CheckoutView.CheckoutPayloadValidationError.missingAddressNumber.errorDescription == "Número do endereço não informado.") + #expect(CheckoutView.CheckoutPayloadValidationError.missingAddressNeighborhood.errorDescription == "Bairro do endereço não informado.") +} diff --git a/PediFoodsTests/DeepLinkDestinationTests.swift b/PediFoodsTests/DeepLinkDestinationTests.swift new file mode 100644 index 0000000..533f94d --- /dev/null +++ b/PediFoodsTests/DeepLinkDestinationTests.swift @@ -0,0 +1,67 @@ +import Foundation +import Testing +@testable import PediFoods + +@Test("PushDeepLinkParser parses order_status with an explicit orderId") +func parsesOrderStatusWithExplicitOrderId() { + let userInfo: [AnyHashable: Any] = ["type": "order_status", "orderId": "order-1", "shortId": "SHORT1"] + let destination = PushDeepLinkParser.parse(userInfo) + #expect(destination == .orderTracking(orderId: "order-1", shortId: "SHORT1")) +} + +@Test("PushDeepLinkParser falls back to shortId as the orderId when orderId is absent") +func fallsBackToShortIdAsOrderId() { + let userInfo: [AnyHashable: Any] = ["type": "order_status", "shortId": "SHORT1"] + let destination = PushDeepLinkParser.parse(userInfo) + #expect(destination == .orderTracking(orderId: "SHORT1", shortId: "SHORT1")) +} + +@Test("PushDeepLinkParser returns nil for order_status with neither orderId nor shortId") +func returnsNilForOrderStatusWithNoIdentifiers() { + let userInfo: [AnyHashable: Any] = ["type": "order_status"] + #expect(PushDeepLinkParser.parse(userInfo) == nil) +} + +@Test("PushDeepLinkParser parses a targetScreen payload into .screen with its params") +func parsesTargetScreenWithParams() { + let userInfo: [AnyHashable: Any] = [ + "targetScreen": "promo", + "couponId": "abc123", + "storeId": "store-9" + ] + guard case let .screen(name, params) = PushDeepLinkParser.parse(userInfo) else { + Issue.record("expected .screen destination") + return + } + #expect(name == "promo") + #expect(params == ["couponId": "abc123", "storeId": "store-9"]) +} + +@Test("PushDeepLinkParser excludes type and targetScreen keys from the screen's params") +func excludesRoutingKeysFromScreenParams() { + let userInfo: [AnyHashable: Any] = ["type": "campaign", "targetScreen": "promo", "couponId": "abc123"] + guard case let .screen(_, params) = PushDeepLinkParser.parse(userInfo) else { + Issue.record("expected .screen destination") + return + } + #expect(params["type"] == nil) + #expect(params["targetScreen"] == nil) + #expect(params["couponId"] == "abc123") +} + +@Test("PushDeepLinkParser returns nil for a payload with neither order_status nor targetScreen") +func returnsNilForUnroutablePayload() { + let userInfo: [AnyHashable: Any] = ["type": "campaign", "campaignId": "c1"] + #expect(PushDeepLinkParser.parse(userInfo) == nil) +} + +@Test("PushDeepLinkParser drops non-String param values from a screen payload") +func dropsNonStringParamValues() { + let userInfo: [AnyHashable: Any] = ["targetScreen": "promo", "count": 5, "label": "sale"] + guard case let .screen(_, params) = PushDeepLinkParser.parse(userInfo) else { + Issue.record("expected .screen destination") + return + } + #expect(params["count"] == nil) + #expect(params["label"] == "sale") +} diff --git a/PediFoodsTests/DeepLinkRouteEffectTests.swift b/PediFoodsTests/DeepLinkRouteEffectTests.swift new file mode 100644 index 0000000..6975541 --- /dev/null +++ b/PediFoodsTests/DeepLinkRouteEffectTests.swift @@ -0,0 +1,35 @@ +import Testing +@testable import PediFoods + +@Test("orderTracking destination routes to the Profile tab with a pending auto-intent order context") +@MainActor +func routeEffectForOrderTracking() { + let view = ContentView() + let effect = view.routeEffect(for: .orderTracking(orderId: "order-1", shortId: "PF-1")) + + #expect(effect == .navigateToOrder( + OrderRouteContext(orderId: "order-1", shortId: "PF-1", paymentMethod: nil, total: nil, intent: .auto), + tab: .profile + )) +} + +@Test("orderTracking destination carries a nil shortId through unchanged") +@MainActor +func routeEffectForOrderTrackingWithoutShortId() { + let view = ContentView() + let effect = view.routeEffect(for: .orderTracking(orderId: "order-2", shortId: nil)) + + #expect(effect == .navigateToOrder( + OrderRouteContext(orderId: "order-2", shortId: nil, paymentMethod: nil, total: nil, intent: .auto), + tab: .profile + )) +} + +@Test("screen destination has no routing effect yet (unhandled deep-link screen)") +@MainActor +func routeEffectForScreenDestinationIsNone() { + let view = ContentView() + let effect = view.routeEffect(for: .screen(name: "promo", params: ["code": "SAVE10"])) + + #expect(effect == .none) +} diff --git a/Tests/PediFoodsTests/FavoritesDecodingTests.swift b/PediFoodsTests/FavoritesDecodingTests.swift similarity index 100% rename from Tests/PediFoodsTests/FavoritesDecodingTests.swift rename to PediFoodsTests/FavoritesDecodingTests.swift diff --git a/PediFoodsTests/FeatureControlServiceTests.swift b/PediFoodsTests/FeatureControlServiceTests.swift new file mode 100644 index 0000000..2251133 --- /dev/null +++ b/PediFoodsTests/FeatureControlServiceTests.swift @@ -0,0 +1,104 @@ +import Foundation +import Testing +@testable import PediFoods + +/// All these tests set the shared `URLProtocolStub.handler` static, so they +/// must not run concurrently with each other (see the note on +/// `SessionStateStorePersistenceTests` for the same reasoning). +@Suite(.serialized) +struct FeatureControlServiceTests { + @MainActor + private func makeService(cacheTTL: TimeInterval = 60) -> FeatureControlService { + let defaults = UserDefaults(suiteName: UUID().uuidString)! + let session = URLProtocolStub.makeSession() + return FeatureControlService(session: session, cacheTTL: cacheTTL, userDefaults: defaults) + } + + private let bootstrapSuccessBody = Data(""" + {"ok":true,"configVersion":3,"evaluatedAt":"2026-01-01T00:00:00Z","source":"live", + "flags":{"at.promo":true},"raw":{"at.promo":{"enabled":true,"variant":"on"}}} + """.utf8) + + @Test("evaluate returns a live snapshot on a successful bootstrap and caches it") + @MainActor + func evaluateSucceedsAndCaches() async { + let service = makeService() + URLProtocolStub.handler = { _ in (.stub(statusCode: 200), self.bootstrapSuccessBody) } + defer { URLProtocolStub.handler = nil } + + let context = FeatureControlEvaluationContext(subjectType: "customer", subjectId: "u1", storeId: nil, attributes: [:]) + let snapshot = await service.evaluate(context: context, jwt: nil) + + #expect(snapshot.source == "live") + #expect(snapshot.configVersion == 3) + #expect(snapshot.isEnabled("at.promo") == true) + } + + @Test("evaluate serves from cache on a second call without hitting the network again") + @MainActor + func evaluateServesFromCacheOnSecondCall() async { + let service = makeService() + var requestCount = 0 + URLProtocolStub.handler = { [bootstrapSuccessBody] _ in + requestCount += 1 + return (.stub(statusCode: 200), bootstrapSuccessBody) + } + defer { URLProtocolStub.handler = nil } + + let context = FeatureControlEvaluationContext(subjectType: "customer", subjectId: "u1", storeId: nil, attributes: [:]) + _ = await service.evaluate(context: context, jwt: nil) + let second = await service.evaluate(context: context, jwt: nil) + + #expect(requestCount == 1) + #expect(second.source == "live") + } + + @Test("evaluate falls back to a stale cache entry when the network call fails") + @MainActor + func evaluateFallsBackToCacheOnNetworkError() async { + let service = makeService() + let context = FeatureControlEvaluationContext(subjectType: "customer", subjectId: "u1", storeId: nil, attributes: [:]) + + URLProtocolStub.handler = { _ in (.stub(statusCode: 200), self.bootstrapSuccessBody) } + _ = await service.evaluate(context: context, jwt: nil) + + URLProtocolStub.handler = { _ in (.stub(statusCode: 500), Data()) } + defer { URLProtocolStub.handler = nil } + let refreshed = await service.evaluate(context: context, jwt: nil, forceRefresh: true) + + #expect(refreshed.source == "cache_fallback") + #expect(refreshed.isEnabled("at.promo") == true) + } + + @Test("evaluate falls back to defaults when the network fails and there's no cache") + @MainActor + func evaluateFallsBackToDefaultsWithNoCache() async { + let service = makeService() + URLProtocolStub.handler = { _ in (.stub(statusCode: 500), Data()) } + defer { URLProtocolStub.handler = nil } + + let context = FeatureControlEvaluationContext(subjectType: "customer", subjectId: "u1", storeId: nil, attributes: [:]) + let snapshot = await service.evaluate(context: context, jwt: nil) + + #expect(snapshot.source == "defaults") + #expect(snapshot.isEnabled("at.promo") == false) + } + + @Test("evaluate re-fetches once a cached entry's TTL has expired") + @MainActor + func evaluateRefetchesAfterCacheExpires() async { + let service = makeService(cacheTTL: -1) // already expired the instant it's written + var requestCount = 0 + URLProtocolStub.handler = { [bootstrapSuccessBody] _ in + requestCount += 1 + return (.stub(statusCode: 200), bootstrapSuccessBody) + } + defer { URLProtocolStub.handler = nil } + + let context = FeatureControlEvaluationContext(subjectType: "customer", subjectId: "u1", storeId: nil, attributes: [:]) + _ = await service.evaluate(context: context, jwt: nil) + _ = await service.evaluate(context: context, jwt: nil) + + #expect(requestCount == 2) + } +} diff --git a/PediFoodsTests/FeatureFlagsStateTests.swift b/PediFoodsTests/FeatureFlagsStateTests.swift new file mode 100644 index 0000000..00f3f0f --- /dev/null +++ b/PediFoodsTests/FeatureFlagsStateTests.swift @@ -0,0 +1,46 @@ +import Testing +@testable import PediFoods + +@Test("FeatureFlagValue.boolValue passes through a boolean case directly") +func featureFlagValueBoolValuePassesThroughBoolean() { + #expect(FeatureFlagValue.boolean(true).boolValue == true) + #expect(FeatureFlagValue.boolean(false).boolValue == false) +} + +@Test("FeatureFlagValue.boolValue treats 'on'/'true' text as true, case-insensitively") +func featureFlagValueBoolValueTextVariants() { + #expect(FeatureFlagValue.text("on").boolValue == true) + #expect(FeatureFlagValue.text("ON").boolValue == true) + #expect(FeatureFlagValue.text("true").boolValue == true) + #expect(FeatureFlagValue.text("off").boolValue == false) + #expect(FeatureFlagValue.text("something-else").boolValue == false) +} + +@Test("FeatureFlagsState.isEnabled prefers the raw flag over the mapped value") +func featureFlagsStateIsEnabledPrefersRaw() { + var state = FeatureFlagsState() + state.raw["promo"] = FeatureControlRawFlag(enabled: true, variant: "on", payload: nil, reason: nil) + state.values["promo"] = .boolean(false) + #expect(state.isEnabled("promo") == true) +} + +@Test("FeatureFlagsState.isEnabled treats a raw flag as enabled when its variant is 'on' even if enabled is false") +func featureFlagsStateIsEnabledRawVariantOn() { + var state = FeatureFlagsState() + state.raw["promo"] = FeatureControlRawFlag(enabled: false, variant: "on", payload: nil, reason: nil) + #expect(state.isEnabled("promo") == true) +} + +@Test("FeatureFlagsState.isEnabled falls back to the mapped value when no raw flag exists") +func featureFlagsStateIsEnabledFallsBackToValues() { + var state = FeatureFlagsState() + state.values["promo"] = .text("on") + #expect(state.isEnabled("promo") == true) +} + +@Test("FeatureFlagsState.isEnabled falls back to the given default when the key is unknown") +func featureFlagsStateIsEnabledFallsBackToDefault() { + let state = FeatureFlagsState() + #expect(state.isEnabled("unknown-key") == false) + #expect(state.isEnabled("unknown-key", default: true) == true) +} diff --git a/PediFoodsTests/GuestLocationStoreTests.swift b/PediFoodsTests/GuestLocationStoreTests.swift new file mode 100644 index 0000000..69b4624 --- /dev/null +++ b/PediFoodsTests/GuestLocationStoreTests.swift @@ -0,0 +1,47 @@ +import Foundation +import Testing +@testable import PediFoods + +/// `GuestLocationStore.shared` is a singleton backed by the real Keychain +/// under fixed key names - concurrent tests touching the same keys would +/// stomp on each other, so this suite runs serialized. Every test cleans +/// up after itself via the store's own clear/nil-set APIs. +@Suite(.serialized) +struct GuestLocationStoreTests { + @Test("deviceId is stable across repeated reads") + func deviceIdIsStableAcrossReads() { + let first = GuestLocationStore.shared.deviceId + let second = GuestLocationStore.shared.deviceId + #expect(first == second) + #expect(first.isEmpty == false) + } + + @Test("selectedState/selectedCity round-trip and clear together via clearSelectedLocation") + func selectedStateAndCityRoundTripAndClear() { + GuestLocationStore.shared.selectedState = "SP" + GuestLocationStore.shared.selectedCity = "São Paulo" + #expect(GuestLocationStore.shared.selectedState == "SP") + #expect(GuestLocationStore.shared.selectedCity == "São Paulo") + + GuestLocationStore.shared.clearSelectedLocation() + #expect(GuestLocationStore.shared.selectedState == nil) + #expect(GuestLocationStore.shared.selectedCity == nil) + } + + @Test("appAttestKeyId round-trips and clears when set to nil") + func appAttestKeyIdRoundTripAndClear() { + GuestLocationStore.shared.appAttestKeyId = "key-123" + #expect(GuestLocationStore.shared.appAttestKeyId == "key-123") + + GuestLocationStore.shared.appAttestKeyId = nil + #expect(GuestLocationStore.shared.appAttestKeyId == nil) + } + + @Test("attestationPlaceholder is stable across repeated reads") + func attestationPlaceholderIsStableAcrossReads() { + let first = GuestLocationStore.shared.attestationPlaceholder + let second = GuestLocationStore.shared.attestationPlaceholder + #expect(first == second) + #expect(first.isEmpty == false) + } +} diff --git a/PediFoodsTests/HomeFiltersStateTests.swift b/PediFoodsTests/HomeFiltersStateTests.swift new file mode 100644 index 0000000..70c24be --- /dev/null +++ b/PediFoodsTests/HomeFiltersStateTests.swift @@ -0,0 +1,23 @@ +import Testing +@testable import PediFoods + +@Test("reset restores every filter to its default except availableCategories") +func homeFiltersStateResetRestoresDefaults() { + var state = HomeFiltersState() + state.sortOption = .price + state.selectedCategories = ["pizza", "burger"] + state.selectedPriceTier = .high + state.maxDistanceKm = 25 + state.availableCategories = ["pizza", "burger", "sushi"] + + state.reset() + + #expect(state.sortOption == .relevance) + #expect(state.selectedCategories.isEmpty) + #expect(state.selectedPriceTier == nil) + #expect(state.maxDistanceKm == 10) + // availableCategories reflects what the backend returned for this + // location, not a user selection - reset() intentionally leaves it + // alone so the filter sheet doesn't lose its option list. + #expect(state.availableCategories == ["pizza", "burger", "sushi"]) +} diff --git a/PediFoodsTests/HomeViewFilteringTests.swift b/PediFoodsTests/HomeViewFilteringTests.swift new file mode 100644 index 0000000..170f36d --- /dev/null +++ b/PediFoodsTests/HomeViewFilteringTests.swift @@ -0,0 +1,67 @@ +import SwiftUI +import Testing +@testable import PediFoods + +/// Constructs a `HomeView` directly (no host window/hierarchy needed to +/// call its plain funcs that take explicit parameters). Note: +/// `@State`-backed properties (`stores`, etc.) mutated *after* +/// construction do NOT reliably persist outside a real SwiftUI render +/// pass - confirmed empirically (computed properties reading `stores` +/// saw the untouched `[]` default even after `view.stores = ...`). So +/// this only covers `HomeView+Filtering` functions that take their +/// input as parameters, not ones that read `@State` implicitly - +/// `filteredStores` and friends are UI-test territory (Batch F), not +/// unit-test territory. +@MainActor +private func makeHomeView() -> HomeView { + var state = AppState() + let appStateBinding = Binding(get: { state }, set: { state = $0 }) + let tabBinding = Binding(get: { .home }, set: { _ in }) + return HomeView(appState: appStateBinding, selectedTab: tabBinding) +} + +@Test("normalizeSearch folds diacritics and case") +@MainActor +func normalizeSearchFoldsDiacriticsAndCase() { + let view = makeHomeView() + #expect(view.normalizeSearch("Açaí") == "acai") + #expect(view.normalizeSearch("PIZZA") == "pizza") + #expect(view.normalizeSearch(" Café ") == "cafe") +} + +@Test("matchesPriceTier buckets delivery fees at the documented boundaries") +@MainActor +func matchesPriceTierBoundaries() { + let view = makeHomeView() + #expect(view.matchesPriceTier(fee: 5, tier: .low)) + #expect(view.matchesPriceTier(fee: 5.01, tier: .low) == false) + #expect(view.matchesPriceTier(fee: 5.01, tier: .medium)) + #expect(view.matchesPriceTier(fee: 10, tier: .medium)) + #expect(view.matchesPriceTier(fee: 10.01, tier: .high)) + #expect(view.matchesPriceTier(fee: 20.01, tier: .veryHigh)) +} + +@Test("estimatedDeliveryMinutes picks the smaller number out of a range like '30-45 min'") +@MainActor +func estimatedDeliveryMinutesPicksMinimumOfRange() { + let view = makeHomeView() + #expect(view.estimatedDeliveryMinutes("30-45 min") == 30) +} + +@Test("estimatedDeliveryMinutes returns Int.max for nil or non-numeric input") +@MainActor +func estimatedDeliveryMinutesMaxForMissingOrGarbageInput() { + let view = makeHomeView() + #expect(view.estimatedDeliveryMinutes(nil) == Int.max) + #expect(view.estimatedDeliveryMinutes("indisponível") == Int.max) +} + +@Test("formatDistance shows meters below 1km and kilometers at or above 1km") +@MainActor +func formatDistanceSwitchesUnitsAtOneKm() { + let view = makeHomeView() + #expect(view.formatDistance(0.5) == "500 m") + #expect(view.formatDistance(1.0) == "1.0 km") + #expect(view.formatDistance(2.3) == "2.3 km") + #expect(view.formatDistance(nil) == "Distância indisponível") +} diff --git a/PediFoodsTests/ImageSourceResolverTests.swift b/PediFoodsTests/ImageSourceResolverTests.swift new file mode 100644 index 0000000..f79300d --- /dev/null +++ b/PediFoodsTests/ImageSourceResolverTests.swift @@ -0,0 +1,46 @@ +import Foundation +import Testing +@testable import PediFoods + +@Test("ImageSourceResolver returns nil for nil, empty, or whitespace-only input") +func imageSourceResolverNilOnEmptyInput() { + #expect(ImageSourceResolver.resolve(nil) == nil) + #expect(ImageSourceResolver.resolve("") == nil) + #expect(ImageSourceResolver.resolve(" ") == nil) +} + +@Test("ImageSourceResolver passes absolute http/https URLs through unchanged") +func imageSourceResolverPassesAbsoluteURLsThrough() { + #expect(ImageSourceResolver.resolve("https://cdn.example.com/img.png") == "https://cdn.example.com/img.png") + #expect(ImageSourceResolver.resolve("http://cdn.example.com/img.png") == "http://cdn.example.com/img.png") +} + +@Test("ImageSourceResolver treats the http/https scheme check case-insensitively") +func imageSourceResolverSchemeCheckIsCaseInsensitive() { + let resolved = ImageSourceResolver.resolve("HTTPS://cdn.example.com/img.png") + #expect(resolved == "HTTPS://cdn.example.com/img.png") +} + +@Test("ImageSourceResolver passes data:image URLs through unchanged") +func imageSourceResolverPassesDataImageURLsThrough() { + let dataURL = "data:image/png;base64,iVBORw0KGgo=" + #expect(ImageSourceResolver.resolve(dataURL) == dataURL) +} + +@Test("ImageSourceResolver normalizes JSON-escaped backslash-slashes before checking the scheme") +func imageSourceResolverNormalizesEscapedSlashes() { + let escaped = "https:\\/\\/cdn.example.com\\/img.png" + #expect(ImageSourceResolver.resolve(escaped) == "https://cdn.example.com/img.png") +} + +@Test("ImageSourceResolver prefixes a relative path with the API base URL") +func imageSourceResolverPrefixesRelativePathWithBaseURL() { + let resolved = ImageSourceResolver.resolve("uploads/img.png") + #expect(resolved == "\(ApiConfig.baseURL.absoluteString)/uploads/img.png") +} + +@Test("ImageSourceResolver doesn't double the leading slash for an already-rooted relative path") +func imageSourceResolverDoesNotDoubleLeadingSlash() { + let resolved = ImageSourceResolver.resolve("/uploads/img.png") + #expect(resolved == "\(ApiConfig.baseURL.absoluteString)/uploads/img.png") +} diff --git a/Tests/PediFoodsTests/LegalDocumentTests.swift b/PediFoodsTests/LegalDocumentTests.swift similarity index 100% rename from Tests/PediFoodsTests/LegalDocumentTests.swift rename to PediFoodsTests/LegalDocumentTests.swift diff --git a/PediFoodsTests/SessionStateStoreTests.swift b/PediFoodsTests/SessionStateStoreTests.swift new file mode 100644 index 0000000..9521ec2 --- /dev/null +++ b/PediFoodsTests/SessionStateStoreTests.swift @@ -0,0 +1,196 @@ +import Foundation +import Testing +@testable import PediFoods + +@Test("makeUserKey prefers a non-empty profileId over email") +func makeUserKeyPrefersProfileId() { + let key = SessionStateStore.makeUserKey(profileId: "abc123", email: "user@example.com") + #expect(key == "id:abc123") +} + +@Test("makeUserKey falls back to email when profileId is nil or blank") +func makeUserKeyFallsBackToEmail() { + #expect(SessionStateStore.makeUserKey(profileId: nil, email: "User@Example.com") == "email:user@example.com") + #expect(SessionStateStore.makeUserKey(profileId: " ", email: "user@example.com") == "email:user@example.com") +} + +@Test("makeUserKey returns nil when both profileId and email are absent") +func makeUserKeyNilWhenBothAbsent() { + #expect(SessionStateStore.makeUserKey(profileId: nil, email: nil) == nil) + #expect(SessionStateStore.makeUserKey(profileId: " ", email: " ") == nil) +} + +/// `SessionStateStore.defaults` is one shared static var - Swift Testing +/// runs `@Test`s concurrently by default, so any test that swaps it would +/// race every other test in this file. `.serialized` forces this suite's +/// tests to run one at a time instead. +@Suite(.serialized) +struct SessionStateStorePersistenceTests { + /// Points `SessionStateStore` at a throwaway, uniquely-named + /// `UserDefaults` suite for the duration of one test, and restores + /// `.standard` after. Internal (not private) so the cross-file + /// extension in CartStateTests.swift can reuse it — both need to be + /// in this same serialization domain, see that file's comment. + func withIsolatedDefaults(_ body: () throws -> Void) rethrows { + let suiteName = "SessionStateStoreTests.\(UUID().uuidString)" + let suite = UserDefaults(suiteName: suiteName)! + let previous = SessionStateStore.defaults + SessionStateStore.defaults = suite + defer { + SessionStateStore.defaults = previous + suite.removePersistentDomain(forName: suiteName) + } + try body() + } + + @Test("shouldPromptPushOptIn is true before the first prompt is ever recorded") + func shouldPromptPushOptInTrueInitially() { + withIsolatedDefaults { + #expect(SessionStateStore.shouldPromptPushOptIn()) + } + } + + @Test("shouldPromptPushOptIn is false immediately after recording a prompt") + func shouldPromptPushOptInFalseRightAfterRecording() { + withIsolatedDefaults { + SessionStateStore.recordPushOptInPrompted() + #expect(SessionStateStore.shouldPromptPushOptIn() == false) + } + } + + @Test("saveAddress/loadAddress round-trips every field") + func addressRoundTrip() { + withIsolatedDefaults { + let state = AddressState(selectedId: "addr-1", display: "Rua A, 100", latitude: -23.5, longitude: -46.6) + SessionStateStore.saveAddress(state) + let loaded = SessionStateStore.loadAddress() + #expect(loaded?.selectedId == "addr-1") + #expect(loaded?.display == "Rua A, 100") + #expect(loaded?.latitude == -23.5) + } + } + + @Test("loadAddress substitutes a placeholder display string for an empty one") + func addressLoadReplacesEmptyDisplay() { + withIsolatedDefaults { + SessionStateStore.saveAddress(AddressState(selectedId: nil, display: "")) + #expect(SessionStateStore.loadAddress()?.display == "Defina seu endereco") + } + } + + @Test("clearAddress removes a previously saved address") + func addressClear() { + withIsolatedDefaults { + SessionStateStore.saveAddress(AddressState(selectedId: "addr-1", display: "Rua A")) + SessionStateStore.clearAddress() + #expect(SessionStateStore.loadAddress() == nil) + } + } + + @Test("saveCart/loadCart round-trips items, addons, and total") + func cartRoundTrip() { + withIsolatedDefaults { + let item = CartItemState( + id: "item-1", productId: "p1", storeId: "s1", name: "Pizza", + addons: [CartItemAddonState(id: "a1", name: "Extra cheese", quantity: 1, unitPrice: 3.0)], + quantity: 2, unitPrice: 29.9 + ) + let cart = CartState(storeId: "s1", storeName: "Loja A", items: [item], total: 65.8) + SessionStateStore.saveCart(cart) + + let loaded = SessionStateStore.loadCart() + #expect(loaded?.storeId == "s1") + #expect(loaded?.items.first?.name == "Pizza") + #expect(loaded?.items.first?.addons.first?.name == "Extra cheese") + #expect(loaded?.total == 65.8) + } + } + + @Test("clearCart removes a previously saved cart") + func cartClear() { + withIsolatedDefaults { + SessionStateStore.saveCart(CartState(storeId: "s1", storeName: "Loja A", items: [], total: 0)) + SessionStateStore.clearCart() + #expect(SessionStateStore.loadCart() == nil) + } + } + + @Test("saveTrackedOrder inserts new orders at the front and updates existing ones in place") + func trackedOrdersInsertAndUpdate() { + withIsolatedDefaults { + SessionStateStore.saveTrackedOrder(PublicOrderResult(id: "o1", status: "PENDING")) + SessionStateStore.saveTrackedOrder(PublicOrderResult(id: "o2", status: "PENDING")) + var orders = SessionStateStore.loadTrackedOrders() + #expect(orders.map(\.id) == ["o2", "o1"]) + + SessionStateStore.saveTrackedOrder(PublicOrderResult(id: "o1", status: "COMPLETED")) + orders = SessionStateStore.loadTrackedOrders() + #expect(orders.map(\.id) == ["o2", "o1"]) + #expect(orders.first(where: { $0.id == "o1" })?.status == "COMPLETED") + } + } + + @Test("loadTrackedOrder finds a specific order by id") + func trackedOrderLookupById() { + withIsolatedDefaults { + SessionStateStore.saveTrackedOrder(PublicOrderResult(id: "o1", status: "PENDING")) + #expect(SessionStateStore.loadTrackedOrder(orderId: "o1")?.status == "PENDING") + #expect(SessionStateStore.loadTrackedOrder(orderId: "missing") == nil) + } + } + + @Test("savePendingCartOrderId clears the value when given an empty string") + func pendingCartOrderIdClearsOnEmptyString() { + withIsolatedDefaults { + SessionStateStore.savePendingCartOrderId("order-1") + #expect(SessionStateStore.loadPendingCartOrderId() == "order-1") + SessionStateStore.savePendingCartOrderId(" ") + #expect(SessionStateStore.loadPendingCartOrderId() == nil) + } + } + + @Test("saveOrderReview de-duplicates by orderId case- and whitespace-insensitively") + func orderReviewDedupesByOrderId() { + withIsolatedDefaults { + let review1 = OrderReviewRecord( + orderId: "Order-1", storeId: nil, shortId: nil, storeName: nil, storeLogoURL: nil, + createdAt: nil, submittedAt: "2026-01-01", rating: 4, comment: "Good", + orderPositiveTags: nil, orderImprovementTags: nil, deliverySentiment: nil, + deliveryPositiveTags: nil, deliveryNegativeTags: nil, appNps: nil, platform: nil, + editableUntil: nil, storeReplyUntil: nil, reviewWindowExpiresAt: nil, + storeReplyMessage: nil, storeReplyAt: nil + ) + SessionStateStore.saveOrderReview(review1) + #expect(SessionStateStore.hasOrderReview(orderId: "order-1")) + #expect(SessionStateStore.loadOrderReviews().count == 1) + + let review2 = OrderReviewRecord( + orderId: "order-1 ", storeId: nil, shortId: nil, storeName: nil, storeLogoURL: nil, + createdAt: nil, submittedAt: "2026-01-02", rating: 5, comment: "Even better", + orderPositiveTags: nil, orderImprovementTags: nil, deliverySentiment: nil, + deliveryPositiveTags: nil, deliveryNegativeTags: nil, appNps: nil, platform: nil, + editableUntil: nil, storeReplyUntil: nil, reviewWindowExpiresAt: nil, + storeReplyMessage: nil, storeReplyAt: nil + ) + SessionStateStore.saveOrderReview(review2) + #expect(SessionStateStore.loadOrderReviews().count == 1) + #expect(SessionStateStore.loadOrderReviews().first?.rating == 5) + } + } + + @Test("saveOrderReviewDraft/loadOrderReviewDraft round-trips and clearOrderReviewDraft removes it") + func orderReviewDraftRoundTripAndClear() { + withIsolatedDefaults { + let draft = OrderReviewDraftState( + orderId: "o1", orderRate: 5, orderComment: "Great", orderPositiveTags: ["fast"], + orderImprovementTags: [], deliverySentiment: "good", deliveryPositiveTags: [], + deliveryNegativeTags: [], appNps: 9, platform: "ios" + ) + SessionStateStore.saveOrderReviewDraft(draft) + #expect(SessionStateStore.loadOrderReviewDraft(orderId: "o1")?.orderComment == "Great") + + SessionStateStore.clearOrderReviewDraft(orderId: "o1") + #expect(SessionStateStore.loadOrderReviewDraft(orderId: "o1") == nil) + } + } +} diff --git a/Tests/PediFoodsTests/SmokeTests.swift b/PediFoodsTests/SmokeTests.swift similarity index 100% rename from Tests/PediFoodsTests/SmokeTests.swift rename to PediFoodsTests/SmokeTests.swift diff --git a/PediFoodsTests/SnackbarCenterTests.swift b/PediFoodsTests/SnackbarCenterTests.swift new file mode 100644 index 0000000..d31445e --- /dev/null +++ b/PediFoodsTests/SnackbarCenterTests.swift @@ -0,0 +1,65 @@ +import Testing +@testable import PediFoods + +@Test("show sets current with the given title, style, and persistence flag") +@MainActor +func showSetsCurrentMessage() { + let center = SnackbarCenter() + center.show(title: "Saved!", style: .success, isPersistent: false) + #expect(center.current?.title == "Saved!") + #expect(center.current?.style == .success) + #expect(center.current?.isPersistent == false) +} + +@Test("handleTap dismisses a non-persistent message and runs its action") +@MainActor +func handleTapDismissesAndRunsAction() { + let center = SnackbarCenter() + var actionRan = false + center.show(title: "Undo?", isPersistent: false, action: { actionRan = true }) + center.handleTap() + #expect(center.current == nil) + #expect(actionRan) +} + +@Test("handleTap does nothing for a persistent message") +@MainActor +func handleTapIgnoresPersistentMessage() { + let center = SnackbarCenter() + var actionRan = false + center.show(title: "Uploading…", isPersistent: true, action: { actionRan = true }) + center.handleTap() + #expect(center.current != nil) + #expect(actionRan == false) +} + +@Test("dismiss(animated:) clears the current message unconditionally") +@MainActor +func dismissClearsCurrentMessage() { + let center = SnackbarCenter() + center.show(title: "Hello", isPersistent: true) + center.dismiss(animated: false) + #expect(center.current == nil) +} + +@Test("dismissPersistent only clears the message when it's actually persistent") +@MainActor +func dismissPersistentOnlyClearsPersistentMessages() { + let center = SnackbarCenter() + center.show(title: "Transient", isPersistent: false) + center.dismissPersistent() + #expect(center.current != nil) + + center.show(title: "Persistent", isPersistent: true) + center.dismissPersistent() + #expect(center.current == nil) +} + +@Test("show replaces a currently-displayed message with the new one") +@MainActor +func showReplacesCurrentMessage() { + let center = SnackbarCenter() + center.show(title: "First", isPersistent: true) + center.show(title: "Second", isPersistent: true) + #expect(center.current?.title == "Second") +} diff --git a/Tests/PediFoodsTests/StoreCatalogNormalizerTests.swift b/PediFoodsTests/StoreCatalogNormalizerTests.swift similarity index 100% rename from Tests/PediFoodsTests/StoreCatalogNormalizerTests.swift rename to PediFoodsTests/StoreCatalogNormalizerTests.swift diff --git a/PediFoodsTests/URLProtocolStub.swift b/PediFoodsTests/URLProtocolStub.swift new file mode 100644 index 0000000..d219aed --- /dev/null +++ b/PediFoodsTests/URLProtocolStub.swift @@ -0,0 +1,46 @@ +import Foundation + +/// Intercepts every request made through a `URLSession` configured with it, +/// so network-dependent code (`ApiClient`, `ApiService`, +/// `FeatureControlService`, ...) can be unit tested without touching a real +/// server. Register a handler per test, then build a session via +/// `URLProtocolStub.makeSession()`. +final class URLProtocolStub: URLProtocol { + /// `nonisolated(unsafe)`: `URLProtocol` subclasses are instantiated and + /// driven by URLSession's own internal (non-Sendable-checked) machinery, + /// off the calling actor. Tests only ever set this once, synchronously, + /// before starting the request that reads it. + nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + + static func makeSession() -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [URLProtocolStub.self] + return URLSession(configuration: config) + } + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + guard let handler = URLProtocolStub.handler else { + client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + do { + let (response, data) = try handler(request) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} + +extension HTTPURLResponse { + static func stub(url: URL = URL(string: "https://example.com")!, statusCode: Int) -> HTTPURLResponse { + HTTPURLResponse(url: url, statusCode: statusCode, httpVersion: nil, headerFields: nil)! + } +} diff --git a/PediFoodsUITests/AuthenticatedProfileNavigationTests.swift b/PediFoodsUITests/AuthenticatedProfileNavigationTests.swift new file mode 100644 index 0000000..631c48b --- /dev/null +++ b/PediFoodsUITests/AuthenticatedProfileNavigationTests.swift @@ -0,0 +1,51 @@ +import XCTest + +/// Covers screens reachable directly from the authenticated Profile tab +/// with no cart/store-browse setup needed - OrdersView and MyReviewsView +/// both load their own data. The standing QA account has real order/review +/// history from prior manual and automated testing, so these don't assert +/// on empty vs. non-empty - just that the real network load completes +/// without getting stuck. See +/// decisions/2026-08-06-ui-test-account-and-app-attest-bypass.md for the +/// account (`ensureLoggedIn`). +final class AuthenticatedProfileNavigationTests: XCTestCase { + override func setUpWithError() throws { + continueAfterFailure = false + } + + override func tearDownWithError() throws { + XCUIApplication().logoutIfAuthenticated() + } + + func testOrdersScreenReachableAndLoadsRealData() throws { + let app = XCUIApplication() + app.launch() + XCTAssertTrue(app.ensureLoggedIn(), "Login never completed") + XCTAssertTrue(app.buttons["Sair da Conta"].waitForExistence(timeout: 10), "Never reached authenticated Profile") + + app.staticTexts["Meus Pedidos"].tap() + + XCTAssertTrue(app.staticTexts["Meus Pedidos"].waitForExistence(timeout: 10)) + for _ in 0..<30 { + if app.activityIndicators.firstMatch.exists == false { break } + usleep(500_000) + } + XCTAssertFalse(app.activityIndicators.firstMatch.exists, "Orders list never finished loading") + } + + func testReviewsScreenReachableAndLoadsRealData() throws { + let app = XCUIApplication() + app.launch() + XCTAssertTrue(app.ensureLoggedIn(), "Login never completed") + XCTAssertTrue(app.buttons["Sair da Conta"].waitForExistence(timeout: 10), "Never reached authenticated Profile") + + app.staticTexts["Minhas Avaliações"].tap() + + XCTAssertTrue(app.staticTexts["Minhas Avaliações"].waitForExistence(timeout: 10)) + for _ in 0..<30 { + if app.activityIndicators.firstMatch.exists == false { break } + usleep(500_000) + } + XCTAssertFalse(app.activityIndicators.firstMatch.exists, "Reviews list never finished loading") + } +} diff --git a/PediFoodsUITests/AuthenticatedSessionFlowTests.swift b/PediFoodsUITests/AuthenticatedSessionFlowTests.swift new file mode 100644 index 0000000..461d007 --- /dev/null +++ b/PediFoodsUITests/AuthenticatedSessionFlowTests.swift @@ -0,0 +1,23 @@ +import XCTest + +/// Covers a real, authenticated session with the standing QA account (see +/// TestFixtures.swift / decisions/2026-08-06-ui-test-account-and-app-attest-bypass.md). +/// Logs out in tearDown so guest-only tests elsewhere in the same run don't +/// inherit an authenticated Keychain session. +final class AuthenticatedSessionFlowTests: XCTestCase { + override func setUpWithError() throws { + continueAfterFailure = false + } + + override func tearDownWithError() throws { + XCUIApplication().logoutIfAuthenticated() + } + + func testLoginReachesAuthenticatedProfile() throws { + let app = XCUIApplication() + app.launch() + XCTAssertTrue(app.ensureLoggedIn(), "Login never completed") + + XCTAssertTrue(app.buttons["Sair da Conta"].waitForExistence(timeout: 10), "Profile tab never showed the authenticated (logged-in) state") + } +} diff --git a/PediFoodsUITests/CartCheckoutFlowTests.swift b/PediFoodsUITests/CartCheckoutFlowTests.swift new file mode 100644 index 0000000..0a1fae2 --- /dev/null +++ b/PediFoodsUITests/CartCheckoutFlowTests.swift @@ -0,0 +1,189 @@ +import XCTest + +/// Covers Store Detail -> add to cart -> Cart -> Checkout, the biggest +/// remaining zero-coverage surface (CheckoutView.swift alone is ~5000 +/// lines). Requires the standing QA account to have a real saved address +/// so Home shows a real store list - see +/// decisions/2026-08-06-ui-test-account-and-app-attest-bypass.md. +final class CartCheckoutFlowTests: XCTestCase { + override func setUpWithError() throws { + continueAfterFailure = false + } + + override func tearDownWithError() throws { + XCUIApplication().logoutIfAuthenticated() + } + + func testAddProductToCartAndReachCheckout() throws { + let app = XCUIApplication() + app.launch() + try reachCheckoutWithOneItem(app) + } + + /// Exercises CheckoutView's payment-method selection and the address + /// picker's "Alterar" entry point without ever tapping "Confirmar e + /// Pagar" - deliberately not submitting a real order (per explicit + /// user direction: cover the screen, don't create real order data). + func testCheckoutPaymentMethodSelectionAndAddressAlterar() throws { + let app = XCUIApplication() + app.launch() + try reachCheckoutWithOneItem(app) + + XCTAssertTrue(app.staticTexts["MÉTODO DE PAGAMENTO"].waitForExistence(timeout: 5)) + + // PIX is the default selection; only tap an alternative if this + // store's real catalog actually offers one - don't assume Cartão + // de Crédito exists (in-app payment methods depend on real store + // config, already found to vary - see the standing-account state + // notes in decisions/2026-08-06-ui-test-account-and-app-attest-bypass.md). + let creditCardRow = app.buttons.matching(NSPredicate(format: "label CONTAINS[c] %@", "Cartão de Crédito")).firstMatch + if creditCardRow.exists { + creditCardRow.tap() + } + + XCTAssertTrue(app.staticTexts["TIPO DE ENTREGA"].waitForExistence(timeout: 5)) + + app.buttons["Alterar"].tap() + XCTAssertTrue( + app.staticTexts["Meus Endereços"].waitForExistence(timeout: 10), + "Alterar never opened the address picker" + ) + + // Dismiss without changing anything, back to Checkout. .isHittable, + // not just .exists: a Back button that exists but is mid-transition + // throws a fatal, uncatchable "not hittable" failure on tap - same + // issue already fixed in UITestSupport.reachProfileTabRegardlessOfAuthState. + // Poll briefly since the push-in animation can still be settling + // right after "Meus Endereços" first becomes visible. + let backButton = app.buttons.matching(NSPredicate(format: "label == %@", "Back")).firstMatch + var dismissed = false + for _ in 0..<10 { + if backButton.exists, backButton.isHittable { + backButton.tap() + dismissed = true + break + } + usleep(200_000) + } + if dismissed == false { + app.swipeDown() + } + if app.staticTexts["Finalizar Pedido"].waitForExistence(timeout: 10) == false { + let dir = "/private/tmp/claude-501/-Users-loverde-co-Documents-Loverde-JOBs-Producao-Loverde-Co-LC-RAG-Struct-projects-PediFoods-ios/4a9ec4f7-c3ad-4cf5-8dad-073446f6fd81/scratchpad" + try? app.screenshot().pngRepresentation.write(to: URL(fileURLWithPath: "\(dir)/after_alterar_dismiss.png")) + } + XCTAssertTrue(app.staticTexts["Finalizar Pedido"].exists, "Never returned to Checkout") + + // Deliberately not tapping "Confirmar e Pagar" - see doc comment. + } + + /// With "Cartão de Crédito" selected, "Confirmar e Pagar" does NOT + /// submit an order immediately - `CheckoutView+Logic.handleConfirmPaymentTap()` + /// returns early and opens `CardSelectionSheet` instead (real order + /// creation only happens if a saved card is then picked and confirmed + /// inside that sheet, which this test does not do). Confirmed by + /// reading the source before relying on it - this is a real, + /// deliberate app behavior, not an assumption. + func testConfirmarEPagarWithCreditCardOpensCardSelectionWithoutSubmitting() throws { + let app = XCUIApplication() + app.launch() + try reachCheckoutWithOneItem(app) + + let creditCardRow = app.buttons.matching(NSPredicate(format: "label CONTAINS[c] %@", "Cartão de Crédito")).firstMatch + XCTAssertTrue(creditCardRow.waitForExistence(timeout: 5), "This store is expected to offer Cartão de Crédito") + creditCardRow.tap() + + app.buttons["Confirmar e Pagar"].tap() + XCTAssertTrue(app.staticTexts["Selecionar Cartão"].waitForExistence(timeout: 10), "Never reached the card selection sheet") + + // "Adicionar novo cartão" opens PaymentCardView (CheckoutView.swift's + // own card-entry form - a different struct from AddCardFormView.swift, + // reached from Profile -> Meus Cartões, needs its own coverage) as a + // sheet on top. Only fills nothing and dismisses - no card saved, no + // order created. + app.buttons["Adicionar novo cartão"].tap() + XCTAssertTrue( + app.staticTexts["Novo Cartão"].waitForExistence(timeout: 10), + "Adicionar novo cartão never opened PaymentCardView" + ) + dismissViaCloseButton(app) + XCTAssertTrue(app.staticTexts["Selecionar Cartão"].waitForExistence(timeout: 10), "Never returned to card selection") + + dismissViaCloseButton(app) + XCTAssertTrue(app.staticTexts["Finalizar Pedido"].waitForExistence(timeout: 10), "Never returned to Checkout") + } + + /// Dismisses via the topmost sheet's "x" close button + /// (`identifier: "xmark"`) - shared by `CardSelectionSheet` and + /// `PaymentCardView`. `.firstMatch` tolerates a lower sheet's own + /// close button still existing underneath in the accessibility tree. + private func dismissViaCloseButton(_ app: XCUIApplication) { + let closeButton = app.buttons.matching(identifier: "xmark").firstMatch + for _ in 0..<10 { + if closeButton.exists, closeButton.isHittable { + closeButton.tap() + return + } + usleep(200_000) + } + app.swipeDown() + } + + private func reachCheckoutWithOneItem(_ app: XCUIApplication) throws { + XCTAssertTrue(app.ensureLoggedIn(), "Login never completed") + + // ensureLoggedIn can land on whichever tab it detected the + // authenticated session from (e.g. Profile, if already logged in + // from a previous test) rather than Home - switch explicitly. + XCTAssertTrue(app.buttons["Home"].waitForExistence(timeout: 10)) + app.buttons["Home"].tap() + + // The store card is one merged tappable element (name + category + + // distance + status all combine into its accessibility label), not + // a plain static text. + let storeCard = app.buttons.matching(NSPredicate(format: "label CONTAINS[c] %@", "MARIBA")).firstMatch + if storeCard.waitForExistence(timeout: 15) == false { + let dir = "/private/tmp/claude-501/-Users-loverde-co-Documents-Loverde-JOBs-Producao-Loverde-Co-LC-RAG-Struct-projects-PediFoods-ios/4a9ec4f7-c3ad-4cf5-8dad-073446f6fd81/scratchpad" + try? app.screenshot().pngRepresentation.write(to: URL(fileURLWithPath: "\(dir)/no_store_card.png")) + } + XCTAssertTrue(storeCard.exists, "Expected store card never appeared on Home") + storeCard.tap() + + // A plain (non-addon, non-pizza) product row's "+" control adds + // directly with no sheet. Once a product's quantity is > 0, its + // outer Button's identifier moves off itself - the same badge- + // merging bug already fixed for the tab bar's cart icon - because + // the row's own quantity Text becomes the Button's accessible + // label/identity instead. The QA account's cart has genuinely + // accumulated real quantities across many runs today, so + // app.buttons.matching(identifier: "plus") stopped matching once + // the first several products all had quantity > 0 (confirmed via + // screenshot: the "+" controls were clearly visible on screen + // while the buttons-only query found nothing). The nested Image + // keeps identifier "plus" regardless of quantity, so target that + // directly instead - same fix pattern as the cart-tab icon. + let addButton = app.images.matching(identifier: "plus").firstMatch + if addButton.waitForExistence(timeout: 25) == false { + let dir = "/private/tmp/claude-501/-Users-loverde-co-Documents-Loverde-JOBs-Producao-Loverde-Co-LC-RAG-Struct-projects-PediFoods-ios/4a9ec4f7-c3ad-4cf5-8dad-073446f6fd81/scratchpad" + try? app.screenshot().pngRepresentation.write(to: URL(fileURLWithPath: "\(dir)/store_detail_timeout40.png")) + } + XCTAssertTrue(addButton.exists, "Store Detail's product list never loaded") + addButton.tap() + + // Once the cart has items, the tab bar button's accessible label + // becomes just the badge count ("4") instead of "Shopping Cart" - + // confirmed via hierarchy dump (SwiftUI's accessibility-children + // combining dropped the nested Image's default "Shopping Cart" + // label entirely once a sibling Text badge was added). The nested + // "cart.fill" Image keeps its identifier regardless, so target + // that directly rather than the outer button. + let cartTab = app.images.matching(identifier: "cart.fill").firstMatch + XCTAssertTrue(cartTab.waitForExistence(timeout: 5)) + cartTab.tap() + XCTAssertTrue(app.staticTexts["Meu Carrinho"].waitForExistence(timeout: 10)) + XCTAssertFalse(app.staticTexts["Seu carrinho está vazio"].exists, "Cart still shows empty after adding a product") + + app.buttons["Ir para o Pagamento"].tap() + XCTAssertTrue(app.staticTexts["Finalizar Pedido"].waitForExistence(timeout: 10), "Never reached Checkout") + } +} diff --git a/PediFoodsUITests/HomeFiltersFlowTests.swift b/PediFoodsUITests/HomeFiltersFlowTests.swift new file mode 100644 index 0000000..b936d6e --- /dev/null +++ b/PediFoodsUITests/HomeFiltersFlowTests.swift @@ -0,0 +1,68 @@ +import XCTest + +/// Covers Home's filter sheet (FiltersModalView.swift, ~700 lines, was +/// 0% covered), reachable via the search bar's filter icon +/// (identifier "slider.horizontal.3"). Uses the authenticated QA account +/// rather than guest mode - the guest address picker +/// (PublicLocationPickerView) currently can't load its states/cities list +/// at all, because the backend rejects the simulator's App Attest bypass +/// with 403 APP_ATTEST_VERIFICATION_FAILED (confirmed directly via curl +/// against /api/public/session, a pre-existing backend-side issue, see +/// decisions/2026-08-06-ui-test-account-and-app-attest-bypass.md). That +/// leaves the guest address-picker sheet re-presenting itself indefinitely +/// (HomeView+Data.loadGuestStores() keeps re-triggering it while no +/// location is set), permanently covering Home. Logging in with the +/// standing QA account swaps AddressPickerModalView to the +/// App-Attest-free AddressesView instead, routing around the guest wall +/// entirely. +final class HomeFiltersFlowTests: XCTestCase { + override func setUpWithError() throws { + continueAfterFailure = false + } + + override func tearDownWithError() throws { + XCUIApplication().logoutIfAuthenticated() + } + + func testFiltersSheetSelectionsAndApply() throws { + let app = XCUIApplication() + app.launch() + XCTAssertTrue(app.ensureLoggedIn(), "Login never completed") + + // ensureLoggedIn can land on whichever tab it detected the + // authenticated session from - switch explicitly. + XCTAssertTrue(app.buttons["Home"].waitForExistence(timeout: 10)) + app.buttons["Home"].tap() + + // The filter icon lives inside Home's collapsing header + // (SearchBar, positioned with .offset(y: collapseProgress * -120)) + // - a plain .tap() was separately confirmed (while still chasing + // the guest-mode blocker above) to sometimes resolve to an invalid + // "Computed hit point {-1, -1}" for this transform-positioned + // element. Tapping a normalized-offset coordinate on the element + // bypasses XCUITest's automatic hit-point computation. + let filterIcon = app.buttons.matching(identifier: "slider.horizontal.3").firstMatch + XCTAssertTrue(filterIcon.waitForExistence(timeout: 10), "Filter icon never appeared on Home") + filterIcon.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() + XCTAssertTrue(app.staticTexts["Filtros"].waitForExistence(timeout: 5), "Never reached the filters sheet") + + // Sort option - just needs to be selectable, no assertion on the + // resulting Home state (that's real backend store data, out of + // scope here). + let ratingSort = app.buttons["Avaliação"] + if ratingSort.exists { + ratingSort.tap() + } + + // Price tier - label is the raw "$"/"$$"/etc symbol. + let mediumPrice = app.buttons["$$"] + if mediumPrice.exists { + mediumPrice.tap() + } + + app.buttons["Aplicar Filtros"].tap() + + // Applying dismisses back to Home. + XCTAssertTrue(app.textFields["Search menu, restaurant or craving"].waitForExistence(timeout: 10), "Never returned to Home after applying filters") + } +} diff --git a/PediFoodsUITests/HomeGuestFlowTests.swift b/PediFoodsUITests/HomeGuestFlowTests.swift new file mode 100644 index 0000000..74a6a09 --- /dev/null +++ b/PediFoodsUITests/HomeGuestFlowTests.swift @@ -0,0 +1,73 @@ +import XCTest + +/// Covers the guest (unauthenticated) Home screen - confirmed reachable +/// and stable, see decisions/2026-08-06-ui-test-account-and-app-attest-bypass.md. +/// Authenticated-only flows (real store data, cart checkout, orders) are +/// blocked by a separate login-navigation issue documented there and are +/// not covered here. +final class HomeGuestFlowTests: XCTestCase { + override func setUpWithError() throws { + continueAfterFailure = false + } + + func testHomeRendersSearchAndGreeting() throws { + let app = XCUIApplication() + app.launch() + XCTAssertTrue(app.reachGuestHome(), "Never reached Home's search field") + + XCTAssertTrue(app.staticTexts["ENTREGAR EM:"].exists) + XCTAssertTrue(app.textFields["Search menu, restaurant or craving"].exists) + XCTAssertTrue(app.images["bell"].exists, "Notifications bell missing") + } + + func testHomeShowsDefaultCategoryChip() throws { + let app = XCUIApplication() + app.launch() + XCTAssertTrue(app.reachGuestHome()) + + XCTAssertTrue(app.staticTexts["Categories"].exists) + XCTAssertTrue(app.staticTexts["Todas"].exists, "Default 'Todas' category chip missing") + } + + func testHomeShowsGuestLocationPromptWhenNoLocationChosen() throws { + let app = XCUIApplication() + app.launch() + XCTAssertTrue(app.reachGuestHome()) + + // No guest state/city selected -> Home's own inline message, not + // the blocking sheet (which we just dismissed to get here). + XCTAssertTrue(app.staticTexts["Escolha um estado e cidade para visualizar os estabelecimentos."].waitForExistence(timeout: 3)) + } + + func testTappingAddressChipReopensLocationPicker() throws { + let app = XCUIApplication() + app.launch() + XCTAssertTrue(app.reachGuestHome()) + + // The "ENTREGAR EM:" address button re-opens the same address + // picker sheet we dismissed to reach Home - confirms the chip is + // wired to the real activeModal state, not just decorative. + let addressChip = app.buttons.matching(NSPredicate(format: "identifier == 'chevron.down'")).firstMatch + if addressChip.exists { + addressChip.tap() + } else { + // Fall back to tapping near the "ENTREGAR EM:" label's row. + app.staticTexts["ENTREGAR EM:"].tap() + } + XCTAssertTrue( + app.staticTexts["Escolha seu estado"].waitForExistence(timeout: 3) + || app.staticTexts["Escolha sua cidade"].waitForExistence(timeout: 3), + "Tapping the address chip did not reopen the location picker" + ) + } + + // No testSearchFieldAcceptsTextInput here: the guest address-picker + // sheet re-presents on an independent timer (see + // decisions/2026-08-06-ui-test-account-and-app-attest-bypass.md) and + // can land back on top between a tap and the following `typeText`, + // which XCTest treats as a fatal, non-catchable event-synthesis + // failure - unlike `.exists` checks, there is no reliable way to guard + // against it. Confirmed via accessibility-hierarchy dump that the sheet + // was back on top at the moment of failure. Dropped rather than + // papered over with more retries. +} diff --git a/PediFoodsUITests/OrderDetailsFlowTests.swift b/PediFoodsUITests/OrderDetailsFlowTests.swift new file mode 100644 index 0000000..74f8512 --- /dev/null +++ b/PediFoodsUITests/OrderDetailsFlowTests.swift @@ -0,0 +1,35 @@ +import XCTest + +/// Covers Orders -> Order Details, another big zero-coverage screen +/// (OrderDetailsView.swift, ~1100 lines). Relies on the standing QA +/// account's real order history - see +/// decisions/2026-08-06-ui-test-account-and-app-attest-bypass.md. +final class OrderDetailsFlowTests: XCTestCase { + override func setUpWithError() throws { + continueAfterFailure = false + } + + override func tearDownWithError() throws { + XCUIApplication().logoutIfAuthenticated() + } + + func testOrderDetailsReachableFromOrdersList() throws { + let app = XCUIApplication() + app.launch() + XCTAssertTrue(app.ensureLoggedIn(), "Login never completed") + XCTAssertTrue(app.buttons["Sair da Conta"].waitForExistence(timeout: 10), "Never reached authenticated Profile") + + app.staticTexts["Meus Pedidos"].tap() + XCTAssertTrue(app.staticTexts["Meus Pedidos"].waitForExistence(timeout: 10)) + + // "Ver Detalhes" appears once per order card; this account has real + // order history (confirmed in AuthenticatedProfileNavigationTests). + let detailsButton = app.buttons["Ver Detalhes"] + XCTAssertTrue(detailsButton.waitForExistence(timeout: 10), "No orders with a 'Ver Detalhes' action found") + detailsButton.tap() + + XCTAssertTrue(app.staticTexts["Detalhes do Pedido"].waitForExistence(timeout: 10), "Never reached Order Details") + XCTAssertTrue(app.staticTexts["Itens do Pedido"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.staticTexts["Resumo de Valores"].waitForExistence(timeout: 5)) + } +} diff --git a/PediFoodsUITests/PediFoodsUITestsLaunchTests.swift b/PediFoodsUITests/PediFoodsUITestsLaunchTests.swift new file mode 100644 index 0000000..74ef55c --- /dev/null +++ b/PediFoodsUITests/PediFoodsUITestsLaunchTests.swift @@ -0,0 +1,19 @@ +import XCTest + +final class PediFoodsUITestsLaunchTests: XCTestCase { + override class var runsForEachTargetApplicationUIConfiguration: Bool { true } + + override func setUpWithError() throws { + continueAfterFailure = false + } + + func testLaunch() throws { + let app = XCUIApplication() + app.launch() + + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "Launch Screen" + attachment.lifetime = .keepAlways + add(attachment) + } +} diff --git a/PediFoodsUITests/ProfileLoggedOutFlowTests.swift b/PediFoodsUITests/ProfileLoggedOutFlowTests.swift new file mode 100644 index 0000000..0f4f85d --- /dev/null +++ b/PediFoodsUITests/ProfileLoggedOutFlowTests.swift @@ -0,0 +1,37 @@ +import XCTest + +/// Covers the Profile tab's logged-out state (`ProfileLoggedOutView`) - +/// confirmed reachable. Actually logging in is blocked by a separate +/// navigation issue in LoginView's entry-reveal animation, documented in +/// decisions/2026-08-06-ui-test-account-and-app-attest-bypass.md - not +/// covered here. +final class ProfileLoggedOutFlowTests: XCTestCase { + override func setUpWithError() throws { + continueAfterFailure = false + } + + func testLoggedOutProfileShowsLoginPrompt() throws { + let app = XCUIApplication() + app.launch() + XCTAssertTrue(app.reachLoggedOutProfile(), "Never reached the logged-out profile screen") + + XCTAssertTrue(app.staticTexts["Entre na sua conta"].exists) + XCTAssertTrue(app.staticTexts["Faça login ou cadastre-se para ver seu perfil, pedidos e endereços."].exists) + XCTAssertTrue(app.buttons["Entrar ou Cadastrar"].isHittable) + } + + func testTappingLoginButtonDoesNotCrash() throws { + let app = XCUIApplication() + app.launch() + XCTAssertTrue(app.reachLoggedOutProfile()) + + app.buttons["Entrar ou Cadastrar"].tap() + sleep(2) + + // Known limitation (see decisions doc): the login screen's entry + // animation doesn't reliably reveal when navigating here from an + // already-running guest session, so this only asserts the app is + // still alive and responsive, not that login itself completes. + XCTAssertEqual(app.state, .runningForeground) + } +} diff --git a/PediFoodsUITests/SavedCardsFlowTests.swift b/PediFoodsUITests/SavedCardsFlowTests.swift new file mode 100644 index 0000000..3dd2b0f --- /dev/null +++ b/PediFoodsUITests/SavedCardsFlowTests.swift @@ -0,0 +1,29 @@ +import XCTest + +/// Covers Profile -> Meus Cartões -> Novo Cartão +/// (SavedCardsView.swift + AddCardFormView.swift, ~1170 lines on the form +/// alone, was 0% covered). Only reaches the form, doesn't submit a real +/// card. +final class SavedCardsFlowTests: XCTestCase { + override func setUpWithError() throws { + continueAfterFailure = false + } + + override func tearDownWithError() throws { + XCUIApplication().logoutIfAuthenticated() + } + + func testAddCardFormReachableFromSavedCards() throws { + let app = XCUIApplication() + app.launch() + XCTAssertTrue(app.ensureLoggedIn(), "Login never completed") + XCTAssertTrue(app.buttons["Sair da Conta"].waitForExistence(timeout: 10), "Never reached authenticated Profile") + + app.staticTexts["Meus Cartões"].tap() + XCTAssertTrue(app.staticTexts["Meus Cartões"].waitForExistence(timeout: 10), "Never reached Meus Cartões") + + app.staticTexts["Adicionar novo cartão"].tap() + XCTAssertTrue(app.staticTexts["Novo Cartão"].waitForExistence(timeout: 10), "Never reached Novo Cartão form") + XCTAssertTrue(app.staticTexts["Número do cartão"].waitForExistence(timeout: 5)) + } +} diff --git a/PediFoodsUITests/TestFixtures.swift b/PediFoodsUITests/TestFixtures.swift new file mode 100644 index 0000000..6d650ab --- /dev/null +++ b/PediFoodsUITests/TestFixtures.swift @@ -0,0 +1,12 @@ +import Foundation + +/// Standing QA account for UI tests that need an authenticated session. +/// Fixed OTP, no SMS round trip needed. See +/// decisions/2026-08-06-ui-test-account-and-app-attest-bypass.md for +/// context (this is separate from guest-session App Attest, which has no +/// known bypass yet). +enum UITestAccount { + static let email = "customer003@loverde.com.br" + static let phone = "19991673003" + static let otp = "66667777" +} diff --git a/PediFoodsUITests/UITestSupport.swift b/PediFoodsUITests/UITestSupport.swift new file mode 100644 index 0000000..52ff997 --- /dev/null +++ b/PediFoodsUITests/UITestSupport.swift @@ -0,0 +1,193 @@ +import XCTest + +extension XCUIApplication { + /// The guest address-picker sheet (`AddressPickerModalView` -> + /// `PublicLocationPickerView`) is a plain `.sheet`, so it's + /// swipe-dismissible - but `HomeView+Data.loadGuestStores()` + /// re-presents it every time Home reappears with no guest location + /// set (see decisions/2026-08-06-ui-test-account-and-app-attest-bypass.md), + /// so dismissing it is a race against that re-presentation. Retries a + /// swipe-down + Home-tab tap until either Home's search field is + /// reachable or the attempt budget runs out. + @discardableResult + func reachGuestHome(attempts: Int = 8) -> Bool { + let searchField = textFields["Search menu, restaurant or craving"] + + // The sheet can reappear a few hundred ms after Home first shows up, + // so a single `.exists` check can pass right before it gets covered + // again - re-check after a short settle delay to confirm it stuck. + func settled() -> Bool { + guard searchField.exists else { return false } + usleep(300_000) + return searchField.exists + } + + if settled() { return true } + + for _ in 1...attempts { + swipeDown() + usleep(300_000) + if searchField.waitForExistence(timeout: 1), settled() { + return true + } + } + return settled() + } + + /// Same race as `reachGuestHome`, but continuing on to tap the + /// Profile tab each attempt until the logged-out profile screen's + /// "Entrar ou Cadastrar" button appears. Only meaningful for a genuine + /// guest session - if a real login persisted (`TokenStore` is + /// Keychain-backed, survives app relaunches), this correctly never + /// succeeds, since it only recognizes the logged-out signal. Use + /// `reachProfileTabRegardlessOfAuthState` for authenticated-flow tests. + @discardableResult + func reachLoggedOutProfile(attempts: Int = 8) -> Bool { + let loginButton = buttons["Entrar ou Cadastrar"] + if loginButton.exists { return true } + + for _ in 1...attempts { + swipeDown() + usleep(300_000) + let profileTab = buttons.matching(identifier: "person.fill").element(boundBy: 1) + if profileTab.exists { + profileTab.tap() + } + usleep(500_000) + if loginButton.exists { + return true + } + } + return loginButton.exists + } + + /// Same swipe-dismiss-then-tap race as `reachLoggedOutProfile`, but + /// recognizes *either* the logged-out ("Entrar ou Cadastrar") or + /// authenticated ("Sair da Conta") signal as success. Needed because + /// an authenticated account with no saved address hits the exact same + /// re-presenting-sheet race as guests (`HomeView`'s "no address" branch + /// re-shows the address picker on every Home reappearance), just + /// rendering `AddressesView` (a real, scrollable address list) instead + /// of `PublicLocationPickerView` (short, static content). `swipeDown()` + /// alone isn't reliable there - a real `List`/`ScrollView` can consume + /// the drag gesture as a scroll instead of a dismiss, unlike the + /// guest picker's static content - so this taps the modal's own + /// "Back" button (`.appInlineNavigationTitle()`'s `dismiss()`-backed + /// button, present on both variants) first, falling back to + /// `swipeDown()` only if that button isn't there. + @discardableResult + func reachProfileTabRegardlessOfAuthState(attempts: Int = 20) -> Bool { + let loginButton = buttons["Entrar ou Cadastrar"] + let logoutButton = buttons["Sair da Conta"] + if loginButton.exists || logoutButton.exists { return true } + + for _ in 1...attempts { + // Exact-match subscript crashes ("multiple matching elements") + // if more than one "Back" button is on screen at once (e.g. a + // pushed detail screen's own back button plus the address + // picker's) - firstMatch tolerates any count. + // .isHittable, not just .exists: tapping a Back button that + // exists but is mid-transition (e.g. multiple pushed screens + // deep, like Profile -> Meus Cartões -> Novo Cartão) throws a + // fatal, uncatchable "not hittable" test failure rather than + // just failing gracefully - confirmed via a real teardown run. + let backButton = buttons.matching(NSPredicate(format: "label == %@", "Back")).firstMatch + if backButton.exists, backButton.isHittable { + backButton.tap() + } else { + swipeDown() + } + usleep(300_000) + // Unlike reachLoggedOutProfile, this must also work once + // authenticated - Home's header avatar shows the account's + // real profile picture instead of a generic "person.fill" icon + // once one is set, dropping the match count from 2 to 1 and + // making a fixed index resolve to nothing. `.last` tolerates + // either count (confirmed via screenshot: a real authenticated + // account with a profile picture only ever produces the tab + // bar's match). + if let profileTab = buttons.matching(identifier: "person.fill").allElementsBoundByIndex.last, + profileTab.exists { + profileTab.tap() + } + usleep(500_000) + if loginButton.exists || logoutButton.exists { + return true + } + } + return loginButton.exists || logoutButton.exists + } + + // MARK: - Authenticated-flow helpers + + /// Real end-to-end login with the standing QA account (see + /// TestFixtures.swift / decisions/2026-08-06-ui-test-account-and-app-attest-bypass.md). + /// The OTP field sets `.textContentType(.oneTimeCode)` + /// (`appOTPKeyboard()`, `TextFieldCompat.swift`) - the iOS system code + /// content type - which two different input methods failed against in + /// this environment: `.typeText()` delivered zero characters (keyboard + /// showed, field stayed empty), and `UIPasteboard` + the app's own + /// "Colar código" paste button delivered one stray, unrelated digit + /// (likely Simulator's host-Mac pasteboard sync racing the write). + /// Fix: tap the real on-screen numeric keypad's digit keys + /// (`app.keys[...]`) directly instead of any text-injection path - + /// confirmed reliable end to end (real OTP validation, real routing). + @discardableResult + func ensureLoggedIn( + email: String = UITestAccount.email, + phone: String = UITestAccount.phone, + otp: String = UITestAccount.otp, + timeout: TimeInterval = 30 + ) -> Bool { + guard reachProfileTabRegardlessOfAuthState() else { return false } + + if buttons["Sair da Conta"].exists { + return true + } + + buttons["Entrar ou Cadastrar"].tap() + + let entrarButton = buttons["ENTRAR"] + guard entrarButton.waitForExistence(timeout: 5) else { return false } + entrarButton.tap() + + let emailField = textFields["seu@email.com"] + guard emailField.waitForExistence(timeout: 5) else { return false } + emailField.tap() + emailField.typeText(email) + + let phoneField = textFields["(00) 00000-0000"] + phoneField.tap() + phoneField.typeText(phone) + + buttons["Receber Código"].tap() + + // The real "request OTP" backend call has been observed taking + // longer than 15s under load - confirmed via trace (waited the + // full 15s, "Verificação" never appeared, not a broken selector). + guard staticTexts["Verificação"].waitForExistence(timeout: 25) else { return false } + textFields.firstMatch.tap() + guard keys["6"].waitForExistence(timeout: 5) else { return false } + for digit in otp { + keys[String(digit)].tap() + } + + return buttons["Home"].waitForExistence(timeout: timeout) + } + + /// Counterpart to `ensureLoggedIn` - call from `tearDown()` in any test + /// that logged in, so guest-only tests elsewhere in the same run don't + /// inherit an authenticated Keychain session (`TokenStore` persists + /// across app relaunches). No-op if already logged out. + @discardableResult + func logoutIfAuthenticated() -> Bool { + guard reachProfileTabRegardlessOfAuthState() else { return false } + guard buttons["Sair da Conta"].exists else { return true } + + buttons["Sair da Conta"].tap() + let confirm = buttons["Sair"] + guard confirm.waitForExistence(timeout: 3) else { return false } + confirm.tap() + return buttons["Entrar ou Cadastrar"].waitForExistence(timeout: 5) + } +} diff --git a/PediFoodsUITests/UserProfileFlowTests.swift b/PediFoodsUITests/UserProfileFlowTests.swift new file mode 100644 index 0000000..126afa6 --- /dev/null +++ b/PediFoodsUITests/UserProfileFlowTests.swift @@ -0,0 +1,24 @@ +import XCTest + +/// Covers Profile -> Meu Perfil (UserProfileView.swift, ~750 lines, was +/// 0.8% covered), reachable via the header's "Ver Perfil" link. +final class UserProfileFlowTests: XCTestCase { + override func setUpWithError() throws { + continueAfterFailure = false + } + + override func tearDownWithError() throws { + XCUIApplication().logoutIfAuthenticated() + } + + func testUserProfileReachableFromProfileHeader() throws { + let app = XCUIApplication() + app.launch() + XCTAssertTrue(app.ensureLoggedIn(), "Login never completed") + XCTAssertTrue(app.buttons["Sair da Conta"].waitForExistence(timeout: 10), "Never reached authenticated Profile") + + app.staticTexts["Ver Perfil"].tap() + + XCTAssertTrue(app.staticTexts["Meu Perfil"].waitForExistence(timeout: 10), "Never reached Meu Perfil") + } +} diff --git a/README.md b/README.md index c663e6a..b97da2c 100644 --- a/README.md +++ b/README.md @@ -1,62 +1,20 @@ -# PediFoods -This is a [Skip](https://skip.tools) dual-platform app project. +![](loverde_company_logo_full.png) +Pedi Foods +---- + +This is a private repository. +[Read the license](LICENSE.md) - +Any question or doubts, please send thru email - - -## Building - -This project is both a stand-alone Swift Package Manager module, -as well as an Xcode project that builds and translates the project -into a Kotlin Gradle project for Android using the skipstone plugin. - -Building the module requires that Skip be installed using -[Homebrew](https://brew.sh) with `brew install skiptools/skip/skip`. - -This will also install the necessary Skip prerequisites: -Kotlin, Gradle, and the Android build tools. - -Installation prerequisites can be confirmed by running -`skip checkup`. The project can be validated with `skip verify`. - -## Running - -Xcode and Android Studio must be downloaded and installed in order to -run the app in the iOS simulator / Android emulator. -An Android emulator must already be running, which can be launched from -Android Studio's Device Manager. - -The project can be opened and run in Xcode from -`Project.xcworkspace`, which also enabled parallel -development of any Skip libary dependencies. - -To run both the Swift and Kotlin apps simultaneously, -launch the "PediFoods App" target from Xcode. -A build phases runs the "Launch Android APK" script that -will deploy the Skip app to a running Android emulator or connected device. -Logging output for the iOS app can be viewed in the Xcode console, and in -Android Studio's logcat tab for the transpiled Kotlin app, or -using `adb logcat` from a terminal. - -## Testing - -The module can be tested using the standard `swift test` command -or by running the test target for the macOS destination in Xcode, -which will run the Swift tests as well as the transpiled -Kotlin JUnit tests in the Robolectric Android simulation environment. - -Parity testing can be performed with `skip test`, -which will output a table of the test results for both platforms. +Daniel Arantes Loverde - +Clarice Moro Arantes Loverde - +Gustavo Moro Arantes Loverde - +Mariana Moro Arantes Loverde - Autor: Daniel Arantes Loverde diff --git a/Skip.env b/Skip.env deleted file mode 100644 index 0ea893e..0000000 --- a/Skip.env +++ /dev/null @@ -1,26 +0,0 @@ -// The configuration file for your Skip App (https://skip.tools). -// Properties specified here are shared between -// Darwin/PediFoods.xcconfig and Android/settings.gradle.kts -// and will be included in the app's metadata files -// Info.plist and AndroidManifest.xml - -// PRODUCT_NAME is the default title of the app, which must match the app's Swift module name -PRODUCT_NAME = PediFoods - -// PRODUCT_BUNDLE_IDENTIFIER is the unique id for both the iOS and Android app -PRODUCT_BUNDLE_IDENTIFIER = com.br.pedifoods.app - -// The semantic version of the app -MARKETING_VERSION = 0.0.1 - -// The build number specifying the internal app version -CURRENT_PROJECT_VERSION = 1 - -// The package name for the Android entry point, referenced by the AndroidManifest.xml -ANDROID_PACKAGE_NAME = pedi.foods - -// If your Android appId is different from the iOS Bundle Identifer, specify it here -// ANDROID_APPLICATION_ID = com.br.pedifoods.app - -// Atomenta endpoint -ATOMENTA_API_URL = "https://atomenta.com.br" diff --git a/Sources/PediFoods/PediFoodsApp.swift b/Sources/PediFoods/PediFoodsApp.swift deleted file mode 100644 index 9b2eeea..0000000 --- a/Sources/PediFoods/PediFoodsApp.swift +++ /dev/null @@ -1,30 +0,0 @@ -import Foundation -import OSLog -import SwiftUI - -let logger: Logger = Logger(subsystem: "com.br.pedifoods.app", category: "PediFoods") - -public struct PediFoodsRootView: View { - public init() {} - - public var body: some View { - ContentView() - } -} - -public final class PediFoodsAppDelegate: Sendable { - public static let shared = PediFoodsAppDelegate() - - private init() {} - - public func onInit() { logger.debug("onInit") } - public func onLaunch() { logger.debug("onLaunch") } - public func onResume() { - logger.debug("onResume") - NotificationCenter.default.post(name: .appDidResume, object: nil) - } - public func onPause() { logger.debug("onPause") } - public func onStop() { logger.debug("onStop") } - public func onDestroy() { logger.debug("onDestroy") } - public func onLowMemory() { logger.debug("onLowMemory") } -} diff --git a/Sources/PediFoods/Resources/Module.xcassets/Contents.json b/Sources/PediFoods/Resources/Module.xcassets/Contents.json deleted file mode 100644 index 73c0059..0000000 --- a/Sources/PediFoods/Resources/Module.xcassets/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Sources/PediFoods/Resources/alelocard_logo.png b/Sources/PediFoods/Resources/alelocard_logo.png deleted file mode 100644 index 9201194..0000000 Binary files a/Sources/PediFoods/Resources/alelocard_logo.png and /dev/null differ diff --git a/Sources/PediFoods/Resources/amexcard_logo.png b/Sources/PediFoods/Resources/amexcard_logo.png deleted file mode 100644 index 42edc89..0000000 Binary files a/Sources/PediFoods/Resources/amexcard_logo.png and /dev/null differ diff --git a/Sources/PediFoods/Resources/hipercard_logo.png b/Sources/PediFoods/Resources/hipercard_logo.png deleted file mode 100644 index a184c60..0000000 Binary files a/Sources/PediFoods/Resources/hipercard_logo.png and /dev/null differ diff --git a/Sources/PediFoods/Resources/icon_pix.png b/Sources/PediFoods/Resources/icon_pix.png deleted file mode 100644 index d3d4824..0000000 Binary files a/Sources/PediFoods/Resources/icon_pix.png and /dev/null differ diff --git a/Sources/PediFoods/Resources/login_dark.png b/Sources/PediFoods/Resources/login_dark.png deleted file mode 100644 index bf637d4..0000000 Binary files a/Sources/PediFoods/Resources/login_dark.png and /dev/null differ diff --git a/Sources/PediFoods/Resources/login_light.png b/Sources/PediFoods/Resources/login_light.png deleted file mode 100644 index e7c5b09..0000000 Binary files a/Sources/PediFoods/Resources/login_light.png and /dev/null differ diff --git a/Sources/PediFoods/Resources/mastercard_logo.png b/Sources/PediFoods/Resources/mastercard_logo.png deleted file mode 100644 index fefd01b..0000000 Binary files a/Sources/PediFoods/Resources/mastercard_logo.png and /dev/null differ diff --git a/Sources/PediFoods/Resources/pedifoods.icon/Assets/Screenshot 2026-04-25 at 16.22.36.png b/Sources/PediFoods/Resources/pedifoods.icon/Assets/Screenshot 2026-04-25 at 16.22.36.png deleted file mode 100644 index 3fefb29..0000000 Binary files a/Sources/PediFoods/Resources/pedifoods.icon/Assets/Screenshot 2026-04-25 at 16.22.36.png and /dev/null differ diff --git a/Sources/PediFoods/Resources/pedifoods.icon/Assets/Screenshot 2026-04-25 at 16.22.59.png b/Sources/PediFoods/Resources/pedifoods.icon/Assets/Screenshot 2026-04-25 at 16.22.59.png deleted file mode 100644 index be0cb55..0000000 Binary files a/Sources/PediFoods/Resources/pedifoods.icon/Assets/Screenshot 2026-04-25 at 16.22.59.png and /dev/null differ diff --git a/Sources/PediFoods/Resources/pedifoods.icon/icon.json b/Sources/PediFoods/Resources/pedifoods.icon/icon.json deleted file mode 100644 index 86b7994..0000000 --- a/Sources/PediFoods/Resources/pedifoods.icon/icon.json +++ /dev/null @@ -1,136 +0,0 @@ -{ - "fill-specializations" : [ - { - "value" : { - "automatic-gradient" : "srgb:0.99020,0.98039,1.00000,1.00000" - } - }, - { - "appearance" : "dark", - "value" : { - "solid" : "srgb:0.00000,0.00000,0.00000,1.00000" - } - } - ], - "groups" : [ - { - "hidden" : false, - "layers" : [ - { - "glass-specializations" : [ - { - "appearance" : "dark", - "value" : true - } - ], - "hidden" : false, - "image-name" : "Screenshot 2026-04-25 at 16.22.36.png", - "name" : "Screenshot 2026-04-25 at 16.22.36", - "opacity-specializations" : [ - { - "value" : 0 - }, - { - "appearance" : "dark", - "value" : 1 - } - ], - "position-specializations" : [ - { - "idiom" : "iOS", - "value" : { - "scale" : 1.24, - "translation-in-points" : [ - 0, - 0 - ] - } - }, - { - "idiom" : "watchOS", - "value" : { - "scale" : 1.54, - "translation-in-points" : [ - 0, - 0 - ] - } - } - ] - }, - { - "hidden" : false, - "image-name" : "Screenshot 2026-04-25 at 16.22.59.png", - "name" : "Screenshot 2026-04-25 at 16.22.59", - "position-specializations" : [ - { - "idiom" : "iOS", - "value" : { - "scale" : 1.24, - "translation-in-points" : [ - 0, - 0 - ] - } - }, - { - "idiom" : "watchOS", - "value" : { - "scale" : 1.54, - "translation-in-points" : [ - 0, - 0 - ] - } - } - ] - } - ], - "opacity-specializations" : [ - { - "appearance" : "dark", - "value" : 1 - } - ], - "shadow" : { - "kind" : "neutral", - "opacity" : 0.5 - }, - "translucency" : { - "enabled" : true, - "value" : 0.5 - } - }, - { - "blend-mode" : "normal", - "hidden-specializations" : [ - { - "value" : false - }, - { - "idiom" : "iOS", - "value" : false - } - ], - "layers" : [ - - ], - "shadow" : { - "kind" : "neutral", - "opacity" : 0.5 - }, - "translucency" : { - "enabled" : true, - "value" : 0.5 - } - } - ], - "supported-platforms" : { - "circles" : [ - "watchOS" - ], - "squares" : [ - "iOS" - ] - } -} \ No newline at end of file diff --git a/Sources/PediFoods/Resources/pedifoods.png b/Sources/PediFoods/Resources/pedifoods.png deleted file mode 100644 index a94a4ff..0000000 Binary files a/Sources/PediFoods/Resources/pedifoods.png and /dev/null differ diff --git a/Sources/PediFoods/Resources/pedifoods_dark.png b/Sources/PediFoods/Resources/pedifoods_dark.png deleted file mode 100644 index a94a4ff..0000000 Binary files a/Sources/PediFoods/Resources/pedifoods_dark.png and /dev/null differ diff --git a/Sources/PediFoods/Resources/pin_image_app.png b/Sources/PediFoods/Resources/pin_image_app.png deleted file mode 100644 index 7ed510e..0000000 Binary files a/Sources/PediFoods/Resources/pin_image_app.png and /dev/null differ diff --git a/Sources/PediFoods/Resources/pin_image_app_dark.png b/Sources/PediFoods/Resources/pin_image_app_dark.png deleted file mode 100644 index 450d7bc..0000000 Binary files a/Sources/PediFoods/Resources/pin_image_app_dark.png and /dev/null differ diff --git a/Sources/PediFoods/Resources/placeholder-pizza.png b/Sources/PediFoods/Resources/placeholder-pizza.png deleted file mode 100644 index e4072c0..0000000 Binary files a/Sources/PediFoods/Resources/placeholder-pizza.png and /dev/null differ diff --git a/Sources/PediFoods/Resources/placeholder-product.png b/Sources/PediFoods/Resources/placeholder-product.png deleted file mode 100644 index 889fa6c..0000000 Binary files a/Sources/PediFoods/Resources/placeholder-product.png and /dev/null differ diff --git a/Sources/PediFoods/Resources/sodexo_logo.png b/Sources/PediFoods/Resources/sodexo_logo.png deleted file mode 100644 index 1aefb69..0000000 Binary files a/Sources/PediFoods/Resources/sodexo_logo.png and /dev/null differ diff --git a/Sources/PediFoods/Resources/tracking-canceled.png b/Sources/PediFoods/Resources/tracking-canceled.png deleted file mode 100644 index 6b0acbc..0000000 Binary files a/Sources/PediFoods/Resources/tracking-canceled.png and /dev/null differ diff --git a/Sources/PediFoods/Resources/tracking-completed.png b/Sources/PediFoods/Resources/tracking-completed.png deleted file mode 100644 index fee3d7f..0000000 Binary files a/Sources/PediFoods/Resources/tracking-completed.png and /dev/null differ diff --git a/Sources/PediFoods/Resources/tracking-delivering.png b/Sources/PediFoods/Resources/tracking-delivering.png deleted file mode 100644 index 2d996ae..0000000 Binary files a/Sources/PediFoods/Resources/tracking-delivering.png and /dev/null differ diff --git a/Sources/PediFoods/Resources/tracking-pending.png b/Sources/PediFoods/Resources/tracking-pending.png deleted file mode 100644 index a4a9344..0000000 Binary files a/Sources/PediFoods/Resources/tracking-pending.png and /dev/null differ diff --git a/Sources/PediFoods/Resources/tracking-preparing.png b/Sources/PediFoods/Resources/tracking-preparing.png deleted file mode 100644 index 23fc9a7..0000000 Binary files a/Sources/PediFoods/Resources/tracking-preparing.png and /dev/null differ diff --git a/Sources/PediFoods/Resources/tracking-ready.png b/Sources/PediFoods/Resources/tracking-ready.png deleted file mode 100644 index fe2f0f2..0000000 Binary files a/Sources/PediFoods/Resources/tracking-ready.png and /dev/null differ diff --git a/Sources/PediFoods/Resources/visacard_logo.png b/Sources/PediFoods/Resources/visacard_logo.png deleted file mode 100644 index 279c267..0000000 Binary files a/Sources/PediFoods/Resources/visacard_logo.png and /dev/null differ diff --git a/bitrise.yml b/bitrise.yml index 4475795..190b437 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -10,10 +10,10 @@ meta: app: envs: - TEST_SHARD_COUNT: 2 - - BITRISE_PROJECT_PATH: Darwin/PediFoods.xcodeproj + - BITRISE_PROJECT_PATH: PediFoods.xcodeproj opts: is_expand: false - - BITRISE_SCHEME: PediFoods App + - BITRISE_SCHEME: PediFoods opts: is_expand: false - BITRISE_DISTRIBUTION_METHOD: app-store @@ -103,7 +103,7 @@ workflows: title: Set build number inputs: - content: | - cd $BITRISE_SOURCE_DIR/Darwin + cd $BITRISE_SOURCE_DIR agvtool new-version -all $BITRISE_BUILD_NUMBER - xcode-archive@5: inputs: @@ -147,7 +147,7 @@ workflows: title: Set build number inputs: - content: | - cd $BITRISE_SOURCE_DIR/Darwin + cd $BITRISE_SOURCE_DIR agvtool new-version -all $BITRISE_BUILD_NUMBER - xcode-archive@5: inputs: diff --git a/Darwin/fastlane/AppStore.xcconfig b/fastlane/AppStore.xcconfig similarity index 55% rename from Darwin/fastlane/AppStore.xcconfig rename to fastlane/AppStore.xcconfig index 2dd4ae8..df61c35 100644 --- a/Darwin/fastlane/AppStore.xcconfig +++ b/fastlane/AppStore.xcconfig @@ -1,5 +1,5 @@ // Additional properties included by the Fastfile build_app -// This file can be used to override various properties from Skip.env +// This file can be used to override various properties from the project's build settings //PRODUCT_BUNDLE_IDENTIFIER = //DEVELOPMENT_TEAM = diff --git a/Darwin/fastlane/Appfile b/fastlane/Appfile similarity index 59% rename from Darwin/fastlane/Appfile rename to fastlane/Appfile index 5c8d2ba..dd3e1b3 100644 --- a/Darwin/fastlane/Appfile +++ b/fastlane/Appfile @@ -1,9 +1,7 @@ # For more information about the Appfile, see: # https://docs.fastlane.tools/advanced/#appfile -require('dotenv') -Dotenv.load '../../Skip.env' -app_identifier(ENV['ANDROID_APPLICATION_ID'] || ENV['PRODUCT_BUNDLE_IDENTIFIER']) +app_identifier("com.br.pedifoods.app") apple_id ENV["APPLE_ID"] itc_team_id ENV["ITC_TEAM_ID"] diff --git a/Darwin/fastlane/Deliverfile b/fastlane/Deliverfile similarity index 100% rename from Darwin/fastlane/Deliverfile rename to fastlane/Deliverfile diff --git a/Darwin/fastlane/Fastfile b/fastlane/Fastfile similarity index 68% rename from Darwin/fastlane/Fastfile rename to fastlane/Fastfile index 6fda24a..577faaf 100644 --- a/Darwin/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -1,35 +1,25 @@ -# This file contains the fastlane.tools configuration -# for the iOS half of the Skip app. +# This file contains the fastlane.tools configuration for the PediFoods iOS app. # You can find the documentation at https://docs.fastlane.tools default_platform(:ios) lane :assemble do |options| - # only build the iOS side of the app - ENV["SKIP_ZERO"] = "true" - - # Manual signing (team/identity/profile) for the "PediFoods App" target is + # Manual signing (team/identity/profile) for the "PediFoods" target is # baked directly into PediFoods.xcodeproj's own project settings - not - # overridden here. A command-line xcargs/update_code_signing_settings - # override applies to the entire build graph, including the SPM package's - # own ephemeral targets (PediFoods, pedi-foods_PediFoods) which explicitly - # reject provisioning profiles and must stay Automatic; there's no way to - # scope a global override to just one target, and update_code_signing_settings - # itself can't parse this project's newer .pbxproj format anyway (gem - # limitation, not fixable by updating the gem). + # overridden here. # agvtool needs VERSIONING_SYSTEM = apple-generic, which this project doesn't # set, so it silently no-ops - pass CURRENT_PROJECT_VERSION directly instead. xcargs = "-skipPackagePluginValidation -skipMacroValidation" xcargs += " CURRENT_PROJECT_VERSION=#{ENV['BUILD_NUMBER']}" if ENV["BUILD_NUMBER"] build_app( - scheme: "PediFoods App", + scheme: "PediFoods", sdk: "iphoneos", export_method: ENV["BITRISE_DISTRIBUTION_METHOD"] || "app-store", xcconfig: "fastlane/AppStore.xcconfig", xcargs: xcargs, - derived_data_path: "../.build/Darwin/DerivedData", - output_directory: "../.build/fastlane/Darwin", + derived_data_path: ".build/DerivedData", + output_directory: ".build/fastlane", skip_archive: ENV["FASTLANE_SKIP_ARCHIVE"] == "YES", skip_codesigning: ENV["FASTLANE_SKIP_CODESIGNING"] == "YES" ) diff --git a/Darwin/fastlane/metadata/app_privacy_details.json b/fastlane/metadata/app_privacy_details.json similarity index 100% rename from Darwin/fastlane/metadata/app_privacy_details.json rename to fastlane/metadata/app_privacy_details.json diff --git a/Darwin/fastlane/metadata/en-US/description.txt b/fastlane/metadata/en-US/description.txt similarity index 100% rename from Darwin/fastlane/metadata/en-US/description.txt rename to fastlane/metadata/en-US/description.txt diff --git a/Darwin/fastlane/metadata/en-US/keywords.txt b/fastlane/metadata/en-US/keywords.txt similarity index 100% rename from Darwin/fastlane/metadata/en-US/keywords.txt rename to fastlane/metadata/en-US/keywords.txt diff --git a/Darwin/fastlane/metadata/en-US/privacy_url.txt b/fastlane/metadata/en-US/privacy_url.txt similarity index 100% rename from Darwin/fastlane/metadata/en-US/privacy_url.txt rename to fastlane/metadata/en-US/privacy_url.txt diff --git a/Darwin/fastlane/metadata/en-US/release_notes.txt b/fastlane/metadata/en-US/release_notes.txt similarity index 100% rename from Darwin/fastlane/metadata/en-US/release_notes.txt rename to fastlane/metadata/en-US/release_notes.txt diff --git a/Darwin/fastlane/metadata/en-US/software_url.txt b/fastlane/metadata/en-US/software_url.txt similarity index 100% rename from Darwin/fastlane/metadata/en-US/software_url.txt rename to fastlane/metadata/en-US/software_url.txt diff --git a/Darwin/fastlane/metadata/en-US/subtitle.txt b/fastlane/metadata/en-US/subtitle.txt similarity index 100% rename from Darwin/fastlane/metadata/en-US/subtitle.txt rename to fastlane/metadata/en-US/subtitle.txt diff --git a/Darwin/fastlane/metadata/en-US/support_url.txt b/fastlane/metadata/en-US/support_url.txt similarity index 100% rename from Darwin/fastlane/metadata/en-US/support_url.txt rename to fastlane/metadata/en-US/support_url.txt diff --git a/Darwin/fastlane/metadata/en-US/title.txt b/fastlane/metadata/en-US/title.txt similarity index 100% rename from Darwin/fastlane/metadata/en-US/title.txt rename to fastlane/metadata/en-US/title.txt diff --git a/Darwin/fastlane/metadata/en-US/version_whats_new.txt b/fastlane/metadata/en-US/version_whats_new.txt similarity index 100% rename from Darwin/fastlane/metadata/en-US/version_whats_new.txt rename to fastlane/metadata/en-US/version_whats_new.txt diff --git a/Darwin/fastlane/metadata/rating.json b/fastlane/metadata/rating.json similarity index 100% rename from Darwin/fastlane/metadata/rating.json rename to fastlane/metadata/rating.json diff --git a/loverde_company_logo_full.png b/loverde_company_logo_full.png new file mode 100755 index 0000000..6a3c114 Binary files /dev/null and b/loverde_company_logo_full.png differ diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..7aecccb --- /dev/null +++ b/project.yml @@ -0,0 +1,133 @@ +name: PediFoods +options: + bundleIdPrefix: com.br.pedifoods + deploymentTarget: + iOS: "17.0" + createIntermediateGroups: true + +packages: + LCEssentials: + url: https://git.loverde.com.br/Loverde-Company-LTDA/LCEssentials.git + from: 1.0.11 + +targets: + PediFoods: + type: application + platform: iOS + sources: + - path: PediFoods + dependencies: + - target: NotificationServiceExtension + embed: true + - package: LCEssentials + product: LCEssentials + settings: + base: + PRODUCT_NAME: PediFoods + PRODUCT_BUNDLE_IDENTIFIER: com.br.pedifoods.app + MARKETING_VERSION: 0.0.1 + CURRENT_PROJECT_VERSION: 1 + INFOPLIST_FILE: PediFoods/Info.plist + GENERATE_INFOPLIST_FILE: YES + CODE_SIGN_ENTITLEMENTS: PediFoods/PediFoods.entitlements + CODE_SIGN_STYLE: Manual + SWIFT_VERSION: "6" + TARGETED_DEVICE_FAMILY: "1" + SUPPORTED_PLATFORMS: "iphoneos iphonesimulator" + SUPPORTS_MACCATALYST: NO + ENABLE_PREVIEWS: YES + LOCALIZATION_PREFERS_STRING_CATALOGS: YES + SWIFT_EMIT_LOC_STRINGS: YES + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS: YES + INFOPLIST_KEY_CFBundleDisplayName: "Pedi Foods" + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription: "PediFoods uses your location to show nearby stores and estimate delivery times." + INFOPLIST_KEY_UISupportedInterfaceOrientations: UIInterfaceOrientationPortrait + INFOPLIST_KEY_UIApplicationSceneManifest_Generation: YES + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES + INFOPLIST_KEY_UILaunchScreen_Generation: NO + INFOPLIST_KEY_UILaunchStoryboardName: LaunchScreen + INFOPLIST_KEY_UIStatusBarStyle: UIStatusBarStyleDefault + configs: + Debug: + DEVELOPMENT_TEAM: "" + "DEVELOPMENT_TEAM[sdk=iphoneos*]": K4E5BZMM4V + PROVISIONING_PROFILE_SPECIFIER: "" + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]": "PediFoods Dev Prov Prof" + Release: + DEVELOPMENT_TEAM: K4E5BZMM4V + "DEVELOPMENT_TEAM[sdk=iphoneos*]": K4E5BZMM4V + CODE_SIGN_IDENTITY: "Apple Distribution" + "CODE_SIGN_IDENTITY[sdk=iphoneos*]": "iPhone Distribution" + PROVISIONING_PROFILE_SPECIFIER: "com.br.pedifoods.app AppStore" + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]": "PediFoods Dist XC Prov Prof" + attributes: + SystemCapabilities: + com.apple.Push: + enabled: true + + NotificationServiceExtension: + type: app-extension + platform: iOS + sources: + - path: NotificationServiceExtension + settings: + base: + PRODUCT_NAME: NotificationService + PRODUCT_BUNDLE_IDENTIFIER: com.br.pedifoods.app.NotificationService + MARKETING_VERSION: 0.0.1 + CURRENT_PROJECT_VERSION: 1 + INFOPLIST_FILE: NotificationServiceExtension/Info.plist + CODE_SIGN_STYLE: Automatic + DEVELOPMENT_TEAM: K4E5BZMM4V + SWIFT_VERSION: "6" + TARGETED_DEVICE_FAMILY: "1" + SKIP_INSTALL: YES + CLANG_ENABLE_OBJC_WEAK: NO + + PediFoodsTests: + type: bundle.unit-test + platform: iOS + sources: + - path: PediFoodsTests + dependencies: + - target: PediFoods + settings: + base: + SWIFT_VERSION: "6" + GENERATE_INFOPLIST_FILE: YES + + PediFoodsUITests: + type: bundle.ui-testing + platform: iOS + sources: + - path: PediFoodsUITests + dependencies: + - target: PediFoods + settings: + base: + SWIFT_VERSION: "6" + GENERATE_INFOPLIST_FILE: YES + +schemes: + PediFoods: + build: + targets: + PediFoods: all + PediFoodsTests: [test] + PediFoodsUITests: [test] + run: + config: Debug + test: + config: Debug + targets: + - PediFoodsTests + - PediFoodsUITests + profile: + config: Release + analyze: + config: Debug + archive: + config: Release diff --git a/scripts/android-run.sh b/scripts/android-run.sh deleted file mode 100755 index 0fda50f..0000000 --- a/scripts/android-run.sh +++ /dev/null @@ -1,424 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)" -ANDROID_SDK_ROOT_DEFAULT="$HOME/Library/Android/sdk" -EMULATOR_BIN_DEFAULT="$ANDROID_SDK_ROOT_DEFAULT/emulator/emulator" -ADB_BIN_DEFAULT="$ANDROID_SDK_ROOT_DEFAULT/platform-tools/adb" - -AVD_NAME="${1:-auto}" -BOOT_TIMEOUT_SECONDS="${BOOT_TIMEOUT_SECONDS:-120}" -ADB_CONNECT_TIMEOUT_SECONDS="${ADB_CONNECT_TIMEOUT_SECONDS:-90}" -APP_ID_DEFAULT="com.br.pedifoods.app" -REQUIRED_BRIDGE_SO="${REQUIRED_BRIDGE_SO:-libPediFoods.so}" -SKIP_EXPORT_ARCH="${SKIP_EXPORT_ARCH:-aarch64}" -START_EMULATOR_WHEN_NO_DEVICE="${START_EMULATOR_WHEN_NO_DEVICE:-0}" -EMULATOR_HEADLESS="${EMULATOR_HEADLESS:-1}" -EMULATOR_MEMORY_MB="${EMULATOR_MEMORY_MB:-1536}" - -if [[ -n "${ANDROID_SDK_ROOT:-}" ]]; then - EMULATOR_BIN="$ANDROID_SDK_ROOT/emulator/emulator" - ADB_BIN="$ANDROID_SDK_ROOT/platform-tools/adb" -else - EMULATOR_BIN="$EMULATOR_BIN_DEFAULT" - ADB_BIN="$ADB_BIN_DEFAULT" -fi - -ANDROID_HOME="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$ANDROID_SDK_ROOT_DEFAULT}}" -ANDROID_SDK_ROOT="${ANDROID_SDK_ROOT:-$ANDROID_HOME}" -export ANDROID_HOME -export ANDROID_SDK_ROOT - -if [[ ! -x "$EMULATOR_BIN" ]]; then - echo "Erro: emulator não encontrado em '$EMULATOR_BIN'" - exit 1 -fi - -if [[ ! -x "$ADB_BIN" ]]; then - echo "Erro: adb não encontrado em '$ADB_BIN'" - exit 1 -fi - -if ! command -v skip >/dev/null 2>&1; then - echo "Erro: comando 'skip' não encontrado no PATH" - exit 1 -fi - -TARGET_SERIAL="" -EMULATOR_PID="" - -APP_ID="$(sed -nE 's/^[[:space:]]*PRODUCT_BUNDLE_IDENTIFIER[[:space:]]*=[[:space:]]*([^[:space:]]+).*/\1/p' "$PROJECT_DIR/Skip.env" | head -n1)" -APP_ID="${APP_ID:-$APP_ID_DEFAULT}" -REQUIRE_FRESH_APK="${REQUIRE_FRESH_APK:-1}" -ALLOW_INSTALLED_FALLBACK="${ALLOW_INSTALLED_FALLBACK:-1}" -RUN_STABILITY_SECONDS="${RUN_STABILITY_SECONDS:-8}" - -repair_skip_packages_links() { - local swift_dir="$PROJECT_DIR/.build/plugins/outputs/pedi-foods/PediFoods/destination/skipstone/PediFoods/src/main/swift" - local packages_dir="$swift_dir/Packages" - if [[ ! -d "$packages_dir" ]]; then - return 0 - fi - - for link_path in "$packages_dir"/*; do - [[ -e "$link_path" || -L "$link_path" ]] || continue - local pkg_name - pkg_name="$(basename "$link_path")" - local pkg_manifest="$link_path/Package.swift" - if [[ -f "$pkg_manifest" ]]; then - continue - fi - - local checkout_path="$PROJECT_DIR/.build/checkouts/$pkg_name" - if [[ -f "$checkout_path/Package.swift" ]]; then - echo "Reparando link de pacote: $pkg_name -> $checkout_path" - rm -rf "$link_path" - ln -s "$checkout_path" "$link_path" - fi - done -} - -clean_skip_generated_outputs() { - echo "Limpando artefatos gerados do Skip para recuperação..." - rm -rf \ - "$PROJECT_DIR/.build/plugins/outputs/skip-android-bridge" \ - "$PROJECT_DIR/.build/plugins/outputs/skip-bridge" \ - "$PROJECT_DIR/.build/plugins/outputs/skip-foundation" \ - "$PROJECT_DIR/.build/plugins/outputs/skip-fuse-ui" \ - "$PROJECT_DIR/.build/plugins/outputs/skip-lib" \ - "$PROJECT_DIR/.build/plugins/outputs/skip-model" \ - "$PROJECT_DIR/.build/plugins/outputs/skip-ui" \ - "$PROJECT_DIR/.build/plugins/outputs/skip-unit" -} - -run_skip_export() { - cd "$PROJECT_DIR" - echo "Executando skip export com arquitetura: $SKIP_EXPORT_ARCH" - if skip export --debug --no-ios --android --arch "$SKIP_EXPORT_ARCH"; then - repair_skip_packages_links - return 0 - fi - - echo "Aviso: skip export falhou na primeira tentativa." - clean_skip_generated_outputs - repair_skip_packages_links - - echo "Tentando skip export novamente após limpeza de cache..." - if skip export --debug --no-ios --android --arch "$SKIP_EXPORT_ARCH"; then - repair_skip_packages_links - return 0 - fi - - repair_skip_packages_links - return 1 -} - -get_avd_abi() { - local avd_name="$1" - local cfg="$HOME/.android/avd/${avd_name}.avd/config.ini" - if [[ ! -f "$cfg" ]]; then - return 1 - fi - sed -nE 's/^[[:space:]]*abi\.type[[:space:]]*=[[:space:]]*([^[:space:]]+).*/\1/p' "$cfg" | head -n1 -} - -apk_supports_abi() { - local apk="$1" - local abi="$2" - unzip -l "$apk" 2>/dev/null | grep "lib/${abi}/${REQUIRED_BRIDGE_SO}$" >/dev/null -} - -apk_contains_required_so() { - local apk="$1" - unzip -l "$apk" 2>/dev/null | grep "/${REQUIRED_BRIDGE_SO}$" >/dev/null -} - -first_connected_device() { - "$ADB_BIN" devices | awk 'NR>1 && $2=="device" {print $1; exit}' -} - -pick_connected_device_for_apk() { - local apk="$1" - local serial abi - while IFS= read -r serial; do - [[ -n "$serial" ]] || continue - abi="$("$ADB_BIN" -s "$serial" shell getprop ro.product.cpu.abi 2>/dev/null | tr -d '\r')" - if [[ -n "$abi" ]] && apk_supports_abi "$apk" "$abi"; then - echo "$serial" - return 0 - fi - done < <("$ADB_BIN" devices | awk 'NR>1 && $2=="device" {print $1}') - return 1 -} - -pick_compatible_avd() { - local apk="$1" - local preferred="$2" - local avd_list - avd_list="$("$EMULATOR_BIN" -list-avds)" - local host_arch - host_arch="$(uname -m 2>/dev/null || echo "")" - - if [[ "$preferred" != "auto" ]] && echo "$avd_list" | grep -qx "$preferred"; then - local preferred_abi - preferred_abi="$(get_avd_abi "$preferred" || true)" - if [[ -n "$preferred_abi" ]] && apk_supports_abi "$apk" "$preferred_abi"; then - echo "$preferred" - return 0 - fi - fi - - local avd abi - while IFS= read -r avd; do - [[ -n "$avd" ]] || continue - abi="$(get_avd_abi "$avd" || true)" - if [[ "$host_arch" == "arm64" || "$host_arch" == "aarch64" ]]; then - if [[ "$abi" == "x86_64" || "$abi" == "x86" ]]; then - continue - fi - fi - if [[ -n "$abi" ]] && apk_supports_abi "$apk" "$abi"; then - echo "$avd" - return 0 - fi - done <<< "$avd_list" - return 1 -} - -ensure_android_ready() { - local apk_path="${1:-}" - local avd_to_boot="$AVD_NAME" - - if [[ -n "$apk_path" && -f "$apk_path" ]]; then - if ! apk_contains_required_so "$apk_path"; then - echo "Erro: APK não contém ${REQUIRED_BRIDGE_SO}. Build inválido para app Skip." - echo "APK: $apk_path" - exit 1 - fi - local picked - picked="$(pick_compatible_avd "$apk_path" "$AVD_NAME" || true)" - if [[ -n "$picked" ]]; then - avd_to_boot="$picked" - fi - fi - - "$ADB_BIN" start-server >/dev/null 2>&1 || true - if [[ -n "$apk_path" && -f "$apk_path" ]]; then - TARGET_SERIAL="$(pick_connected_device_for_apk "$apk_path" || true)" - else - TARGET_SERIAL="$(first_connected_device)" - fi - - if [[ -z "$TARGET_SERIAL" ]]; then - if [[ "$START_EMULATOR_WHEN_NO_DEVICE" != "1" ]]; then - echo "Erro: nenhum dispositivo Android conectado." - echo "Para evitar consumo de memória, este script não inicia emulador por padrão." - echo "Conecte um celular com USB debugging e rode novamente." - echo "Se quiser forçar emulador: START_EMULATOR_WHEN_NO_DEVICE=1 ./scripts/android-run.sh" - exit 1 - fi - - if ! "$EMULATOR_BIN" -list-avds | grep -qx "$avd_to_boot"; then - echo "Erro: AVD '$avd_to_boot' não existe. Disponíveis:" - "$EMULATOR_BIN" -list-avds - exit 1 - fi - - echo "Iniciando emulador: $avd_to_boot" - local emulator_args=("$EMULATOR_BIN" "-avd" "$avd_to_boot" "-no-snapshot-load") - if [[ "$EMULATOR_HEADLESS" == "1" ]]; then - emulator_args+=("-no-window" "-gpu" "swiftshader_indirect" "-no-audio") - fi - if [[ -n "$EMULATOR_MEMORY_MB" ]]; then - emulator_args+=("-memory" "$EMULATOR_MEMORY_MB") - fi - - nohup "${emulator_args[@]}" >/tmp/skip-android-emulator.log 2>&1 & - EMULATOR_PID="$!" - sleep 2 - else - BOOTED="$("$ADB_BIN" -s "$TARGET_SERIAL" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" - if [[ "$BOOTED" == "1" ]]; then - echo "Dispositivo já pronto." - else - echo "Dispositivo conectado, aguardando finalização do boot." - fi - fi - - if [[ -z "$TARGET_SERIAL" ]]; then - echo "Aguardando ADB conectar..." - CONNECT_START_TS="$(date +%s)" - while true; do - if [[ -n "$apk_path" && -f "$apk_path" ]]; then - TARGET_SERIAL="$(pick_connected_device_for_apk "$apk_path" || true)" - else - TARGET_SERIAL="$(first_connected_device)" - fi - if [[ -n "$TARGET_SERIAL" ]]; then - break - fi - - NOW_TS="$(date +%s)" - if (( NOW_TS - CONNECT_START_TS >= ADB_CONNECT_TIMEOUT_SECONDS )); then - echo "Timeout aguardando ADB conectar (${ADB_CONNECT_TIMEOUT_SECONDS}s)." - echo "Estado atual do adb:" - "$ADB_BIN" devices || true - if [[ -n "$EMULATOR_PID" ]] && ! kill -0 "$EMULATOR_PID" 2>/dev/null; then - echo "Processo do emulador encerrou antes da conexão." - fi - echo "Últimas linhas do log do emulador (/tmp/skip-android-emulator.log):" - tail -n 80 /tmp/skip-android-emulator.log || true - exit 1 - fi - if [[ -n "$EMULATOR_PID" ]] && ! kill -0 "$EMULATOR_PID" 2>/dev/null; then - echo "Processo do emulador encerrou antes da conexão." - echo "Últimas linhas do log do emulador (/tmp/skip-android-emulator.log):" - tail -n 80 /tmp/skip-android-emulator.log || true - exit 1 - fi - sleep 2 - done - fi - - echo "Aguardando boot do Android..." - START_TS="$(date +%s)" - until [[ "$("$ADB_BIN" -s "$TARGET_SERIAL" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" == "1" ]]; do - NOW_TS="$(date +%s)" - if (( NOW_TS - START_TS >= BOOT_TIMEOUT_SECONDS )); then - echo "Timeout aguardando boot do Android (${BOOT_TIMEOUT_SECONDS}s) no dispositivo $TARGET_SERIAL." - "$ADB_BIN" devices || true - tail -n 80 /tmp/skip-android-emulator.log || true - exit 1 - fi - sleep 2 - done - - if [[ -n "$apk_path" && -f "$apk_path" ]]; then - local device_abi - device_abi="$("$ADB_BIN" -s "$TARGET_SERIAL" shell getprop ro.product.cpu.abi 2>/dev/null | tr -d '\r')" - if [[ -n "$device_abi" ]] && ! apk_supports_abi "$apk_path" "$device_abi"; then - echo "Erro: APK não possui ${REQUIRED_BRIDGE_SO} para ABI do dispositivo: $device_abi" - echo "Tente outro AVD: x86_64 para APK x86_64, arm64-v8a para APK arm64." - exit 1 - fi - fi -} - -repair_skip_packages_links - -find_apk_path() { - if [[ -f "$PROJECT_DIR/.build/skip-export/PediFoods-debug.apk" ]]; then - echo "$PROJECT_DIR/.build/skip-export/PediFoods-debug.apk" - return 0 - fi - local exported_apk - exported_apk="$(find "$PROJECT_DIR/.build/skip-export" -type f -name '*-debug.apk' 2>/dev/null | head -n1 || true)" - if [[ -n "$exported_apk" ]]; then - echo "$exported_apk" - return 0 - fi - if [[ -f "$PROJECT_DIR/.build/Android/app/outputs/apk/debug/app-debug.apk" ]]; then - echo "$PROJECT_DIR/.build/Android/app/outputs/apk/debug/app-debug.apk" - return 0 - fi - if [[ -f "$PROJECT_DIR/Android/app/build/outputs/apk/debug/app-debug.apk" ]]; then - echo "$PROJECT_DIR/Android/app/build/outputs/apk/debug/app-debug.apk" - return 0 - fi - find "$PROJECT_DIR/.build/Android" "$PROJECT_DIR/Android/app/build/outputs/apk/debug" -type f -name '*.apk' 2>/dev/null | head -n1 || true -} - -APK_PATH="$(find_apk_path)" - -SOURCE_MTIME="$(find "$PROJECT_DIR/Sources" -type f -name '*.swift' -exec stat -f '%m' {} \; 2>/dev/null | sort -nr | head -n1 || echo 0)" -SOURCE_MTIME="${SOURCE_MTIME:-0}" -APK_MTIME=0 -if [[ -n "$APK_PATH" && -f "$APK_PATH" ]]; then - APK_MTIME="$(stat -f '%m' "$APK_PATH" 2>/dev/null || echo 0)" -fi -APK_STALE=0 -if (( SOURCE_MTIME > APK_MTIME )); then - APK_STALE=1 -fi - -if [[ -z "$APK_PATH" || "${FORCE_EXPORT:-0}" == "1" || "$APK_STALE" == "1" ]]; then - if [[ "$APK_STALE" == "1" && "${FORCE_EXPORT:-0}" != "1" ]]; then - echo "APK desatualizado em relação ao código fonte. Tentando gerar novo APK..." - fi - echo "Gerando APK (skip export)..." - if ! run_skip_export; then - echo "Aviso: skip export falhou. Tentando usar último APK gerado..." - if [[ -d "$PROJECT_DIR/Android" ]]; then - echo "Tentando fallback via Gradle (:app:assembleDebug)..." - ( - cd "$PROJECT_DIR/Android" - ANDROID_SDK_ROOT="$ANDROID_SDK_ROOT" gradle :app:assembleDebug --no-daemon - ) || echo "Aviso: fallback Gradle também falhou." - fi - fi -fi - -APK_PATH="$(find_apk_path)" - -if [[ -z "$APK_PATH" ]]; then - if [[ "$ALLOW_INSTALLED_FALLBACK" == "1" ]]; then - ensure_android_ready - echo "Aviso: APK não encontrado. Tentando abrir app já instalado: $APP_ID" - "$ADB_BIN" -s "$TARGET_SERIAL" shell monkey -p "$APP_ID" -c android.intent.category.LAUNCHER 1 - echo "App aberto sem reinstalar (fallback)." - exit 0 - fi - echo "Erro: APK não encontrado." - exit 1 -fi - -ensure_android_ready "$APK_PATH" - -if [[ "$REQUIRE_FRESH_APK" == "1" ]]; then - APK_MTIME="$(stat -f '%m' "$APK_PATH" 2>/dev/null || echo 0)" - SOURCE_MTIME="$(find "$PROJECT_DIR/Sources" -type f -name '*.swift' -exec stat -f '%m' {} \; 2>/dev/null | sort -nr | head -n1 || echo 0)" - if (( SOURCE_MTIME > APK_MTIME )); then - if [[ "$ALLOW_INSTALLED_FALLBACK" == "1" ]]; then - echo "Aviso: APK continua desatualizado (build novo não gerado)." - echo "Abrindo app já instalado para não bloquear o fluxo." - "$ADB_BIN" -s "$TARGET_SERIAL" shell monkey -p "$APP_ID" -c android.intent.category.LAUNCHER 1 - echo "App aberto sem reinstalar (fallback)." - exit 0 - fi - echo "Erro: APK continua desatualizado (build novo não gerado)." - echo "Não vou abrir versão antiga para evitar teste enganoso." - echo "Dica: rode com REQUIRE_FRESH_APK=0 para abrir mesmo assim." - exit 1 - fi -fi - -echo "Instalando APK: $APK_PATH" -"$ADB_BIN" -s "$TARGET_SERIAL" install -r "$APK_PATH" - -LAUNCHABLE="$("$ADB_BIN" -s "$TARGET_SERIAL" shell cmd package resolve-activity --brief "$APP_ID" 2>/dev/null | tr -d '\r' | tail -n1)" -if [[ "$LAUNCHABLE" == */* ]]; then - echo "Abrindo app: $LAUNCHABLE" - "$ADB_BIN" -s "$TARGET_SERIAL" shell am start -n "$LAUNCHABLE" -else - echo "Abrindo app (fallback monkey): $APP_ID" - "$ADB_BIN" -s "$TARGET_SERIAL" shell monkey -p "$APP_ID" -c android.intent.category.LAUNCHER 1 -fi - -echo "Validando execução do app..." -sleep 3 -APP_PID="$("$ADB_BIN" -s "$TARGET_SERIAL" shell pidof "$APP_ID" 2>/dev/null | tr -d '\r')" -if [[ -z "$APP_PID" ]]; then - echo "Erro: app não permaneceu em execução após abrir." - "$ADB_BIN" -s "$TARGET_SERIAL" logcat -d | tail -n 120 || true - exit 1 -fi - -sleep "$RUN_STABILITY_SECONDS" -APP_PID_AFTER="$("$ADB_BIN" -s "$TARGET_SERIAL" shell pidof "$APP_ID" 2>/dev/null | tr -d '\r')" -if [[ -z "$APP_PID_AFTER" ]]; then - echo "Erro: app encerrou durante validação de estabilidade (${RUN_STABILITY_SECONDS}s)." - "$ADB_BIN" -s "$TARGET_SERIAL" logcat -d | tail -n 160 || true - exit 1 -fi - -echo "App instalado e aberto no Android."