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/Darwin/NotificationServiceExtension/Info.plist b/NotificationServiceExtension/Info.plist similarity index 100% rename from Darwin/NotificationServiceExtension/Info.plist rename to NotificationServiceExtension/Info.plist diff --git a/Darwin/NotificationServiceExtension/NotificationService.swift b/NotificationServiceExtension/NotificationService.swift similarity index 100% rename from Darwin/NotificationServiceExtension/NotificationService.swift rename to NotificationServiceExtension/NotificationService.swift 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..02166ad --- /dev/null +++ b/PediFoods.xcodeproj/project.pbxproj @@ -0,0 +1,1290 @@ +// !$*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 */; }; + 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 = ""; }; + 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 */, + 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 */, + 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 = { + 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 uses your location to show nearby stores and estimate delivery times."; + 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 = { + 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 uses your location to show nearby stores and estimate delivery times."; + 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 92% rename from Sources/PediFoods/ContentView.swift rename to PediFoods/ContentView.swift index c624263..5f06711 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 @@ -35,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) } } } @@ -269,24 +274,41 @@ struct ContentView: View { self.pushDeepLinkObserver = nil } - @MainActor - private func route(to destination: DeepLinkDestination) { - guard root == .main else { return } + /// 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): - appState.pendingOrderDeepLink = OrderRouteContext( - orderId: orderId, - shortId: shortId, - paymentMethod: nil, - total: nil, - intent: .auto + return .navigateToOrder( + OrderRouteContext( + orderId: orderId, + shortId: shortId, + paymentMethod: nil, + total: nil, + intent: .auto + ), + tab: .profile ) - selectedTab = .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 } } @@ -385,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/Sources/PediFoods/Coordinators/DeepLinkDestination.swift b/PediFoods/Coordinators/DeepLinkDestination.swift similarity index 100% rename from Sources/PediFoods/Coordinators/DeepLinkDestination.swift rename to PediFoods/Coordinators/DeepLinkDestination.swift 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 a6ad1e4..cf44a64 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.", @@ -311,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" : { }, @@ -453,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.", @@ -575,38 +564,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" : { @@ -724,13 +684,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.", @@ -833,9 +786,6 @@ } } } - }, - "Notificações" : { - }, "Notificações desativadas" : { @@ -845,10 +795,6 @@ }, "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.", @@ -919,19 +865,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", @@ -949,36 +887,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?" : { }, @@ -997,10 +905,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" : { }, @@ -1008,24 +912,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" : { @@ -1156,9 +1048,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 100% rename from Sources/PediFoods/Services/ApiCustomerPayloadModels.swift rename to PediFoods/Services/ApiCustomerPayloadModels.swift 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 100% rename from Sources/PediFoods/Services/ApiModels.swift rename to PediFoods/Services/ApiModels.swift 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 100% rename from Sources/PediFoods/Services/ApiService.swift rename to PediFoods/Services/ApiService.swift 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/Sources/PediFoods/Services/PushNotificationCoordinator.swift b/PediFoods/Services/PushNotificationCoordinator.swift similarity index 95% rename from Sources/PediFoods/Services/PushNotificationCoordinator.swift rename to PediFoods/Services/PushNotificationCoordinator.swift index c319feb..b319dc1 100644 --- a/Sources/PediFoods/Services/PushNotificationCoordinator.swift +++ b/PediFoods/Services/PushNotificationCoordinator.swift @@ -173,6 +173,14 @@ final class PushNotificationCoordinator: NSObject { } } +/// `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. @@ -189,9 +197,9 @@ extension PushNotificationCoordinator: UNUserNotificationCenterDelegate { didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void ) { - let userInfo = response.notification.request.content.userInfo + let userInfo = UncheckedSendableBox(value: response.notification.request.content.userInfo) Task { @MainActor in - PushNotificationCoordinator.shared.handleTap(userInfo: userInfo) + PushNotificationCoordinator.shared.handleTap(userInfo: userInfo.value) } completionHandler() } 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 90% rename from Sources/PediFoods/Services/SessionStateStore.swift rename to PediFoods/Services/SessionStateStore.swift index 8ac2fa4..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" @@ -94,12 +100,12 @@ enum SessionStateStore { /// 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 = UserDefaults.standard.object(forKey: pushOptInPromptKey) as? Date else { return true } + guard let last = defaults.object(forKey: pushOptInPromptKey) as? Date else { return true } return Date().timeIntervalSince(last) > pushOptInCooldown } static func recordPushOptInPrompted() { - UserDefaults.standard.set(Date(), forKey: pushOptInPromptKey) + defaults.set(Date(), forKey: pushOptInPromptKey) } static func makeUserKey(profileId: String?, email: String?) -> String? { @@ -123,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 @@ -146,7 +152,6 @@ enum SessionStateStore { } static func loadAddress() -> AddressState? { - let defaults = UserDefaults.standard let activeKey = loadActiveUserKey() let scopedKey = addressStorageKey(for: activeKey) @@ -214,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 { @@ -235,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), @@ -297,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 { @@ -336,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 { @@ -361,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) { @@ -374,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 @@ -387,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 [] } @@ -429,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 } @@ -444,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 100% rename from Sources/PediFoods/State/AppState.swift rename to PediFoods/State/AppState.swift 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 99% rename from Sources/PediFoods/Views/Auth/OtpView.swift rename to PediFoods/Views/Auth/OtpView.swift index d524e95..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 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 100% rename from Sources/PediFoods/Views/Main/AddressesView.swift rename to PediFoods/Views/Main/AddressesView.swift 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 100% rename from Sources/PediFoods/Views/Main/CheckoutView+Logic.swift rename to PediFoods/Views/Main/CheckoutView+Logic.swift 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 91% rename from Sources/PediFoods/Views/Main/MainTabView.swift rename to PediFoods/Views/Main/MainTabView.swift index d9eec5f..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,14 +16,14 @@ struct MainTabView: View { } case .cart: NavigationStack { - CartView(appState: $appState, selectedTab: $selectedTab, root: $root) + CartView(appState: $appState, selectedTab: $selectedTab, enterAuth: enterAuth) } case .profile: NavigationStack { if appState.session.isAuthenticated { - ProfileView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState) + ProfileView(selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState, enterAuth: enterAuth) } else { - ProfileLoggedOutView(root: $root) + ProfileLoggedOutView(enterAuth: enterAuth) } } } 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 98% rename from Sources/PediFoods/Views/Main/OrderTrackingView.swift rename to PediFoods/Views/Main/OrderTrackingView.swift index ff746d5..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 @@ -740,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 { diff --git a/Sources/PediFoods/Views/Main/OrdersView.swift b/PediFoods/Views/Main/OrdersView.swift similarity index 100% rename from Sources/PediFoods/Views/Main/OrdersView.swift rename to PediFoods/Views/Main/OrdersView.swift 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 98% rename from Sources/PediFoods/Views/Main/ProfileView.swift rename to PediFoods/Views/Main/ProfileView.swift index 6df7aef..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 @@ -283,7 +281,7 @@ struct ProfileView: View { AppContentCache.shared.invalidate() AppImageCache.shared.invalidateAll() appState = AppState() - root = .auth + enterAuth() } @MainActor @@ -316,7 +314,7 @@ struct ProfileView: View { } struct ProfileLoggedOutView: View { - @Binding var root: RootFlow + let enterAuth: () -> Void var body: some View { VStack(spacing: 18) { @@ -337,7 +335,7 @@ struct ProfileLoggedOutView: View { .padding(.horizontal, 32) Button { - root = .auth + enterAuth() } label: { Text("Entrar ou Cadastrar") .font(AppTypography.heading3) 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 99% rename from Sources/PediFoods/Views/Main/UserProfileView.swift rename to PediFoods/Views/Main/UserProfileView.swift index 5b28b47..ad6560c 100644 --- a/Sources/PediFoods/Views/Main/UserProfileView.swift +++ b/PediFoods/Views/Main/UserProfileView.swift @@ -293,11 +293,9 @@ struct UserProfileView: View { } private func openSystemSettings() { -#if canImport(UIKit) if let url = URL(string: UIApplication.openSettingsURLString) { openURL(url) } -#endif } /// Preference only for now — the actual Face ID/Touch ID unlock flow 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..7ce87f2 --- /dev/null +++ b/PediFoodsUITests/CartCheckoutFlowTests.swift @@ -0,0 +1,62 @@ +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() + 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 "+" button adds + // directly with no sheet - identified by its SF Symbol name like + // the rest of this app's icon-only controls (person.fill, etc). + let addButton = app.buttons.matching(identifier: "plus").firstMatch + XCTAssertTrue(addButton.waitForExistence(timeout: 15), "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/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..c07ba28 --- /dev/null +++ b/PediFoodsUITests/UITestSupport.swift @@ -0,0 +1,190 @@ +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() + + guard staticTexts["Verificação"].waitForExistence(timeout: 15) 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 61b1e27..0000000 --- a/Sources/PediFoods/PediFoodsApp.swift +++ /dev/null @@ -1,38 +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") - Task { @MainActor in - PushNotificationCoordinator.shared.startObservingDeviceToken() - PushNotificationCoordinator.shared.becomeNotificationCenterDelegate() - await PushNotificationCoordinator.shared.refreshRegistrationIfAuthorized() - await PushNotificationCoordinator.shared.syncCustomerAttributes() - } - } - 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."