diff --git a/.gitea/workflows/beta.yml b/.gitea/workflows/beta.yml new file mode 100644 index 0000000..64c6a52 --- /dev/null +++ b/.gitea/workflows/beta.yml @@ -0,0 +1,67 @@ +name: Beta (TestFlight) + +on: + push: + branches: + - develop + +jobs: + wake-macos-vm: + runs-on: macos-ctl + steps: + - name: Start macOS VM container + run: docker start macos + + build-and-upload: + needs: wake-macos-vm + runs-on: macos-release + + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + ITC_TEAM_ID: ${{ secrets.ITC_TEAM_ID }} + DEVELOPMENT_TEAM: ${{ secrets.DEVELOPMENT_TEAM }} + BUILD_NUMBER: ${{ gitea.run_number }} + + steps: + - name: Checkout + run: | + export GIT_TERMINAL_PROMPT=0 + export GIT_ASKPASS=/bin/false + export SSH_ASKPASS=/bin/false + REPO_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git" + AUTH_URL="$(echo "$REPO_URL" | sed "s#https://#https://x-access-token:${{ gitea.token }}@#")" + git -c credential.helper= -c http.lowSpeedLimit=1000 -c http.lowSpeedTime=30 clone "$AUTH_URL" . + git checkout "${{ gitea.sha }}" + + - name: Write App Store Connect API key + run: | + cat > fastlane/apikey.json < fastlane/apikey.json <` -- **Note:** Obtained via `/api/customer/login`. - -#### 3. Hybrid Store Auth (for Store External APIs) -Used when interacting with a specific store (Catalog, Checkout). -- **Header 1:** `Atomenta-Token: 550e8400-e29b-41d4-a716-446655440008` (Store Module ID) -- **Header 2:** `Authorization: Bearer ` (Required for Checkout/Orders) - ---- - -## 👤 Customer Management - -### 1. Register Customer -**POST** `/api/customer` - -**Headers:** -- `Atomenta-Token: 550e8400-e29b-41d4-a716-44665544000a` (Customer Module ID) - -**Body:** - -```json -{ - "name": "Daniel Loverde", - "email": "daniel@example.com", - "phoneNumber": "+5511999999999", - "birthDate": "1990-01-01T00:00:00Z" // Opcional -} -``` - -**Response:** - -```json -{ - "error": false, - "code": "CUSTOMER_CREATED", - "result": { - "id": "cust_uuid...", - "name": "Daniel Loverde", - "email": "daniel@example.com" - } -} -``` - -### 2. Login (Get Token) -**POST** `/api/customer/login` - -**Body:** - -```json -{ - "email": "daniel@example.com", - "otp": "123456" // Em dev/demo, apenas email pode bastar se configurado assim -} -``` - -**Response:** - -```json -{ - "error": false, - "code": "LOGIN_SUCCESS", - "result": { - "token": "eyJhbGciOi...", // <--- Use as Bearer Token for other requests - "customer": { - "id": "cust_uuid...", - "name": "Daniel Loverde", - "email": "daniel@example.com" - } - } -} -``` - -### 3. Get Profile -**GET** `/api/customer/profile` - -**Headers:** - -- `Authorization: Bearer ` -- `Atomenta-Token: 550e8400-e29b-41d4-a716-44665544000a` - -**Response:** - -```json -{ - "error": false, - "code": "CUSTOMER_PROFILE_RETRIEVED", - "result": { - "id": "cust_uuid...", - "name": "Daniel Loverde", - "email": "daniel@example.com", - "phoneNumber": "+5511999999999", - "favorites": ["store_abc...", "store_xyz..."], - "address_book": [], - "behavioral_stats": { - "total_orders": 5, - "avg_ticket_size": 45.00 - } - } -} -``` - -### 4. Update Profile -**POST** `/api/customer/:id` - -**Headers:** - -- `Authorization: Bearer ` -- `Atomenta-Token: 550e8400-e29b-41d4-a716-44665544000a` - -**Body:** - -```json -{ - "name": "Daniel A. Loverde", // Fields to update - "phoneNumber": "+5511988888888", - "biometricsEnabled": true -} -``` - ---- - -## 🏠 App Home Screen - -### List Stores (Smart Listing) -**GET** `/api/app/stores` - -**Headers:** -- `Authorization: Bearer ` (Customer obrigatório) - -Lists stores based on user location, filtered by distance. - -**Query Parameters:** - -- `lat` (Required): User Latitude (e.g., `-23.550520`) -- `lng` (Required): User Longitude (e.g., `-46.633308`) -- `category` (Optional): Filter by category name (e.g., `Lanches`) -- `search` (Optional): Search by store name - -**Notes:** - -- Sem login do customer, a listagem é bloqueada. -- Se `lat/lng` não forem enviados, a API tenta usar o endereço salvo no perfil do customer (`address_book.lat_long`). -- Lojas fora do raio/bairro de entrega não são exibidas. - -**Response:** - -```json -{ - "error": false, - "result": [ - { - "id": "store_123...", - "name": "Burger King", - "logo": "https://...", - "cover": "https://...", - "category": "Lanches", - "rating": 4.8, - "deliveryTime": "30-45 min", - "deliveryFee": 5.99, - "distance": 1.2, // km - "isOpen": true, - "statusLabel": "Aberto" - } - ] -} -``` - -### List User Orders -**GET** `/api/app/orders` - -**Headers:** - -- `Authorization: Bearer ` - -**Response:** - -```json -{ - "error": false, - "result": [ - { - "id": "ord_123...", - "total": 54.90, - "status": "completed", - "createdAt": "2024-03-20T10:00:00Z", - "items": [...] - } - ] -} -``` - ---- - -## 🏪 Store Integration (External API) - -**Base URL:** `/api/store/:storeId` - -### 1. Store Identity & Status -**GET** `/api/store/:storeId/identity` - -**Headers/Query:** - -- `x-user-lat` / `lat`: User Latitude -- `x-user-lng` / `lng`: User Longitude - -**Response:** - -```json -{ - "error": false, - "result": { - "fantasyName": "Mc Donalds", - "distance": 2.5, - "deliveryTime": "30-45 min", - "minOrder": 15.00 - } -} -``` - -### 2. Full Store Info -**GET** `/api/store/:storeId/info` - -Returns detailed info including operating hours and accepted payment methods. - -**Response:** - -```json -{ - "error": false, - "code": "STORE_INFO_RETRIEVED", - "result": { - "isOpen": true, - "paymentMethods": { - "paymentOnDelivery": true, - "paymentOnPickup": true, - "acceptCash": true, - "acceptPix": true, - "acceptCreditCard": true, // Resumo Geral - "acceptDebitCard": true, // Resumo Geral - "brands": { - "credit": { - "visa": true, - "master": true, - "elo": true, - "amex": false, - "hipercard": false - }, - "debit": { - "visa": true, - "master": true, - "elo": true - } - } - }, - "openingHours": { ... }, - "address": { ... } - } -} -``` - -### 3. Product Catalog -**GET** `/api/store/:storeId/catalog` - -Returns categories with their respective products. - -**Response:** - -```json -{ - "error": false, - "result": [ - { - "id": "cat_1...", - "name": "Burgers", - "products": [ - { - "id": "prod_1...", - "name": "Big Mac", - "price": 25.90, - "image": "https://...", - "addonGroups": [...] - } - ] - } - ] -} -``` - -### 4. Create Order (Checkout) -**POST** `/api/store/:storeId/orders` - -**Body:** - -```json -{ - "customer": { - "name": "Daniel Loverde", - "phone": "11999999999", - "email": "daniel@example.com", - "asaasId": "cus_000005165985" // Opcional. - }, - // Nota: O usuário DEVE estar autenticado via Bearer Token. - // Os dados de 'customer' enviados aqui são usados preferencialmente para - // registrar os dados de entrega/cobrança deste pedido específico no Asaas, - // mas o 'userId' e o histórico são vinculados ao token do usuário logado. - "items": [ - { - "productId": "prod_123", - "qty": 2, - "price": 25.00, - "addons": [] - } - ], - "total": 50.00, - "paymentMethod": "PIX", // "PIX" | "CREDIT_CARD" | "DEBIT_CARD" | "MONEY" - "deliveryType": "DELIVERY", // "DELIVERY" | "PICKUP" - "address": { - "street": "Rua Exemplo", - "number": "123", - "neighborhood": "Centro" - } -} -``` - -**Response:** - -```json -{ - "error": false, - "code": "ORDER_CREATED", - "result": { - "id": "ord_987...", - "status": "created", - "paymentPayload": "https://www.asaas.com/i/..." // Link para pagamento (Pix/Boleto) - } -} -``` diff --git a/API_Store_External.md b/API_Store_External.md deleted file mode 100644 index db13983..0000000 --- a/API_Store_External.md +++ /dev/null @@ -1,389 +0,0 @@ -# Atomenta API - Store Management & External Guide (Pedi Foods Edition) - -Este documento é o guia definitivo e completo para parceiros e aplicações externas (como pedifoods.com.br) que consomem a API da plataforma Atomenta para gestão de lojas. **Esta API reflete 100% das funcionalidades do painel administrativo.** - -## 🔐 Autenticação e Headers - -Todas as chamadas devem usar autenticação híbrida. - -### Headers Obrigatórios: -- **Accept**: `application/json` -- **Atomenta-Token**: `550e8400-e29b-41d4-a716-446655440008` (Escopo: Store Management) -- **Authorization**: `Bearer ` - ---- - -## 🏬 Gestão de Identidade e Lojas - -### 1. Listar Lojas do Usuário -**GET** `/api/store/list` - Retorna todas as lojas vinculadas ao token do usuário. - -### 2. Informações Completas da Loja (Settings) -**GET** `/api/store/:storeId/info` - Retorna a configuração completa da loja, incluindo dados bancários, horários e flags de pagamento. - -**Resposta Exemplo:** - -```json -{ - "error": false, - "result": { - "isOpen": true, - "statusLabel": "Aberto Agora", - "isManualOpen": true, - "fantasyName": "Pizzaria do Arantes", - "razaoSocial": "Loverde Co LTDA", - "document": "12.345.678/0001-99", - "documentType": "CNPJ", - "email": "loja@loverde.com.br", - "phone": "11988776655", - "paymentMethods": { - "paymentOnDelivery": true, - "acceptCash": true, - "acceptCreditVisa": true, - "acceptCreditMaster": true, - "acceptCreditElo": true, - "acceptVoucherAlelo": true, - "acceptPix": true - }, - "bankInfo": { - "bankName": "Banco do Brasil", - "accountType": "Corrente", - "agency": "1234", - "account": "56789-0", - "pixKey": "12345678000199" - }, - "openingHours": { - "monday": [{"open": "18:00", "close": "23:00"}], - "friday": [{"open": "11:00", "close": "15:00"}, {"open": "18:00", "close": "00:00"}] - }, - "address": { - "street": "Av. Brasil, 1000", - "latitude": -23.55052, - "longitude": -46.633308 - } - } -} -``` - ---- - -## 📊 Dashboard Real-time (Portal Parity) - -### 3. Dashboard e Monitoramento -**GET** `/api/store/:storeId/dashboard` - -**Resposta Detalhada (100% Parity):** - -```json -{ - "error": false, - "result": { - "orderFlow": { - "urgencys": [ { "id": "ORD-123", "clientName": "Daniel", "minutes": 45, "status": "PREPARING" } ], - "received": [ /* Pedidos Pendentes */ ], - "inProgress": [ /* Pedidos Em Preparo */ ], - "readyTo": [ /* Pedidos Prontos para Entrega/Retirada */ ], - "inRoute": [ /* Pedidos em Rota */ ], - "delivered": [ /* Pedidos Finalizados na Sessão */ ] - }, - "payments": { - "totalSelledToday": 2450.00, - "growthToday": 15.5, - "avgTicket": 65.20, - "growthPeriod": 8.5 - }, - "alerts": { - "pendingOver3Minutes": 2, - "delayedInProgress": 1 - } - } -} -``` - ---- - -## 🍴 Catálogo e Menu (Complex Payloads) - -### 4. Categorias de Pizza -**POST** `/api/store/:storeId/catalog/categories` - -```json -{ - "name": "Pizzas Salgadas", - "isPizzaCategory": true, - "pizzaConfig": { - "sizes": [ - { "id": "small", "name": "Broto", "slices": 4, "maxFlavors": 1 }, - { "id": "large", "name": "Grande", "slices": 8, "maxFlavors": 3 } - ], - "doughs": [ { "id": "tradicional", "name": "Tradicional", "price": 0 } ], - "crusts": [ { "id": "catupiry", "name": "Borda de Catupiry", "price": 10.00 } ] - } -} -``` - -### 5. Produtos Tipo Combo ou Pizza -**POST** `/api/store/:storeId/catalog/products` - -```json -{ - "type": "pizza", - "name": "Pizza de Calabresa", - "categoryId": "cat_pizzas", - "pizzaPrices": { - "small": "35.00", - "large": "55.00" - }, - "addonGroups": [ - { - "name": "Remover Ingredientes", - "minSelectors": 0, "maxSelectors": 5, - "items": [ { "name": "Sem Cebola", "price": 0 } ] - } - ] -} -``` - ---- - -## 📦 Histórico e Detalhes do Pedido - -### 6. Histórico com Filtros -**GET** `/api/store/:storeId/orders/history?start=2024-01-01&end=2024-01-31&q=Ana` - -**Objeto de Pedido Completo (result.list[0]):** - -```json -{ - "id": "order_guid", - "shortId": "1234", - "clientName": "Arantes Loverde", - "totalValue": 120.50, - "netValue": 112.40, - "pediFoodsFee": 1.00, - "status": "COMPLETED", - "createdAt": "2024-01-08T15:00:00Z", - "confirmOtp": "12345678", - "deliveryEstimate": "35-50 min", - "deliveryTypeLabel": "Entrega Própria", - "timeline": [ - { "status": "PENDING", "time": "15:00:00", "message": "Pedido criado" }, - { "status": "ACCEPTED", "time": "15:02:00", "message": "Chef aceitou o pedido" }, - { "status": "COMPLETED", "time": "15:45:00", "message": "Entregue ao cliente" } - ], - "items": [ - { "name": "Pizza Grande", "price": 55.00, "choices": ["Borda Catupiry", "Meio Calabresa", "Meio Mussarela"] } - ] -} -``` - -> [!IMPORTANT] -> O campo `confirmOtp` (8 dígitos) é o código que o motoboy deve utilizar para iniciar a confirmação. O `customerOtp` (4 dígitos do cliente) **NÃO** é retornado via API por questões de segurança. -``` - -### 7. Detalhes de um Pedido Individual -**GET** `/api/store/:storeId/orders/:orderId` - -Retorna o objeto completo do pedido (parity 100% com o painel). - ---- - -## 💰 Financeiro Detalhado - -### 7. Resumo de Performance e Gateway -**GET** `/api/store/:storeId/financial/summary` - -**Resposta:** -```json -{ - "error": false, - "result": { - "performance": { - "totalSelledAllTime": 150230.50, - "avgTicket": 72.00 - }, - "gateway": { - "balance": { "balance": 1500.00, "available": 1200.00 }, - "transfers": [ - { "id": "TRANS-1", "date": "2024-01-05", "value": 500.00, "status": "DONE" } - ] - } - } -} -``` - ---- - -## ⭐ Reviews e Avaliações - -### 8. Métricas e Respostas -**GET** `/api/store/:storeId/reviews` - -**Resposta:** -```json -{ - "error": false, - "result": { - "metrics": { - "averageRate": "4.1", - "totalReviews": 260, - "starsDistribution": { "5": 121, "4": 60, "3": 23, "2": 15, "1": 39 }, - "itemFeedback": { - "flavor": 207, - "ingredients": 94, - "packaging": 35, - "temperature": 29, - "appearance": 23 - }, - "improvementFeedback": { - "quantity": 128, - "temperature": 89, - "packaging": 42, - "ingredients": 23, - "flavor": 13 - }, - "deliveryFeedback": { - "positive": 102, - "negative": 60 - } - }, - "list": [ - { - "id": "rev_1", - "clientName": "Ana", - "rate": 5, - "message": "Melhor pizza!", - "reply": "Obrigado Ana!", - "repliedAt": "2024-01-08T16:00:00Z" - } - ] - } -} -``` - -### 9. Contestar Avaliação -**POST** `/api/store/:storeId/reviews/:reviewId/dispute` -```json -{ "reason": "Cliente agressivo e mensagem falsa sobre o produto." } -``` - ---- - -## 🖼️ Upload de Ativos e Galeria -**POST** `/api/store/:storeId/assets/upload` (Form-data: `file`) -> Retorna URL absoluta. - -**GET** `/api/store/:storeId/assets` -> Retorna lista de URLs absolutas de todas as imagens da loja (logo, capa e produtos). - ---- - -## 🏷️ Reordenação de Catálogo -**POST** `/api/store/:storeId/catalog/products/reorder` (Payload: `{ "ids": ["p1", "p2", ...] }`) -**POST** `/api/store/:storeId/catalog/categories/reorder` (Payload: `{ "ids": ["c1", "c2", ...] }`) - ---- - -## 🛵 Fluxo de Entrega Pedi Foods (2-Step OTP) - -| Campo | Tipo | Descrição | -|---|---|---| -| `otp` | String | Legacy OTP (4 digits). | -| `confirmOtp` | String | **Novo:** Código de 8 dígitos para o motoboy. (Use este para o fluxo de 2 etapas) | -| `deliveryEstimate` | String | Tempo estimado de entrega (ex: "30-45 min"). | -| `pediFoodsFee` | Number | Taxa de serviço Pedi Foods calculada. | - -## 8. Entrega e Confirmação (Motoboy Flow) - -Novos endpoints públicos para confirmação de entrega em duas etapas. - -### 8.1. Página de Confirmação -`GET /delivery/confirm/:orderId` -Renderiza a página para o motoboy iniciar o processo. - -### 8.2. Validar Motoboy (Passo 1) -`POST /delivery/confirm/:orderId/validate-motoboy` -Valida o código de 8 dígitos (`confirmOtp`). - -**Payload:** -```json -{ "otp": "12345678" } -``` - -### 8.3. Validar Cliente (Passo 2) -`POST /delivery/confirm/:orderId/validate-customer` -Valida o código de 4 dígitos (`customerOtp`) e finaliza o pedido (`COMPLETED`). - -**Payload:** -```json -{ "otp": "1234" } -``` -Para garantir a segurança da entrega, o sistema utiliza um fluxo de confirmação em duas etapas via página pública: - -1. **URL de Confirmação**: `https://atomenta.loverde.com.br/delivery/confirm/:orderRealId` -2. **Passo 1 (Motoboy)**: O entregador insere o `confirmOtp` (8 dígitos) visível no painel da loja. -3. **Passo 2 (Cliente)**: Após validar o código do motoboy, o sistema solicita o `customerOtp` (4 dígitos) que o cliente possui em seu app/notificação. -4. **Finalização**: A validação bem-sucedida do segundo código altera o status do pedido para `COMPLETED` automaticamente. - -Endpoints utilizados pela página pública (CORS habilitado para domínios parceiros): -- `POST /delivery/confirm/:orderId/validate-motoboy` { "otp": "String(8)" } -- `POST /delivery/confirm/:orderId/validate-customer` { "otp": "String(4)" } - ---- - -## 🛒 Checkout e Criação de Pedido -### 10. Criar Novo Pedido (Public) -**POST** `/api/public/orders` - -Este endpoint é utilizado pelo front-end de checkout (Pedi Foods) para criar o pedido no sistema da loja. - -**Payload:** - -```json -{ - "storeId": "store_123456", - "userId": "user_guid_or_guest", - "clientName": "João Silva", - "clientPhone": "11999999999", - "deliveryType": "DELIVERY", // ou "PICKUP" - "address": { - "street": "Rua das Flores", - "number": "123", - "neighborhood": "Centro", - "city": "São Paulo", - "state": "SP", - "zip": "01000-000", - "complement": "Apt 10", - "lat": -23.55, - "lng": -46.63 - }, - "paymentMethod": "PIX", // "CREDIT_CARD", "DEBIT_CARD", "CASH" - "itemsTotal": 50.00, - "deliveryFee": 5.00, - "serviceFee": 0.00, - "discount": 0.00, - "total": 55.00, - "items": [ - { - "id": "prod_1", - "name": "Pizza", - "price": 50.00, - "qty": 1, - "image": "..." - } - ] -} -``` - -**Resposta Sucesso:** - -```json -{ - "error": false, - "result": { - "id": "order_guid", - "shortId": "1234", - "status": "PENDING", - "paymentStatus": "PENDING", - "paymentPayload": "..." // Se PIX (Big String Base64 ou Copia e Cola) - } -} -``` diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..44b39c8 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,86 @@ +![](loverde_company_logo_full.png) + +Copyright (C) Loverde Company - All Rights Reserved +---------- + +# ⚠️ INFORMAÇÕES CONFIDENCIAIS E PROPRIEDADE INTELECTUAL ⚠️ + +## 🚨 AVISO LEGAL IMPORTANTE + +**ATENÇÃO: Este repositório contém informações CONFIDENCIAIS e PROPRIETÁRIAS.** + +### ⛔ PROIBIÇÕES ABSOLUTAS + +- **NÃO** compartilhe este código com terceiros +- **NÃO** faça fork, clone ou cópia sem autorização expressa +- **NÃO** utilize qualquer parte deste código em outros projetos +- **NÃO** distribua, venda ou comercialize este software +- **NÃO** reverse engineer ou descompile o código +- **NÃO** acesse sem permissão explícita do proprietário + +### 🔒 DIREITOS DE PROPRIEDADE INTELECTUAL + +Este software é propriedade exclusiva de **Daniel Arantes Loverde** e está protegido por: + +- **Lei de Propriedade Intelectual (Lei 9.610/98)** +- **Lei de Software (Lei 9.609/98)** +- **Lei Geral de Proteção de Dados (LGPD - Lei 13.709/18)** +- **Direitos Autorais e Patentes** + +### ⚖️ CONSEQUÊNCIAS LEGAIS + +O acesso não autorizado, uso indevido, cópia ou distribuição deste código constitui: + +- **CRIME** de violação de propriedade intelectual +- **CRIME** de roubo de propriedade intelectual +- **CRIME** de violação de direitos autorais +- **CRIME** de acesso não autorizado a sistema informatizado + +**Penas previstas: 2 a 5 anos de reclusão + multa** + +### 🛡️ MEDIDAS DE SEGURANÇA + +- **Monitoramento**: Todo acesso é registrado e monitorado +- **Rastreamento**: IPs e atividades são rastreados +- **Logs**: Sistema completo de auditoria +- **Criptografia**: Dados protegidos com AES-256 +- **Autenticação**: Acesso restrito com JWT + OTP + +### 📧 CONTATO AUTORIZADO + +**ÚNICO CONTATO VÁLIDO:** +- **Email**: daniel@loverde.com.br +- **Autorização**: Apenas via email oficial + +### 🔐 CLASSIFICAÇÃO + +**NÍVEL DE CONFIDENCIALIDADE: MÁXIMO** +- **Restrito**: Apenas para o proprietário +- **Não Divulgar**: Informações proprietárias +- **Não Compartilhar**: Código fonte protegido + +--- + +## ⚠️ DISCLAIMER FINAL + +**Ao acessar este repositório, você concorda que:** + +1. **Reconhece** que este é um software proprietário +2. **Compromete-se** a não violar os direitos de propriedade intelectual +3. **Aceita** as consequências legais por qualquer violação +4. **Entende** que o acesso é monitorado e rastreado +5. **Concorda** em respeitar todas as leis aplicáveis + +--- + +*Este documento é parte integrante do software e deve ser mantido confidencial.* + +--- + +This source code is protected under international copyright law. All rights +reserved and protected by the copyright holders. +This file is confidential and only available to authorized individuals with the +permission of the copyright holders. If you encounter this file and do not have +permission, please contact the copyright holders and delete this file. + +Autor: Daniel Arantes Loverde diff --git a/NotificationServiceExtension/Info.plist b/NotificationServiceExtension/Info.plist new file mode 100644 index 0000000..4f01c63 --- /dev/null +++ b/NotificationServiceExtension/Info.plist @@ -0,0 +1,31 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + NotificationService + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionPointIdentifier + com.apple.usernotifications.service + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).NotificationService + + + diff --git a/NotificationServiceExtension/NotificationService.swift b/NotificationServiceExtension/NotificationService.swift new file mode 100644 index 0000000..ad5c69a --- /dev/null +++ b/NotificationServiceExtension/NotificationService.swift @@ -0,0 +1,60 @@ +import UserNotifications + +/// Rich (image) push — see docs/api/push-notifications-integration-guide.md §4.3. +/// Only fires when Atomenta's push payload sets `"mutable-content": 1`, which +/// it does by default on any push carrying an image (real wire key is +/// `"image"`, not the guide's `"imageUrl"` — verified against +/// `PushNotificationService.dispatchApns`/`dispatchApnsMany` in Atomenta). +/// `@unchecked Sendable`: the extension process only ever runs one +/// `didReceive`/`serviceExtensionTimeWillExpire` cycle at a time — there's no +/// real concurrent access to `contentHandler`/`bestAttemptContent` to guard +/// against, just a background download completion handing back to this +/// instance. +final class NotificationService: UNNotificationServiceExtension, @unchecked Sendable { + private var contentHandler: ((UNNotificationContent) -> Void)? + private var bestAttemptContent: UNMutableNotificationContent? + + override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { + self.contentHandler = contentHandler + let mutableContent = (request.content.mutableCopy() as? UNMutableNotificationContent) ?? UNMutableNotificationContent() + bestAttemptContent = mutableContent + + guard let imageURLString = request.content.userInfo["image"] as? String, + let imageURL = URL(string: imageURLString) else { + deliver() + return + } + + URLSession.shared.downloadTask(with: imageURL) { [weak self] location, _, _ in + if let location, let attachment = Self.attachment(fromDownloadedFile: location) { + self?.bestAttemptContent?.attachments = [attachment] + } + self?.deliver() + }.resume() + } + + /// The system calls this if `didReceive` doesn't finish within its time + /// budget — must still deliver the best content built so far, since + /// `contentHandler` is contractually required to run exactly once. + override func serviceExtensionTimeWillExpire() { + deliver() + } + + private func deliver() { + guard let bestAttemptContent else { return } + contentHandler?(bestAttemptContent) + contentHandler = nil + } + + private static func attachment(fromDownloadedFile location: URL) -> UNNotificationAttachment? { + let tmpURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension("jpg") + do { + try FileManager.default.moveItem(at: location, to: tmpURL) + return try UNNotificationAttachment(identifier: "image", url: tmpURL) + } catch { + return nil + } + } +} diff --git a/PediFoods.xcodeproj/project.pbxproj b/PediFoods.xcodeproj/project.pbxproj new file mode 100644 index 0000000..7b48fa3 --- /dev/null +++ b/PediFoods.xcodeproj/project.pbxproj @@ -0,0 +1,1324 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + 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 */; }; + 901A6A5A00C18FC59F764B85 /* PublicStoreDistanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E55893DD92CBFB24F212454 /* PublicStoreDistanceTests.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 /* NotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = C724E02EC34BB1D4352C2FF0 /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 85EA52253BABA327EC07CFF0 /* AuthFormattersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 561E00C486D60EC2DF720EAF /* AuthFormattersTests.swift */; }; + 8D6622AE9325D02B830BD115 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 6CAA9B253B1A6C8815A0E69D /* LaunchScreen.storyboard */; }; + 90ADA376585F5438F3A1A1D6 /* HomeView+Favorites.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1517B141263438E466452037 /* HomeView+Favorites.swift */; }; + 918BCBC1EAD7EE134DB9742D /* CheckoutTypesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8FF15CEE5B137074E6B2489 /* CheckoutTypesTests.swift */; }; + 926E0C4DD3C82485D4262A62 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29B2F3558C58CD1E71F0F3A0 /* ContentView.swift */; }; + 939765EEF8567833CF9E27A1 /* ApiModelsDecodingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 794249487F718BCFBF176E8D /* ApiModelsDecodingTests.swift */; }; + 93B3F9B5BD866F29387684B6 /* StoreDetailView+Components.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC07B79A19D5214AB24B6C39 /* StoreDetailView+Components.swift */; }; + 9835E4C5F2EA0F7432260118 /* SessionEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5865360EA85401C6A85CBBD5 /* SessionEvents.swift */; }; + 9CFB1DF4CB441490C1290E32 /* ApiFavoriteModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCCEB6D0701BC09215B3D9AC /* ApiFavoriteModels.swift */; }; + 9EDDCECCAEEB8FA4909DA9D4 /* AppCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECBFCE20C54A79DEA8F4512B /* AppCoordinator.swift */; }; + 9F09502CF9331F0C94BFA5F0 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2B2C5BBDFF4473C472F45B4 /* AppState.swift */; }; + A115420D6854B7C9DCF339DE /* CartStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F9CDFF62AE19F3EE36A1D19 /* CartStateTests.swift */; }; + A24EE365C06DBA8FD2D91213 /* HomeFiltersFlowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B44E3CD64AF31CEFFD932D7D /* HomeFiltersFlowTests.swift */; }; + A3DFC65C635BE5D96660A432 /* Inputs.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB95D6804877D2ED244811D0 /* Inputs.swift */; }; + A68ED6FBE5307F8674A5A4AB /* HomeView+Data.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E0CCEF22D356F9EC723999D /* HomeView+Data.swift */; }; + A6AE21C3CC220D50251865BE /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 63DCCE423400D4D3785EE0F0 /* Assets.xcassets */; }; + AA0A4F088033729F9B74B2BB /* LegalViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30B3B6E69309F3EDF28E81CC /* LegalViews.swift */; }; + ABE383C64BD045B9732AA8C5 /* HomeView+Filtering.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0D7ECA55B1FE5FC617E84CB /* HomeView+Filtering.swift */; }; + AC7E0F52FEF7976D97190205 /* ApiModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60DC2520DBC74D68AEE659E5 /* ApiModels.swift */; }; + AD19173ACE58E819FEABD318 /* LoginEmailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDEE09D43411627E9D1973F2 /* LoginEmailView.swift */; }; + B193A29A377C68744B2F8FD5 /* PublicLocationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C44662BAA20AD862BE1DBC40 /* PublicLocationService.swift */; }; + B239BDCEB9F8934F1C71258C /* AppCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6A238BEC35852BBE8E79BA /* AppCache.swift */; }; + B24B898508C8A8EF4191EC89 /* SmokeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 160571AB2F8D4DF98C0BE16F /* SmokeTests.swift */; }; + B303CFDCE85737EA9392BDFE /* RegistrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0707B6F412DC1B1CE99349B /* RegistrationView.swift */; }; + B3B83AD2859F714EAAF93641 /* OrderRealtimeTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5E02FE8D1294A9A93EEB270 /* OrderRealtimeTracker.swift */; }; + B4AB8465254E51804FFF8671 /* KeychainStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89B662E8B7E1B8F1CF7E657E /* KeychainStore.swift */; }; + B58074D656C5E6D5CF3D8EC9 /* ReviewsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BF467DF1447525F04B242B0 /* ReviewsView.swift */; }; + C2245B550422BD7784B1E5D4 /* ApiCustomerPayloadModelsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6B08901A67C8D90A3B50882 /* ApiCustomerPayloadModelsTests.swift */; }; + C6D36ED105E3F5E7C01BC022 /* TextFieldCompat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E3A0A2E253ABFCA35D02F28 /* TextFieldCompat.swift */; }; + C6D6243D2D266D0F8BAC3F22 /* SessionStateStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08341A3C2B69FA9A135101D2 /* SessionStateStore.swift */; }; + C6F379D343C6FC9BEA7D1E18 /* ApiOrderTrackingModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3908E5B0512E1AA756DD012 /* ApiOrderTrackingModels.swift */; }; + CCE23FD7706B34BDE544E05A /* Buttons.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EC381D02A37312BE360024F /* Buttons.swift */; }; + CD96980F3D7B603126FDE6AC /* URLProtocolStub.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51798A414469C911D44F0F6 /* URLProtocolStub.swift */; }; + CEA08090E08B5E39FD0DD5D6 /* CachedRemoteImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = EECF1BBC608B65EC1F1B6114 /* CachedRemoteImage.swift */; }; + D2FB3C624B3C27E59BCA997A /* MainTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EA34EE194C71755B3C82F30 /* MainTabView.swift */; }; + D3EC6E0A68B809F7A3370B1B /* ProductDetailSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 553A05B9EAD0B9D0D9F1B369 /* ProductDetailSheet.swift */; }; + D48148B155BA2F76C41C0787 /* HomeFiltersStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FBD7D809ACE1840165A9DCC /* HomeFiltersStateTests.swift */; }; + D4D5C460FF25F353D6A49B62 /* FiltersModalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30024AC97E79912E1358DDFB /* FiltersModalView.swift */; }; + D73A4204B15D5DFE5FF75F59 /* AddCardFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB38ADB5D671BBB9120BF437 /* AddCardFormView.swift */; }; + D7B6082157DA0DF2382EB943 /* HomeGuestFlowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBD89D11DF567A6A6E078CC8 /* HomeGuestFlowTests.swift */; }; + D8FE2EA3ADFAD7BFA2A27D2E /* StoreDetailSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA44F5384D21B7F718F95522 /* StoreDetailSupport.swift */; }; + DBA0E46D6CFF520460D0418C /* AddAddressFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76B24E69A3F6A3EB86449870 /* AddAddressFormView.swift */; }; + DE499D6150C15EB046AF7963 /* CartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD5EE81AE4347B4086684F8B /* CartView.swift */; }; + E3C2AE0D45A31B955EFAEEAD /* OrderDetailsFlowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE0C7A506EDE8D0FD8F16AC6 /* OrderDetailsFlowTests.swift */; }; + E4D492E4B651A8FCC5016B43 /* PizzaFlavorAddonsSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5ECD3AA9543133BE623668D /* PizzaFlavorAddonsSheet.swift */; }; + E7817C56D8F083A231E11E4E /* LocationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAB9F980426D39E93E091051 /* LocationService.swift */; }; + EBBF7EFFDC9286D4F64544F4 /* PublicLocationModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = F85F176EA2B204CC95072D28 /* PublicLocationModels.swift */; }; + EC9F35551075A78EF7869784 /* AppBackButtonIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3056B162576522D94702AE6E /* AppBackButtonIcon.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 /* NotificationService.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 = ""; }; + 3056B162576522D94702AE6E /* AppBackButtonIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppBackButtonIcon.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; explicitFileType = wrapper.cfbundle; includeInIndex = 0; 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; explicitFileType = wrapper.application; includeInIndex = 0; 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 = ""; }; + 4E55893DD92CBFB24F212454 /* PublicStoreDistanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicStoreDistanceTests.swift; sourceTree = ""; }; + 8AF987AB319512C123DFD558 /* PediFoodsUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PediFoodsUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 8D2C6A0E85E133F947293181 /* OrderDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderDetailsView.swift; sourceTree = ""; }; + 8DD2B73D89E6D33643EF58F5 /* DeepLinkRouteEffectTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLinkRouteEffectTests.swift; sourceTree = ""; }; + 8E3A0A2E253ABFCA35D02F28 /* TextFieldCompat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextFieldCompat.swift; sourceTree = ""; }; + 8FBD7D809ACE1840165A9DCC /* HomeFiltersStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeFiltersStateTests.swift; sourceTree = ""; }; + 912795B76154EFC8190EA448 /* ApiCardModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiCardModels.swift; sourceTree = ""; }; + 93749D009C7B305AC0DB5B65 /* ApiClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiClientTests.swift; sourceTree = ""; }; + 9786809975F4461F277BFBA6 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; + 9BDC45386D2B0D0BE4881FA4 /* TokenStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenStore.swift; sourceTree = ""; }; + 9D56A28027450A094082177F /* ApiOrderTrackingModelsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiOrderTrackingModelsTests.swift; sourceTree = ""; }; + 9D8F18894F32F9F498705B59 /* ApiReviewModelsDecodingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiReviewModelsDecodingTests.swift; sourceTree = ""; }; + 9E595B073D646DFF412D7D19 /* DesignSystem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DesignSystem.swift; sourceTree = ""; }; + 9FC876FED16E41B02B287218 /* CheckoutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckoutView.swift; sourceTree = ""; }; + 9FFFA1B7A41D715078A8CCC8 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + A2081885D00F88746FCEC839 /* ImageSourceResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageSourceResolver.swift; sourceTree = ""; }; + A51798A414469C911D44F0F6 /* URLProtocolStub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLProtocolStub.swift; sourceTree = ""; }; + AC07B79A19D5214AB24B6C39 /* StoreDetailView+Components.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StoreDetailView+Components.swift"; sourceTree = ""; }; + ACB000ED1A0CFD200D73D4E3 /* StoreDetailPizzaSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreDetailPizzaSupport.swift; sourceTree = ""; }; + B0707B6F412DC1B1CE99349B /* RegistrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegistrationView.swift; sourceTree = ""; }; + B2AD93DBB0F52B4B485C97BA /* UserProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileView.swift; sourceTree = ""; }; + B2CE116C585A7529CF309B3D /* SnackbarCenterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnackbarCenterTests.swift; sourceTree = ""; }; + B33118037CF56CD70C332E2C /* UITestSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITestSupport.swift; sourceTree = ""; }; + B35D1C07050150E305FAF09A /* PizzaProductDetailSheet+Flow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PizzaProductDetailSheet+Flow.swift"; sourceTree = ""; }; + B44E3CD64AF31CEFFD932D7D /* HomeFiltersFlowTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeFiltersFlowTests.swift; sourceTree = ""; }; + B807C1B779CAE1340D377D43 /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = ""; }; + B85C265A23C9BA93E3DD6898 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; + BBD89D11DF567A6A6E078CC8 /* HomeGuestFlowTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeGuestFlowTests.swift; sourceTree = ""; }; + BCCEB6D0701BC09215B3D9AC /* ApiFavoriteModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiFavoriteModels.swift; sourceTree = ""; }; + BEAA5F0BA8EBC7F2D19472D6 /* AuthenticatedSessionFlowTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticatedSessionFlowTests.swift; sourceTree = ""; }; + BF413C3924669E761BF455B8 /* OrdersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrdersView.swift; sourceTree = ""; }; + C0E7D6AA983FD6CF97727F10 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + C3A0787FEAE67318791D6CFD /* AuthenticatedProfileNavigationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticatedProfileNavigationTests.swift; sourceTree = ""; }; + C44662BAA20AD862BE1DBC40 /* PublicLocationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicLocationService.swift; sourceTree = ""; }; + C4745C12F14B695C77DFE178 /* SnackbarOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnackbarOverlay.swift; sourceTree = ""; }; + C6B08901A67C8D90A3B50882 /* ApiCustomerPayloadModelsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiCustomerPayloadModelsTests.swift; sourceTree = ""; }; + C724E02EC34BB1D4352C2FF0 /* NotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationService.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 /* NotificationService.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 */, + 4E55893DD92CBFB24F212454 /* PublicStoreDistanceTests.swift */, + 747AD255B6D881E011FDF907 /* SessionStateStoreTests.swift */, + 160571AB2F8D4DF98C0BE16F /* SmokeTests.swift */, + B2CE116C585A7529CF309B3D /* SnackbarCenterTests.swift */, + 18EC38EEE1E7105BC21E354C /* StoreCatalogNormalizerTests.swift */, + A51798A414469C911D44F0F6 /* URLProtocolStub.swift */, + ); + path = PediFoodsTests; + sourceTree = ""; + }; + AFBC6E75D01939DF5C1F5D0F /* Main */ = { + isa = PBXGroup; + children = ( + 76B24E69A3F6A3EB86449870 /* AddAddressFormView.swift */, + DB38ADB5D671BBB9120BF437 /* AddCardFormView.swift */, + 7CD06F9A7AF9365D1FBE35D0 /* AddressComponents.swift */, + 395EA4C12C0187DA59DFFC17 /* AddressesView.swift */, + CD5EE81AE4347B4086684F8B /* CartView.swift */, + 6FC454261845C1EDF970552A /* CheckoutTypes.swift */, + 9FC876FED16E41B02B287218 /* CheckoutView.swift */, + 7463F80A5AAACB5FBE6EB160 /* CheckoutView+Logic.swift */, + 30024AC97E79912E1358DDFB /* FiltersModalView.swift */, + 0286512E123F685F27082A2C /* HomeScrollOffsetObserver.swift */, + B85C265A23C9BA93E3DD6898 /* HomeView.swift */, + 4E0CCEF22D356F9EC723999D /* HomeView+Data.swift */, + 1517B141263438E466452037 /* HomeView+Favorites.swift */, + E0D7ECA55B1FE5FC617E84CB /* HomeView+Filtering.swift */, + 79D2854B8062D96742CFDD99 /* HomeViewComponents.swift */, + 2EA34EE194C71755B3C82F30 /* MainTabView.swift */, + 8D2C6A0E85E133F947293181 /* OrderDetailsView.swift */, + BF413C3924669E761BF455B8 /* OrdersView.swift */, + 709FCA67231B4E8D8F976C87 /* OrderTrackingView.swift */, + D5ECD3AA9543133BE623668D /* PizzaFlavorAddonsSheet.swift */, + D6A072F5D761EFC2CAA767C3 /* PizzaProductDetailSheet.swift */, + B35D1C07050150E305FAF09A /* PizzaProductDetailSheet+Flow.swift */, + 553A05B9EAD0B9D0D9F1B369 /* ProductDetailSheet.swift */, + B807C1B779CAE1340D377D43 /* ProfileView.swift */, + 4DC5416AEBA77C43CF55ABA8 /* PublicLocationPickerView.swift */, + 6BF467DF1447525F04B242B0 /* ReviewsView.swift */, + 258B22AD407CCD44A4DC0C06 /* SavedCardsView.swift */, + ACB000ED1A0CFD200D73D4E3 /* StoreDetailPizzaSupport.swift */, + CA44F5384D21B7F718F95522 /* StoreDetailSupport.swift */, + 71DB74B676A58B367F026090 /* StoreDetailView.swift */, + AC07B79A19D5214AB24B6C39 /* StoreDetailView+Components.swift */, + 103668F3D2C86F68A07D76E7 /* StoreDetailView+Logic.swift */, + B2AD93DBB0F52B4B485C97BA /* UserProfileView.swift */, + ); + path = Main; + sourceTree = ""; + }; + C0C1E2AAD06BC8D9F221FFD5 = { + isa = PBXGroup; + children = ( + 751388ED084854A042DD69C8 /* NotificationServiceExtension */, + D9035999B1DF9773A5B7641E /* PediFoods */, + 9EAD88F6DC92C7FBB673C95A /* PediFoodsTests */, + CB598C3E047E250BA5A847DF /* PediFoodsUITests */, + 126A579C448C784867DC3AB1 /* Products */, + ); + sourceTree = ""; + }; + C3E51098EEA82A07159740FD /* Launch */ = { + isa = PBXGroup; + children = ( + F0B079DFAB606DCA87A536C9 /* LaunchSplashView.swift */, + ); + path = Launch; + sourceTree = ""; + }; + CB598C3E047E250BA5A847DF /* PediFoodsUITests */ = { + isa = PBXGroup; + children = ( + C3A0787FEAE67318791D6CFD /* AuthenticatedProfileNavigationTests.swift */, + BEAA5F0BA8EBC7F2D19472D6 /* AuthenticatedSessionFlowTests.swift */, + 7034E89267D9F5D86BF1E424 /* CartCheckoutFlowTests.swift */, + B44E3CD64AF31CEFFD932D7D /* HomeFiltersFlowTests.swift */, + BBD89D11DF567A6A6E078CC8 /* HomeGuestFlowTests.swift */, + FE0C7A506EDE8D0FD8F16AC6 /* OrderDetailsFlowTests.swift */, + 56E3E6C1834786CA3621B7BB /* PediFoodsUITestsLaunchTests.swift */, + 7C13751F450A42A2BDB0783D /* ProfileLoggedOutFlowTests.swift */, + 1A2B90A5FBAD43B6C97C8D21 /* SavedCardsFlowTests.swift */, + 5BB1ACA04E2868C622EF57D1 /* TestFixtures.swift */, + B33118037CF56CD70C332E2C /* UITestSupport.swift */, + 739EE6B1FFDC38C9B0C038F3 /* UserProfileFlowTests.swift */, + ); + path = PediFoodsUITests; + sourceTree = ""; + }; + D028B69395DC85C9ACF8F840 /* Auth */ = { + isa = PBXGroup; + children = ( + 36AA0706B5951E99AE98E6A3 /* AuthFlowView.swift */, + 28B3C375FC18002A884A44F1 /* AuthFormatters.swift */, + 30B3B6E69309F3EDF28E81CC /* LegalViews.swift */, + FDEE09D43411627E9D1973F2 /* LoginEmailView.swift */, + F18F768F7A2A47111A85FE88 /* LoginView.swift */, + 5E5FB65101BEF4615C7EB3AB /* OtpView.swift */, + B0707B6F412DC1B1CE99349B /* RegistrationView.swift */, + ); + path = Auth; + sourceTree = ""; + }; + D4D41A579B14E8A27AE766B0 /* DesignSystem */ = { + isa = PBXGroup; + children = ( + 9E595B073D646DFF412D7D19 /* DesignSystem.swift */, + ); + path = DesignSystem; + sourceTree = ""; + }; + D7552D3AF029ADAC7DE5A76B /* Components */ = { + isa = PBXGroup; + children = ( + 3056B162576522D94702AE6E /* AppBackButtonIcon.swift */, + 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 /* NotificationService.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 = { + 66486D25E3A8A75EA8FACE44 = { + TestTargetID = B3B5CFF77A8ED019D1299ABB; + }; + B3B5CFF77A8ED019D1299ABB = { + SystemCapabilities = { + com.apple.Push = { + enabled = YES; + }; + }; + }; + }; + }; + buildConfigurationList = 160F7992C53AEB34ACB61511 /* Build configuration list for PBXProject "PediFoods" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + es, + fr, + ja, + "zh-Hans", + ); + mainGroup = C0C1E2AAD06BC8D9F221FFD5; + packageReferences = ( + A90709E0CE6A667BD0C0FF25 /* XCRemoteSwiftPackageReference "LCEssentials" */, + ); + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33EB2407F0F758EB98A769D3 /* NotificationServiceExtension */, + B3B5CFF77A8ED019D1299ABB /* PediFoods */, + 3027BAE353B414EC70E2A2AC /* PediFoodsTests */, + 66486D25E3A8A75EA8FACE44 /* PediFoodsUITests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 8CCC7BFEF89A92F0A68013B7 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A6AE21C3CC220D50251865BE /* Assets.xcassets in Resources */, + 8D6622AE9325D02B830BD115 /* LaunchScreen.storyboard in Resources */, + 0DAC2E33C08E494991B01262 /* Localizable.xcstrings in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 2661CA3D265B6235F979DF01 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 508C3902DD2D1C42B4714724 /* NotificationService.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 550F59C254CEF0B02DC9558B /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 09FA9556CD659F93C25447DB /* AuthenticatedProfileNavigationTests.swift in Sources */, + 1AFC280D183E5DC53949C317 /* AuthenticatedSessionFlowTests.swift in Sources */, + 4B85C214A0BF3DF50311C76C /* CartCheckoutFlowTests.swift in Sources */, + A24EE365C06DBA8FD2D91213 /* HomeFiltersFlowTests.swift in Sources */, + D7B6082157DA0DF2382EB943 /* HomeGuestFlowTests.swift in Sources */, + E3C2AE0D45A31B955EFAEEAD /* OrderDetailsFlowTests.swift in Sources */, + 80B476CCE2B90BEE2FA4CB37 /* PediFoodsUITestsLaunchTests.swift in Sources */, + 68AB5B542C6486AA7FFF3258 /* ProfileLoggedOutFlowTests.swift in Sources */, + F4A296C6C0D031BB9407F7A0 /* SavedCardsFlowTests.swift in Sources */, + 709EF974B65A6E5F7C84F55D /* TestFixtures.swift in Sources */, + 7CAAE9391C622C1CAF3A08E3 /* UITestSupport.swift in Sources */, + 562033CE82B2347AE3059516 /* UserProfileFlowTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D5B291FBCE39187148F29BD6 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + DBA0E46D6CFF520460D0418C /* AddAddressFormView.swift in Sources */, + D73A4204B15D5DFE5FF75F59 /* AddCardFormView.swift in Sources */, + 5F457450CBF039AA0396E0E9 /* AddressComponents.swift in Sources */, + 1067B48BE2EEADA0358DEDB6 /* AddressesView.swift in Sources */, + 4508BD7E4DF8DF6CE6F9D8A7 /* ApiCardModels.swift in Sources */, + 33AB0B87867400F3D0C65FA0 /* ApiClient.swift in Sources */, + 704F004D8E59873BF6108FC4 /* ApiConfig.swift in Sources */, + 29E376FB24A2F19361D5BB6A /* ApiCustomerPayloadModels.swift in Sources */, + 9CFB1DF4CB441490C1290E32 /* ApiFavoriteModels.swift in Sources */, + AC7E0F52FEF7976D97190205 /* ApiModels.swift in Sources */, + 2B202626C34FC9F5C87C0388 /* ApiOrderModels.swift in Sources */, + C6F379D343C6FC9BEA7D1E18 /* ApiOrderTrackingModels.swift in Sources */, + 42FD703A96353119FAB2F711 /* ApiPizzaModels.swift in Sources */, + 691351CFE207B9F34361AEB6 /* ApiReviewModels.swift in Sources */, + 279A489F01434E0523EC1BAA /* ApiService.swift in Sources */, + EC9F35551075A78EF7869784 /* AppBackButtonIcon.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 */, + 901A6A5A00C18FC59F764B85 /* PublicStoreDistanceTests.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)"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Distribution"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; + CODE_SIGN_STYLE = Manual; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = K4E5BZMM4V; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.br.pedifoods.PediFoodsTests; + PROVISIONING_PROFILE_SPECIFIER = ""; + SDKROOT = iphoneos; + SWIFT_VERSION = 6; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/PediFoods.app/PediFoods"; + }; + name = Release; + }; + 388EFAF707AD295240B9E951 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + APP_ATTEST_ENVIRONMENT = production; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = PediFoods/PediFoods.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 2026.0.25.0.0.1; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = K4E5BZMM4V; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = PediFoods/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "Pedi Foods"; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "PediFoods usa sua localização para mostrar lojas perto de você"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = NO; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDefault; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 0.0.1; + PRODUCT_BUNDLE_IDENTIFIER = com.br.pedifoods.app; + PRODUCT_NAME = PediFoods; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "LC Prov PediFoods Dist Profile"; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 6; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; + 4424276379B7A6A98892CFBC /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + APP_ATTEST_ENVIRONMENT = development; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = PediFoods/PediFoods.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 2026.0.25.0.0.1; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = K4E5BZMM4V; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = PediFoods/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "Pedi Foods"; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "PediFoods usa sua localização para mostrar lojas perto de você"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = NO; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDefault; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 0.0.1; + PRODUCT_BUNDLE_IDENTIFIER = com.br.pedifoods.app; + PRODUCT_NAME = PediFoods; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "LC Prov PediFoods Dev Profile"; + 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)"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; + CODE_SIGN_STYLE = Manual; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = K4E5BZMM4V; + "DEVELOPMENT_TEAM[sdk=macosx*]" = K4E5BZMM4V; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.br.pedifoods.PediFoodsTests; + PROVISIONING_PROFILE_SPECIFIER = ""; + 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_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 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; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "LC Prov PediFoods Dev Push Profile"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_VERSION = 6; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + 6278624D046E5E4FF59B68A4 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + DEVELOPMENT_TEAM = K4E5BZMM4V; + 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)"; + DEVELOPMENT_TEAM = K4E5BZMM4V; + 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_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 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; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "LC Prov PediFoods Dist Push Profile"; + 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/pedi-foods/Project.xcworkspace/contents.xcworkspacedata b/PediFoods.xcodeproj/project.xcworkspace/contents.xcworkspacedata similarity index 59% rename from pedi-foods/Project.xcworkspace/contents.xcworkspacedata rename to PediFoods.xcodeproj/project.xcworkspace/contents.xcworkspacedata index 9b1558e..919434a 100644 --- a/pedi-foods/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/pedi-foods/Darwin/Assets.xcassets/AccentColor.colorset/Contents.json b/PediFoods/Assets.xcassets/AccentColor.colorset/Contents.json similarity index 100% rename from pedi-foods/Darwin/Assets.xcassets/AccentColor.colorset/Contents.json rename to PediFoods/Assets.xcassets/AccentColor.colorset/Contents.json diff --git a/PediFoods/Assets.xcassets/AppIcon.appiconset/Contents.json b/PediFoods/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..50c4762 --- /dev/null +++ b/PediFoods/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,38 @@ +{ + "images" : [ + { + "filename" : "icon_pattern_1024x1024.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "icon_pattern_dark_1024x1024.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "filename" : "icon_pattern_1024x1024 1.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/PediFoods/Assets.xcassets/AppIcon.appiconset/icon_pattern_1024x1024 1.png b/PediFoods/Assets.xcassets/AppIcon.appiconset/icon_pattern_1024x1024 1.png new file mode 100644 index 0000000..55cea2c Binary files /dev/null and b/PediFoods/Assets.xcassets/AppIcon.appiconset/icon_pattern_1024x1024 1.png differ diff --git a/PediFoods/Assets.xcassets/AppIcon.appiconset/icon_pattern_1024x1024.png b/PediFoods/Assets.xcassets/AppIcon.appiconset/icon_pattern_1024x1024.png new file mode 100644 index 0000000..55cea2c Binary files /dev/null and b/PediFoods/Assets.xcassets/AppIcon.appiconset/icon_pattern_1024x1024.png differ diff --git a/PediFoods/Assets.xcassets/AppIcon.appiconset/icon_pattern_dark_1024x1024.png b/PediFoods/Assets.xcassets/AppIcon.appiconset/icon_pattern_dark_1024x1024.png new file mode 100644 index 0000000..00271b7 Binary files /dev/null and b/PediFoods/Assets.xcassets/AppIcon.appiconset/icon_pattern_dark_1024x1024.png differ diff --git a/pedi-foods/Darwin/Assets.xcassets/Contents.json b/PediFoods/Assets.xcassets/Contents.json similarity index 100% rename from pedi-foods/Darwin/Assets.xcassets/Contents.json rename to PediFoods/Assets.xcassets/Contents.json diff --git a/PediFoods/Assets.xcassets/LaunchBackground.colorset/Contents.json b/PediFoods/Assets.xcassets/LaunchBackground.colorset/Contents.json new file mode 100644 index 0000000..35fe5b3 --- /dev/null +++ b/PediFoods/Assets.xcassets/LaunchBackground.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.969", + "green" : "0.961", + "red" : "0.953" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.102", + "green" : "0.102", + "red" : "0.102" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/PediFoods/Assets.xcassets/alelocard_logo.imageset/Contents.json b/PediFoods/Assets.xcassets/alelocard_logo.imageset/Contents.json new file mode 100644 index 0000000..c43b70e --- /dev/null +++ b/PediFoods/Assets.xcassets/alelocard_logo.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "alelocard_logo.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/PediFoods/Assets.xcassets/alelocard_logo.imageset/alelocard_logo.png b/PediFoods/Assets.xcassets/alelocard_logo.imageset/alelocard_logo.png new file mode 100644 index 0000000..9201194 Binary files /dev/null and b/PediFoods/Assets.xcassets/alelocard_logo.imageset/alelocard_logo.png differ diff --git a/PediFoods/Assets.xcassets/amexcard_logo.imageset/Contents.json b/PediFoods/Assets.xcassets/amexcard_logo.imageset/Contents.json new file mode 100644 index 0000000..e25b625 --- /dev/null +++ b/PediFoods/Assets.xcassets/amexcard_logo.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "amexcard_logo.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/PediFoods/Assets.xcassets/amexcard_logo.imageset/amexcard_logo.png b/PediFoods/Assets.xcassets/amexcard_logo.imageset/amexcard_logo.png new file mode 100644 index 0000000..42edc89 Binary files /dev/null and b/PediFoods/Assets.xcassets/amexcard_logo.imageset/amexcard_logo.png differ diff --git a/PediFoods/Assets.xcassets/hipercard_logo.imageset/Contents.json b/PediFoods/Assets.xcassets/hipercard_logo.imageset/Contents.json new file mode 100644 index 0000000..9893028 --- /dev/null +++ b/PediFoods/Assets.xcassets/hipercard_logo.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "hipercard_logo.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/PediFoods/Assets.xcassets/hipercard_logo.imageset/hipercard_logo.png b/PediFoods/Assets.xcassets/hipercard_logo.imageset/hipercard_logo.png new file mode 100644 index 0000000..a184c60 Binary files /dev/null and b/PediFoods/Assets.xcassets/hipercard_logo.imageset/hipercard_logo.png differ diff --git a/PediFoods/Assets.xcassets/icon_pix.imageset/Contents.json b/PediFoods/Assets.xcassets/icon_pix.imageset/Contents.json new file mode 100644 index 0000000..22f4230 --- /dev/null +++ b/PediFoods/Assets.xcassets/icon_pix.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "icon_pix.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/PediFoods/Assets.xcassets/icon_pix.imageset/icon_pix.png b/PediFoods/Assets.xcassets/icon_pix.imageset/icon_pix.png new file mode 100644 index 0000000..d3d4824 Binary files /dev/null and b/PediFoods/Assets.xcassets/icon_pix.imageset/icon_pix.png differ diff --git a/PediFoods/Assets.xcassets/mastercard_logo.imageset/Contents.json b/PediFoods/Assets.xcassets/mastercard_logo.imageset/Contents.json new file mode 100644 index 0000000..a37b708 --- /dev/null +++ b/PediFoods/Assets.xcassets/mastercard_logo.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "mastercard_logo.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/PediFoods/Assets.xcassets/mastercard_logo.imageset/mastercard_logo.png b/PediFoods/Assets.xcassets/mastercard_logo.imageset/mastercard_logo.png new file mode 100644 index 0000000..fefd01b Binary files /dev/null and b/PediFoods/Assets.xcassets/mastercard_logo.imageset/mastercard_logo.png differ diff --git a/PediFoods/Assets.xcassets/pedifoods.imageset/Contents.json b/PediFoods/Assets.xcassets/pedifoods.imageset/Contents.json new file mode 100644 index 0000000..0ed1bd7 --- /dev/null +++ b/PediFoods/Assets.xcassets/pedifoods.imageset/Contents.json @@ -0,0 +1,52 @@ +{ + "images": [ + { + "idiom": "universal", + "filename": "pedifoods.png", + "scale": "1x" + }, + { + "idiom": "universal", + "scale": "2x" + }, + { + "idiom": "universal", + "scale": "3x" + }, + { + "idiom": "universal", + "filename": "pedifoods_dark.png", + "scale": "1x", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + }, + { + "idiom": "universal", + "scale": "2x", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + }, + { + "idiom": "universal", + "scale": "3x", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + } + ], + "info": { + "version": 1, + "author": "xcode" + } +} \ No newline at end of file diff --git a/PediFoods/Assets.xcassets/pedifoods.imageset/pedifoods.png b/PediFoods/Assets.xcassets/pedifoods.imageset/pedifoods.png new file mode 100644 index 0000000..a94a4ff Binary files /dev/null and b/PediFoods/Assets.xcassets/pedifoods.imageset/pedifoods.png differ diff --git a/PediFoods/Assets.xcassets/pedifoods.imageset/pedifoods_dark.png b/PediFoods/Assets.xcassets/pedifoods.imageset/pedifoods_dark.png new file mode 100644 index 0000000..a94a4ff Binary files /dev/null and b/PediFoods/Assets.xcassets/pedifoods.imageset/pedifoods_dark.png differ diff --git a/PediFoods/Assets.xcassets/pin_image_app.imageset/Contents.json b/PediFoods/Assets.xcassets/pin_image_app.imageset/Contents.json new file mode 100644 index 0000000..c6998a7 --- /dev/null +++ b/PediFoods/Assets.xcassets/pin_image_app.imageset/Contents.json @@ -0,0 +1,52 @@ +{ + "images": [ + { + "idiom": "universal", + "filename": "pin_image_app.png", + "scale": "1x" + }, + { + "idiom": "universal", + "scale": "2x" + }, + { + "idiom": "universal", + "scale": "3x" + }, + { + "idiom": "universal", + "filename": "pin_image_app_dark.png", + "scale": "1x", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + }, + { + "idiom": "universal", + "scale": "2x", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + }, + { + "idiom": "universal", + "scale": "3x", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + } + ], + "info": { + "version": 1, + "author": "xcode" + } +} \ No newline at end of file diff --git a/PediFoods/Assets.xcassets/pin_image_app.imageset/pin_image_app.png b/PediFoods/Assets.xcassets/pin_image_app.imageset/pin_image_app.png new file mode 100644 index 0000000..7ed510e Binary files /dev/null and b/PediFoods/Assets.xcassets/pin_image_app.imageset/pin_image_app.png differ diff --git a/PediFoods/Assets.xcassets/pin_image_app.imageset/pin_image_app_dark.png b/PediFoods/Assets.xcassets/pin_image_app.imageset/pin_image_app_dark.png new file mode 100644 index 0000000..450d7bc Binary files /dev/null and b/PediFoods/Assets.xcassets/pin_image_app.imageset/pin_image_app_dark.png differ diff --git a/PediFoods/Assets.xcassets/placeholder-pizza.imageset/Contents.json b/PediFoods/Assets.xcassets/placeholder-pizza.imageset/Contents.json new file mode 100644 index 0000000..162c776 --- /dev/null +++ b/PediFoods/Assets.xcassets/placeholder-pizza.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "placeholder-pizza.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/PediFoods/Assets.xcassets/placeholder-pizza.imageset/placeholder-pizza.png b/PediFoods/Assets.xcassets/placeholder-pizza.imageset/placeholder-pizza.png new file mode 100644 index 0000000..e4072c0 Binary files /dev/null and b/PediFoods/Assets.xcassets/placeholder-pizza.imageset/placeholder-pizza.png differ diff --git a/PediFoods/Assets.xcassets/placeholder-product.imageset/Contents.json b/PediFoods/Assets.xcassets/placeholder-product.imageset/Contents.json new file mode 100644 index 0000000..7e07a38 --- /dev/null +++ b/PediFoods/Assets.xcassets/placeholder-product.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "placeholder-product.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/PediFoods/Assets.xcassets/placeholder-product.imageset/placeholder-product.png b/PediFoods/Assets.xcassets/placeholder-product.imageset/placeholder-product.png new file mode 100644 index 0000000..889fa6c Binary files /dev/null and b/PediFoods/Assets.xcassets/placeholder-product.imageset/placeholder-product.png differ diff --git a/PediFoods/Assets.xcassets/sodexo_logo.imageset/Contents.json b/PediFoods/Assets.xcassets/sodexo_logo.imageset/Contents.json new file mode 100644 index 0000000..450d47f --- /dev/null +++ b/PediFoods/Assets.xcassets/sodexo_logo.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "sodexo_logo.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/PediFoods/Assets.xcassets/sodexo_logo.imageset/sodexo_logo.png b/PediFoods/Assets.xcassets/sodexo_logo.imageset/sodexo_logo.png new file mode 100644 index 0000000..1aefb69 Binary files /dev/null and b/PediFoods/Assets.xcassets/sodexo_logo.imageset/sodexo_logo.png differ diff --git a/PediFoods/Assets.xcassets/tracking-canceled.imageset/Contents.json b/PediFoods/Assets.xcassets/tracking-canceled.imageset/Contents.json new file mode 100644 index 0000000..bd8508b --- /dev/null +++ b/PediFoods/Assets.xcassets/tracking-canceled.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "tracking-canceled.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/PediFoods/Assets.xcassets/tracking-canceled.imageset/tracking-canceled.png b/PediFoods/Assets.xcassets/tracking-canceled.imageset/tracking-canceled.png new file mode 100644 index 0000000..6b0acbc Binary files /dev/null and b/PediFoods/Assets.xcassets/tracking-canceled.imageset/tracking-canceled.png differ diff --git a/PediFoods/Assets.xcassets/tracking-completed.imageset/Contents.json b/PediFoods/Assets.xcassets/tracking-completed.imageset/Contents.json new file mode 100644 index 0000000..210ff36 --- /dev/null +++ b/PediFoods/Assets.xcassets/tracking-completed.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "tracking-completed.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/PediFoods/Assets.xcassets/tracking-completed.imageset/tracking-completed.png b/PediFoods/Assets.xcassets/tracking-completed.imageset/tracking-completed.png new file mode 100644 index 0000000..fee3d7f Binary files /dev/null and b/PediFoods/Assets.xcassets/tracking-completed.imageset/tracking-completed.png differ diff --git a/PediFoods/Assets.xcassets/tracking-delivering.imageset/Contents.json b/PediFoods/Assets.xcassets/tracking-delivering.imageset/Contents.json new file mode 100644 index 0000000..427443e --- /dev/null +++ b/PediFoods/Assets.xcassets/tracking-delivering.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "tracking-delivering.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/PediFoods/Assets.xcassets/tracking-delivering.imageset/tracking-delivering.png b/PediFoods/Assets.xcassets/tracking-delivering.imageset/tracking-delivering.png new file mode 100644 index 0000000..2d996ae Binary files /dev/null and b/PediFoods/Assets.xcassets/tracking-delivering.imageset/tracking-delivering.png differ diff --git a/PediFoods/Assets.xcassets/tracking-pending.imageset/Contents.json b/PediFoods/Assets.xcassets/tracking-pending.imageset/Contents.json new file mode 100644 index 0000000..eb65c04 --- /dev/null +++ b/PediFoods/Assets.xcassets/tracking-pending.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "tracking-pending.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/PediFoods/Assets.xcassets/tracking-pending.imageset/tracking-pending.png b/PediFoods/Assets.xcassets/tracking-pending.imageset/tracking-pending.png new file mode 100644 index 0000000..a4a9344 Binary files /dev/null and b/PediFoods/Assets.xcassets/tracking-pending.imageset/tracking-pending.png differ diff --git a/PediFoods/Assets.xcassets/tracking-preparing.imageset/Contents.json b/PediFoods/Assets.xcassets/tracking-preparing.imageset/Contents.json new file mode 100644 index 0000000..e0a94c8 --- /dev/null +++ b/PediFoods/Assets.xcassets/tracking-preparing.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "tracking-preparing.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/PediFoods/Assets.xcassets/tracking-preparing.imageset/tracking-preparing.png b/PediFoods/Assets.xcassets/tracking-preparing.imageset/tracking-preparing.png new file mode 100644 index 0000000..23fc9a7 Binary files /dev/null and b/PediFoods/Assets.xcassets/tracking-preparing.imageset/tracking-preparing.png differ diff --git a/PediFoods/Assets.xcassets/tracking-ready.imageset/Contents.json b/PediFoods/Assets.xcassets/tracking-ready.imageset/Contents.json new file mode 100644 index 0000000..f5e3c56 --- /dev/null +++ b/PediFoods/Assets.xcassets/tracking-ready.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "tracking-ready.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/PediFoods/Assets.xcassets/tracking-ready.imageset/tracking-ready.png b/PediFoods/Assets.xcassets/tracking-ready.imageset/tracking-ready.png new file mode 100644 index 0000000..fe2f0f2 Binary files /dev/null and b/PediFoods/Assets.xcassets/tracking-ready.imageset/tracking-ready.png differ diff --git a/PediFoods/Assets.xcassets/visacard_logo.imageset/Contents.json b/PediFoods/Assets.xcassets/visacard_logo.imageset/Contents.json new file mode 100644 index 0000000..6cbca03 --- /dev/null +++ b/PediFoods/Assets.xcassets/visacard_logo.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "visacard_logo.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/PediFoods/Assets.xcassets/visacard_logo.imageset/visacard_logo.png b/PediFoods/Assets.xcassets/visacard_logo.imageset/visacard_logo.png new file mode 100644 index 0000000..279c267 Binary files /dev/null and b/PediFoods/Assets.xcassets/visacard_logo.imageset/visacard_logo.png differ diff --git a/PediFoods/Components/AppBackButtonIcon.swift b/PediFoods/Components/AppBackButtonIcon.swift new file mode 100644 index 0000000..c84869a --- /dev/null +++ b/PediFoods/Components/AppBackButtonIcon.swift @@ -0,0 +1,13 @@ +import SwiftUI + +struct AppBackButtonIcon: View { + var body: some View { + Image(systemName: "chevron.left") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(AppColors.textPrimary) + .frame(width: 32, height: 32) + .background(AppColors.surface) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.06), radius: 8, y: 2) + } +} diff --git a/PediFoods/Components/Buttons.swift b/PediFoods/Components/Buttons.swift new file mode 100644 index 0000000..7c02c3d --- /dev/null +++ b/PediFoods/Components/Buttons.swift @@ -0,0 +1,70 @@ +import SwiftUI + +struct PrimaryButton: View { + let title: String + var fullWidth: Bool = true + var image: Image? = nil + let action: @MainActor @Sendable () -> Void + + var body: some View { + Button(action: { action() }) { + HStack { + Text(title) + .font(AppTypography.button) + .tracking(AppTypography.buttonLetterSpacing) + if let image { + image + } + } + .foregroundStyle(AppColors.textInverse) + .frame(maxWidth: fullWidth ? .infinity : nil) + .frame(height: 56) + .background(AppColors.primary) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + .buttonStyle(.plain) + } +} + +struct PrimaryButtonLabel: View { + let title: String + var fullWidth: Bool = true + + var body: some View { + Text(title) + .font(AppTypography.button) + .tracking(AppTypography.buttonLetterSpacing) + .foregroundStyle(AppColors.textInverse) + .frame(maxWidth: fullWidth ? .infinity : nil) + .frame(height: 56) + .background(AppColors.primary) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + .labelStyle(.titleOnly) + } +} + +struct SecondaryButton: View { + let title: String + var fullWidth: Bool = true + var image: Image? = nil + let action: @MainActor @Sendable () -> Void + + var body: some View { + Button(action: { action() }) { + HStack { + Text(title) + .font(AppTypography.button) + .tracking(AppTypography.buttonLetterSpacing) + if let image { + image + } + } + .foregroundStyle(AppColors.textPrimary) + .frame(maxWidth: fullWidth ? .infinity : nil) + .frame(height: 56) + .background(AppColors.secondary) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + .buttonStyle(.plain) + } +} diff --git a/PediFoods/Components/CachedRemoteImage.swift b/PediFoods/Components/CachedRemoteImage.swift new file mode 100644 index 0000000..84eefb6 --- /dev/null +++ b/PediFoods/Components/CachedRemoteImage.swift @@ -0,0 +1,146 @@ +import Foundation +import SwiftUI +import UIKit + +enum ImageFitMode { + /// Scale to cover the whole box, cropping whichever axis overflows. + /// Default — matches historical behavior for every image in the app. + case fill + /// Scale so height always matches the box exactly; width follows the + /// source's aspect ratio and gets cropped/gapped on the sides. Opt-in + /// only — used by the Store Detail cover header. + case heightFit +} + +struct CachedRemoteImage: View { + let imageURL: String? + let ttl: TimeInterval + let fitMode: ImageFitMode + let placeholder: Placeholder + + @StateObject var loader = CachedRemoteImageLoader() + + init( + imageURL: String?, + ttl: TimeInterval = AppCacheTTL.twoHours, + fitMode: ImageFitMode = .fill, + @ViewBuilder placeholder: () -> Placeholder + ) { + self.imageURL = imageURL + self.ttl = ttl + self.fitMode = fitMode + self.placeholder = placeholder() + } + + var body: some View { + GeometryReader { geometry in + Group { + if let uiImage = loader.uiImage { + rendered(image: Image(uiImage: uiImage), pixelSize: uiImage.size, in: geometry.size) + } else { + placeholder + } + } + .frame(width: geometry.size.width, height: geometry.size.height) + .clipped() + } + .onAppear { + loader.load(imageURL: imageURL, ttl: ttl) + } + .onChange(of: imageURL) { _, newValue in + loader.load(imageURL: newValue, ttl: ttl) + } + } + + @ViewBuilder + private func rendered(image: Image, pixelSize: CGSize, in containerSize: CGSize) -> some View { + switch fitMode { + case .fill: + image + .resizable() + .scaledToFill() + case .heightFit: + let aspect = pixelSize.height > 0 ? pixelSize.width / pixelSize.height : 1 + let renderWidth = containerSize.height * aspect + image + .resizable() + .frame(width: renderWidth, height: containerSize.height) + .frame(width: containerSize.width, height: containerSize.height) + } + } +} + +@MainActor +final class CachedRemoteImageLoader: ObservableObject { + @Published var uiImage: UIImage? + + private var currentKey: String? + private var task: Task? + + deinit { + task?.cancel() + } + + func load(imageURL: String?, ttl: TimeInterval) { + let normalized = Self.normalizeImageSource(imageURL) + let key = normalized ?? "" + guard currentKey != key else { return } + currentKey = key + + task?.cancel() + + uiImage = nil + + guard let normalized, normalized.isEmpty == false else { return } + + let dataCacheKey = Self.dataURLCacheKey(normalized) + if let cachedDataImage: PlatformImage = AppContentCache.shared.value(for: dataCacheKey, as: PlatformImage.self) { + uiImage = cachedDataImage + return + } + + if let image = Self.imageFromDataURL(normalized) { + AppContentCache.shared.set(image, for: dataCacheKey, ttl: ttl) + uiImage = image + return + } + + guard let url = URL(string: normalized) else { return } + + task = Task { [weak self] in + let image = await AppImageCache.shared.image(for: url, ttl: ttl) + guard Task.isCancelled == false else { return } + await MainActor.run { + self?.uiImage = image + } + } + } + + private static func normalizeImageSource(_ value: String?) -> String? { + ImageSourceResolver.resolve(value) + } + + private static func dataURLCacheKey(_ source: String) -> String { + let head = String(source.prefix(48)) + let tail = String(source.suffix(48)) + return "data-image:\(source.count):\(head):\(tail)" + } + + private static func imageFromDataURL(_ source: String) -> PlatformImage? { + let lower = source.lowercased() + guard lower.hasPrefix("data:image"), let commaIndex = source.firstIndex(of: ",") else { return nil } + + let header = String(source[.. Void)? = nil + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + ZStack(alignment: .topLeading) { + mediaBlock + + if let promo = store.promoText { + Text(promo) + .font(AppTypography.caption) + .foregroundStyle(AppColors.textInverse) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color.red) + .clipShape(Capsule()) + .padding(10) + } + + HStack { + Spacer() + Button { + onFavoriteToggle?() + } label: { + Image(systemName: store.isFavorite ? "heart.fill" : "heart") + .foregroundStyle(store.isFavorite ? Color.red : AppColors.textMuted) + .padding(8) + .background(AppColors.surface) + .clipShape(Circle()) + .shadow(color: AppShadow.soft.color, radius: AppShadow.soft.radius, y: AppShadow.soft.y) + } + .buttonStyle(.borderless) + .accessibilityLabel(store.isFavorite ? "Remover loja dos favoritos" : "Adicionar loja aos favoritos") + .padding(10) + } + } + + Text(store.name) + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + + if store.isOpen { + HStack(spacing: 6) { + Image(systemName: "star.fill") + .font(.caption) + .foregroundStyle(Color(hex: "#F5B335")) + Text(String(format: "%.1f", store.rating)) + .font(.caption) + .foregroundStyle(AppColors.textPrimary) + Text("(\(store.reviews))") + .font(.caption) + .foregroundStyle(AppColors.textMuted) + // Empty distance means "unavailable" - drop the separator + // with it, otherwise the row ends in a dangling "·". + // See docs/plans/public-store-distance-consumer.md. + if store.distance.isEmpty == false { + Text("·") + .font(.caption) + .foregroundStyle(AppColors.textMuted) + Text(store.distance) + .font(.caption) + .foregroundStyle(AppColors.textMuted) + } + } + } else { + Text(store.statusLabel?.isEmpty == false ? (store.statusLabel ?? "Fechado") : "Fechado") + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + } + + Text(store.category) + .font(.caption) + .foregroundStyle(AppColors.textMuted) + } + .padding(14) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous)) + .saturation(store.isOpen ? 1 : 0) + .opacity(store.isOpen ? 1 : 0.9) + } + + private var mediaBlock: some View { + ZStack { + RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous) + .fill(AppColors.brandSoft) + + mediaImage + } + .frame(height: 120) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + + @ViewBuilder + private var mediaImage: some View { + AsyncStoreImage(imageURL: store.imageURL) + } + + private var storeIconPlaceholder: some View { + ZStack { + Image("placeholder-product") + .resizable() + .scaledToFill() + .opacity(0.7) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .clipped() + + Circle() + .fill(AppColors.surface.opacity(0.92)) + .frame(width: 64, height: 64) + .overlay( + Image(systemName: store.iconName) + .font(.title2) + .foregroundStyle(AppColors.primary) + ) + } + } +} + +struct FeaturedStoreCardModel: Identifiable { + let id: String + let name: String + let rating: Double + let reviews: String + let distance: String + let deliveryFee: Double? + let category: String + let promoText: String? + let isFavorite: Bool + let iconName: String + let imageURL: String? + let logoURL: String? + let coverURL: String? + let isOpen: Bool + let statusLabel: String? +} + +struct SpecialOfferCard: View { + let model: SpecialOfferCardModel + + var body: some View { + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous) + .fill( + LinearGradient( + colors: model.colors, + startPoint: .leading, + endPoint: .trailing + ) + ) + + Circle() + .fill(Color.white.opacity(0.18)) + .frame(width: 120, height: 120) + .offset(x: 140, y: 10) + + VStack(alignment: .leading, spacing: 8) { + Text(model.title) + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textInverse) + Text(model.subtitle) + .font(AppTypography.body) + .foregroundStyle(AppColors.textInverse.opacity(0.85)) + } + .padding(20) + } + .shadow(color: AppShadow.soft.color, radius: AppShadow.soft.radius, y: AppShadow.soft.y) + } +} + +struct SpecialOfferCardModel: Identifiable { + let id: String + let title: String + let subtitle: String + let colors: [Color] +} diff --git a/PediFoods/Components/TextFieldCompat.swift b/PediFoods/Components/TextFieldCompat.swift new file mode 100644 index 0000000..225346f --- /dev/null +++ b/PediFoods/Components/TextFieldCompat.swift @@ -0,0 +1,25 @@ +import SwiftUI + +extension View { + @ViewBuilder + func appNoAutoCap() -> some View { +#if os(iOS) + self + .textInputAutocapitalization(.never) + .autocorrectionDisabled() +#else + self +#endif + } + + @ViewBuilder + func appOTPKeyboard() -> some View { +#if os(iOS) + self + .keyboardType(.numberPad) + .textContentType(.oneTimeCode) +#else + self +#endif + } +} diff --git a/PediFoods/ContentView.swift b/PediFoods/ContentView.swift new file mode 100644 index 0000000..90881e0 --- /dev/null +++ b/PediFoods/ContentView.swift @@ -0,0 +1,516 @@ +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 + private let tokenStore: TokenStore = DefaultTokenStore() + @State var appState = AppState() + @State var isBootstrappingSession = false + @State var hasPerformedInitialLaunchBootstrap = false + @State var showLaunchSplash = true + @State var shouldPulseLaunchSplash = true + @State private var sessionExpiredObserver: NSObjectProtocol? + @State var cartResetObserver: Any? + @State var appResumeObserver: Any? + @State var pushDeepLinkObserver: Any? + @StateObject var snackbarCenter = SnackbarCenter.shared + + var body: some View { + ZStack(alignment: .top) { + Group { + switch root { + case .auth: + AuthFlowView( + root: $root, + selectedTab: $selectedTab, + tokenStore: tokenStore, + appState: $appState + ) + .transition(.move(edge: .trailing)) + case .main: + if isBootstrappingSession { + sessionBootstrapLoadingView + } else { + MainTabView(selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState, enterAuth: enterAuthFlow) + .transition(.move(edge: .leading)) + } + } + } + + SnackbarOverlay(center: snackbarCenter) + + if showLaunchSplash { + LaunchSplashView(shouldPulse: shouldPulseLaunchSplash) + .transition(.opacity) + } + } + .sheet(item: $appState.activeModal, onDismiss: { appState.activeModal = nil }) { modal in + switch modal { + case .addressPicker: + AddressPickerModalView(appState: $appState, selectedTab: $selectedTab) + case .filters: + FiltersModalView(appState: $appState) + } + } + .onChange(of: appState.address.display) { _, _ in + dismissAddressPickerIfAddressExists() + } + .onChange(of: appState.address.selectedId) { _, _ in + dismissAddressPickerIfAddressExists() + } + .onChange(of: appState.address.latitude) { _, _ in + dismissAddressPickerIfAddressExists() + } + .onChange(of: appState.address.longitude) { _, _ in + dismissAddressPickerIfAddressExists() + } + .task { + await performInitialLaunchBootstrap() + } + .onChange(of: root) { _, newValue in + if newValue == .main { + Task { + await bootstrapSessionStateIfNeeded() + } + } + } + .onChange(of: appState.profile.id) { _, _ in + Task { @MainActor in + await refreshFeatureFlags(forceRefresh: true) + } + } + .onChange(of: appState.cart.storeId) { _, _ in + Task { @MainActor in + await refreshFeatureFlags(forceRefresh: true) + } + } + .onAppear { + attachCartResetObserverIfNeeded() + attachAppResumeObserverIfNeeded() + attachSessionExpiredObserverIfNeeded() + attachPushDeepLinkObserverIfNeeded() + } + .onDisappear { + detachCartResetObserver() + detachAppResumeObserver() + detachSessionExpiredObserver() + detachPushDeepLinkObserver() + } + } + + private var sessionBootstrapLoadingView: some View { + VStack(spacing: 12) { + ProgressView() + Text("Carregando sua sessão...") + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(AppColors.backgroundLight) + } + + @MainActor + private func performInitialLaunchBootstrap() async { + guard hasPerformedInitialLaunchBootstrap == false else { return } + hasPerformedInitialLaunchBootstrap = true + + let start = Date() + await bootstrapSessionStateIfNeeded() + + // Keep the in-app splash visible long enough to avoid abrupt transition + // between native launch screen and app content. + let elapsed = Date().timeIntervalSince(start) + let minimumSplashDuration: TimeInterval = 1.0 + if elapsed < minimumSplashDuration { + let remaining = minimumSplashDuration - elapsed + let nanoseconds = UInt64(remaining * 1_000_000_000) + try? await Task.sleep(nanoseconds: nanoseconds) + } + + if root == .auth { + shouldPulseLaunchSplash = false + try? await Task.sleep(nanoseconds: 180_000_000) + withAnimation(.easeInOut(duration: 0.34)) { + showLaunchSplash = false + } + return + } + + shouldPulseLaunchSplash = false + withAnimation(.easeOut(duration: 0.28)) { + showLaunchSplash = false + } + } + + @MainActor + private func bootstrapSessionStateIfNeeded() async { + guard root == .main else { return } + guard isBootstrappingSession == false else { return } + + isBootstrappingSession = true + + if let jwt = tokenStore.jwt { + appState.session.jwt = jwt + appState.session.isAuthenticated = true + } + + if let cachedAddress = SessionStateStore.loadAddress() { + appState.address = cachedAddress + dismissAddressPickerIfAddressExists() + } + + if let cachedCart = SessionStateStore.loadCart() { + appState.cart = cachedCart + } + + // Anonymous session: the account address cache above doesn't apply — + // restore the "ENTREGAR EM" label from the state/city picked via the + // public locator (GuestLocationStore persists this in Keychain across + // launches on its own; this just resyncs the display label with it). + if appState.session.isAuthenticated == false, + let guestState = GuestLocationStore.shared.selectedState, + let guestCity = GuestLocationStore.shared.selectedCity { + appState.address.display = "\(guestCity), \(guestState)" + dismissAddressPickerIfAddressExists() + } + + // Always refresh profile when authenticated. + // This keeps profile/address/cart scope consistent after relogin + // and avoids stale local state during checkout payload generation. + // Anonymous browsing has no session to refresh, so skip the call + // entirely rather than let it 401 and force-logout a guest. + if appState.session.isAuthenticated { + do { + let response = try await ApiService().profile() + if response.error == false, let customer = response.result { + hydrateAppState(with: customer) + } + } catch let error as ApiServiceError { + if case .sessionExpired = error { + forceLogoutToStart() + } + } catch { + // Keep local state when backend refresh fails transiently + } + } + + await refreshFeatureFlags(forceRefresh: false) + isBootstrappingSession = false + } + + private func attachCartResetObserverIfNeeded() { + guard cartResetObserver == nil else { return } + cartResetObserver = NotificationCenter.default.addObserver( + forName: .cartDidReset, + object: nil, + queue: nil + ) { _ in + Task { @MainActor in + appState.cart = CartState() + } + } + } + + private func detachCartResetObserver() { + guard let cartResetObserver else { return } + NotificationCenter.default.removeObserver(cartResetObserver) + self.cartResetObserver = nil + } + + private func attachAppResumeObserverIfNeeded() { + guard appResumeObserver == nil else { return } + appResumeObserver = NotificationCenter.default.addObserver( + forName: .appDidResume, + object: nil, + queue: nil + ) { _ in + Task { @MainActor in + guard root == .main else { return } + await refreshFeatureFlags(forceRefresh: true) + } + } + } + + private func detachAppResumeObserver() { + guard let appResumeObserver else { return } + NotificationCenter.default.removeObserver(appResumeObserver) + self.appResumeObserver = nil + } + + /// §6 of the push notifications guide, generalized: any tapped push that + /// resolves to a `DeepLinkDestination` (reported by + /// `PushNotificationCoordinator`) lands here. Adding a future promo/coupon + /// screen means adding one case to `route(to:)` below — this observer, + /// the notification name, and the AppState plumbing for it stay put. + private func attachPushDeepLinkObserverIfNeeded() { + guard pushDeepLinkObserver == nil else { return } + pushDeepLinkObserver = NotificationCenter.default.addObserver( + forName: .pushDeepLinkReceived, + object: nil, + queue: nil + ) { notification in + guard let destination = notification.userInfo?["destination"] as? DeepLinkDestination else { return } + Task { @MainActor in + route(to: destination) + } + } + } + + private func detachPushDeepLinkObserver() { + guard let pushDeepLinkObserver else { return } + NotificationCenter.default.removeObserver(pushDeepLinkObserver) + self.pushDeepLinkObserver = nil + } + + /// Pure mapping from a deep-link destination to what `route(to:)` should + /// do - split out so it's testable without a live `ContentView` + /// instance (this reads no `@State`, `route(to:)` is what applies the + /// result to `appState`/`selectedTab`). + func routeEffect(for destination: DeepLinkDestination) -> DeepLinkRouteEffect { + switch destination { + case .orderTracking(let orderId, let shortId): + return .navigateToOrder( + OrderRouteContext( + orderId: orderId, + shortId: shortId, + paymentMethod: nil, + total: nil, + intent: .auto + ), + tab: .profile + ) + case .screen(let name, let params): + // Promo/coupon screens (participating stores/products, §6's + // `targetScreen` convention) land here once they exist — see + // decisions/2026-08-04-push-deeplink-routing-contract.md. + logger.debug("Unhandled deep-link screen: \(name, privacy: .public) params=\(params, privacy: .public)") + return .none + } + } + + @MainActor + private func route(to destination: DeepLinkDestination) { + guard root == .main else { return } + switch routeEffect(for: destination) { + case .navigateToOrder(let context, let tab): + appState.pendingOrderDeepLink = context + selectedTab = tab + case .none: + break + } + } + + @MainActor + private func hydrateAppState(with customer: CustomerProfile) { + appState.profile.id = customer.id + appState.profile.name = customer.name + appState.profile.email = customer.email + appState.profile.phone = customer.phoneNumber ?? "" + appState.profile.profilePicture = customer.profilePicture ?? "" + appState.favorites.storeIds = Set(customer.favorites ?? []) + SessionStateStore.setActiveUserKey( + SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email) + ) + if let cachedCart = SessionStateStore.loadCart() { + appState.cart = cachedCart + } else { + appState.cart = CartState() + } + + let addresses = customer.addressBook ?? [] + guard addresses.isEmpty == false else { + appState.address = AddressState() + SessionStateStore.clearAddress() + return + } + + let preferredAddress = resolvePreferredAddress(from: addresses, current: appState.address) + if let preferredAddress { + applyAddress(preferredAddress) + SessionStateStore.saveAddress(appState.address) + } + } + + private func resolvePreferredAddress(from addresses: [CustomerAddress], current: AddressState) -> CustomerAddress? { + guard addresses.isEmpty == false else { return nil } + + if let selectedId = current.selectedId?.trimmingCharacters(in: .whitespacesAndNewlines), + selectedId.isEmpty == false, + let byId = addresses.first(where: { ($0.id ?? "").trimmingCharacters(in: .whitespacesAndNewlines) == selectedId }) { + return byId + } + + let normalizedDisplay = current.display + .trimmingCharacters(in: .whitespacesAndNewlines) + .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) + .lowercased() + + if normalizedDisplay.isEmpty == false && normalizedDisplay != "defina seu endereco", + let byLabel = addresses.first(where: { + (($0.label ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) + .lowercased()) == normalizedDisplay + }) { + return byLabel + } + + if let lat = current.latitude, let lng = current.longitude, + let byCoordinate = addresses.first(where: { address in + guard let addrLat = address.latLong?.first, + let addrLng = address.latLong?.dropFirst().first else { return false } + return abs(addrLat - lat) < 0.00001 && abs(addrLng - lng) < 0.00001 + }) { + return byCoordinate + } + + return addresses.first + } + + @MainActor + private func applyAddress(_ address: CustomerAddress) { + appState.address.selectedId = address.id + + let cleanLabel = (address.label ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + appState.address.display = cleanLabel.isEmpty ? "Defina seu endereco" : cleanLabel + + if let lat = address.latLong?.first, let lng = address.latLong?.dropFirst().first { + appState.address.latitude = lat + appState.address.longitude = lng + } else { + appState.address.latitude = nil + appState.address.longitude = nil + } + } + + @MainActor + private func forceLogoutToStart() { + tokenStore.clear() + SessionStateStore.clearActiveUser() + SessionStateStore.clearTrackedOrders() + SessionStateStore.clearPendingCartOrder() + AppContentCache.shared.invalidate() + AppImageCache.shared.invalidateAll() + isBootstrappingSession = false + appState = AppState() + selectedTab = .home + enterAuthFlow() + } + + /// The single funnel for entering the auth flow. `LoginView` is a + /// static screen with its own `LCENavigationView` back button (sets + /// `root = .main`), so no animation priming is needed here - see + /// decisions/2026-08-27-payment-auth-lockout-clean-intro-screen.md. + @MainActor + private func enterAuthFlow() { + withAnimation(.easeInOut(duration: 0.3)) { + root = .auth + } + } + + @MainActor + private func refreshFeatureFlags(forceRefresh: Bool) async { + guard root == .main else { return } + + let subjectType: String + let subjectId: String + if let profileId = appState.profile.id?.trimmingCharacters(in: .whitespacesAndNewlines), profileId.isEmpty == false { + subjectType = "customer" + subjectId = profileId + } else { + subjectType = "anonymous" + subjectId = "anonymous-device" + } + + var attrs: [String: String] = [:] + let addressLabel = appState.address.display.trimmingCharacters(in: .whitespacesAndNewlines) + if addressLabel.isEmpty == false, addressLabel.lowercased() != "defina seu endereco" { + attrs["addressLabel"] = addressLabel + } + + let context = FeatureControlEvaluationContext( + subjectType: subjectType, + subjectId: subjectId, + storeId: appState.cart.storeId, + attributes: attrs + ) + + let snapshot = await FeatureControlService.shared.evaluate( + context: context, + jwt: appState.session.jwt, + forceRefresh: forceRefresh + ) + appState.featureFlags = snapshot + await FeatureControlService.shared.sendExposureEvents( + snapshot: snapshot, + context: context, + jwt: appState.session.jwt + ) + } + + private func hasConfiguredAddress() -> Bool { + if appState.address.selectedId != nil { + return true + } + let normalized = appState.address.display + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + return normalized.isEmpty == false && normalized != "defina seu endereco" + } + + @MainActor + private func dismissAddressPickerIfAddressExists() { + guard appState.activeModal == .addressPicker else { return } + if hasConfiguredAddress() { + appState.activeModal = nil + } + } + + private func attachSessionExpiredObserverIfNeeded() { + guard sessionExpiredObserver == nil else { return } + sessionExpiredObserver = NotificationCenter.default.addObserver( + forName: .sessionExpired, + object: nil, + queue: .main + ) { _ in + Task { @MainActor in + forceLogoutToStart() + } + } + } + + private func detachSessionExpiredObserver() { + guard let observer = sessionExpiredObserver else { return } + NotificationCenter.default.removeObserver(observer) + sessionExpiredObserver = nil + } +} + +struct AddressPickerModalView: View { + @Binding var appState: AppState + @Binding var selectedTab: MainTab + + var body: some View { + NavigationStack { + if appState.session.isAuthenticated { + AddressesView( + message: appState.address.onboardingMessage, + appState: $appState, + selectionMode: true + ) + .onAppear { + appState.address.onboardingMessage = nil + } + } else { + PublicLocationPickerView(appState: $appState) + } + } + } +} diff --git a/PediFoods/Coordinators/AppCoordinator.swift b/PediFoods/Coordinators/AppCoordinator.swift new file mode 100644 index 0000000..50ee526 --- /dev/null +++ b/PediFoods/Coordinators/AppCoordinator.swift @@ -0,0 +1,10 @@ +enum RootFlow: Hashable { + case auth + case main +} + +enum MainTab: Hashable { + case home + case cart + case profile +} diff --git a/PediFoods/Coordinators/DeepLinkDestination.swift b/PediFoods/Coordinators/DeepLinkDestination.swift new file mode 100644 index 0000000..1a1b489 --- /dev/null +++ b/PediFoods/Coordinators/DeepLinkDestination.swift @@ -0,0 +1,49 @@ +import Foundation + +/// Single decode target for anything that can deep-link into the app — push +/// notification taps today (docs/api/push-notifications-integration-guide.md +/// §6), in-app promo/discount banner taps tomorrow (same shape: a string-keyed +/// dictionary naming a screen + its params). Adding a new destination means +/// adding one case here + one branch in `ContentView.route(to:)` — never a new +/// `NotificationCenter` name, `AppState` field, or observer pair. +enum DeepLinkDestination: Equatable { + /// `type: "order_status"` — the only push type Atomenta fixes today + /// (§6), so it gets its own case instead of routing through `targetScreen`. + case orderTracking(orderId: String, shortId: String?) + + /// Everything else, keyed by `targetScreen` — our own convention (§6 + /// leaves this to us). Once a real screen exists for a given name (e.g. + /// future promo/coupon "list of participating stores/products"), give it + /// its own case; until then this carries the raw params so nothing is + /// silently dropped. + case screen(name: String, params: [String: String]) +} + +/// Resolves the *navigation* half of a tapped push's payload. Pure and +/// side-effect-free on purpose: easy to unit test, and reusable as-is for +/// in-app banner taps once those exist. Campaign-open reporting (§6a) is a +/// separate side effect, not a navigation target, and is not decided here — +/// see `PushNotificationCoordinator.handleTap`. +struct PushDeepLinkParser { + static func parse(_ userInfo: [AnyHashable: Any]) -> DeepLinkDestination? { + if let type = userInfo["type"] as? String, type == "order_status" { + // Atomenta's real order-status push (PushNotificationService. + // notifyOrderStatusUpdate) sends only `shortId`, never `orderId` — + // `GET /api/public/orders/:orderId` accepts either as the lookup + // key server-side, so `shortId` doubles as the id here too. + let shortId = userInfo["shortId"] as? String + guard let orderId = (userInfo["orderId"] as? String) ?? shortId else { return nil } + return .orderTracking(orderId: orderId, shortId: shortId) + } + + guard let targetScreen = userInfo["targetScreen"] as? String else { return nil } + var params: [String: String] = [:] + for (key, value) in userInfo { + guard let key = key as? String, key != "type", key != "targetScreen" else { continue } + if let stringValue = value as? String { + params[key] = stringValue + } + } + return .screen(name: targetScreen, params: params) + } +} diff --git a/PediFoods/DesignSystem/DesignSystem.swift b/PediFoods/DesignSystem/DesignSystem.swift new file mode 100644 index 0000000..7ea032c --- /dev/null +++ b/PediFoods/DesignSystem/DesignSystem.swift @@ -0,0 +1,125 @@ +import SwiftUI +import UIKit + +// MARK: - Colors + +struct AppColors { + private init() {} + // Brandbook 2026 (Pedi Foods) + // Primary now follows the "FOODS" dark green tone from the logo. + static let primary = Color(.sRGB, red: 52/255.0, green: 93/255.0, blue: 84/255.0, opacity: 1.0) + // Secondary keeps the vivid lime from the symbol/logo body. + static let secondary = Color(.sRGB, red: 213/255.0, green: 216/255.0, blue: 65/255.0, opacity: 1.0) + static let tertiary = Color(.sRGB, red: 167/255.0, green: 191/255.0, blue: 66/255.0, opacity: 1.0) + + static let brandDark = Color(.sRGB, red: 31/255.0, green: 45/255.0, blue: 64/255.0, opacity: 1.0) + static let brandSoft = Color(.sRGB, red: 242/255.0, green: 245/255.0, blue: 227/255.0, opacity: 1.0) + + static let backgroundLight = Color(.sRGB, red: 243/255.0, green: 245/255.0, blue: 247/255.0, opacity: 1.0) + static let backgroundDark = Color(.sRGB, red: 31/255.0, green: 45/255.0, blue: 64/255.0, opacity: 1.0) + static let surface = Color.white + static let textPrimary = Color.black + static let textInverse = Color.white + static let textMuted = Color(.sRGB, red: 102/255.0, green: 112/255.0, blue: 133/255.0, opacity: 1.0) +} + +struct AppDarkColors { + private init() {} + static let background = Color(.sRGB, red: 31/255.0, green: 45/255.0, blue: 64/255.0, opacity: 1.0) + static let surface = Color.white.opacity(0.08) + static let textPrimary = Color.white + static let textSecondary = Color(.sRGB, red: 208/255.0, green: 213/255.0, blue: 221/255.0, opacity: 1.0) + static let primary = AppColors.primary + static let secondary = AppColors.secondary + static let tertiary = AppColors.tertiary +} + +// MARK: - Typography + +struct AppTypography { + private init() {} + // Brandbook typography: Nexa (fallback: system font) + static let fontFamily = "Nexa-Regular" + + static let heading1 = resolvedFont(size: 28, fallbackWeight: .bold) + static let heading25 = resolvedFont(size: 25, fallbackWeight: .bold) + static let heading2 = resolvedFont(size: 20, fallbackWeight: .semibold) + static let heading3 = resolvedFont(size: 16, fallbackWeight: .semibold) + static let body = resolvedFont(size: 16, fallbackWeight: .regular) + static let button = resolvedFont(size: 14, fallbackWeight: .semibold) + static let caption = resolvedFont(size: 10, fallbackWeight: .regular) + static let overline = resolvedFont(size: 11, fallbackWeight: .regular) + + static let bodyLineHeight: CGFloat = 1.6 + static let buttonLetterSpacing: CGFloat = 0.08 + static let captionLetterSpacing: CGFloat = 0.12 + + private static func resolvedFont(size: CGFloat, fallbackWeight: Font.Weight) -> Font { + if isBrandFontAvailable { + return Font.custom(fontFamily, size: size) + } + return .system(size: size, weight: fallbackWeight, design: .default) + } + + private static var isBrandFontAvailable: Bool { + UIFont(name: fontFamily, size: 16) != nil + } +} + +// MARK: - Layout + +struct AppLayout { + private init() {} + static let radiusMD: CGFloat = 12 + static let radiusLG: CGFloat = 16 + static let radiusXL: CGFloat = 24 + static let radiusFull: CGFloat = 9999 + + static let spacing: [CGFloat] = [4, 8, 12, 16, 24, 32] +} + +// MARK: - Shadow + +struct ShadowSpec { + let color: Color + let radius: CGFloat + let y: CGFloat +} + +struct AppShadow { + private init() {} + static let soft = ShadowSpec(color: Color.black.opacity(0.08), radius: 20, y: 6) + static let glow = ShadowSpec(color: AppColors.tertiary.opacity(0.2), radius: 24, y: 8) +} + +// MARK: - Helpers + +extension Color { + init(hex: String, alpha: Double = 1.0) { + let cleaned = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var int: UInt64 = 0 + if let val = UInt64(cleaned, radix: 16) { + int = val + } + + let r, g, b: UInt64 + switch cleaned.count { + case 6: // RRGGBB + r = (int >> 16) & 0xFF + g = (int >> 8) & 0xFF + b = int & 0xFF + case 3: // RGB + r = ((int >> 8) & 0xF) * 17 + g = ((int >> 4) & 0xF) * 17 + b = (int & 0xF) * 17 + default: + r = 0; g = 0; b = 0 + } + + self.init(.sRGB, + red: Double(r) / 255, + green: Double(g) / 255, + blue: Double(b) / 255, + opacity: alpha) + } +} diff --git a/pedi-foods/Darwin/Entitlements.plist b/PediFoods/Info.plist similarity index 92% rename from pedi-foods/Darwin/Entitlements.plist rename to PediFoods/Info.plist index 6631ffa..0c67376 100644 --- a/pedi-foods/Darwin/Entitlements.plist +++ b/PediFoods/Info.plist @@ -1,6 +1,5 @@ - - + diff --git a/PediFoods/LaunchScreen.storyboard b/PediFoods/LaunchScreen.storyboard new file mode 100644 index 0000000..6383af8 --- /dev/null +++ b/PediFoods/LaunchScreen.storyboard @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/PediFoods/PediFoods.entitlements b/PediFoods/PediFoods.entitlements new file mode 100644 index 0000000..80d962c --- /dev/null +++ b/PediFoods/PediFoods.entitlements @@ -0,0 +1,16 @@ + + + + + aps-environment + development + com.apple.developer.devicecheck.app-attest-opt-in + + CDhash + + com.apple.developer.devicecheck.appattest-environment + $(APP_ATTEST_ENVIRONMENT) + com.apple.developer.location.push + + + 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/PediFoods/Resources/Localizable.xcstrings b/PediFoods/Resources/Localizable.xcstrings new file mode 100644 index 0000000..7099a48 --- /dev/null +++ b/PediFoods/Resources/Localizable.xcstrings @@ -0,0 +1,1297 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "" : { + + }, + "- %@" : { + "comment" : "A text element displaying a negative monetary value, indicating a discount.", + "isCommentAutoGenerated" : true + }, + "..." : { + + }, + "·" : { + "comment" : "A period character used to separate different pieces of information in a list.", + "isCommentAutoGenerated" : true + }, + "(%@)" : { + "comment" : "A text element showing the number of reviews for a store. The content inside the parentheses is the number of reviews.", + "isCommentAutoGenerated" : true + }, + "%lld" : { + "comment" : "A label displaying the quantity of a particular item in the user's cart.", + "isCommentAutoGenerated" : true + }, + "%lld Items" : { + "comment" : "Header title for a list that contains the number of items", + "extractionState" : "stale", + "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld elementos" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld éléments" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アイテム数 %lld" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 个条目" + } + } + } + }, + "%lldx" : { + + }, + "%lldx %@" : { + "comment" : "A line that shows the quantity of an ordered item and its name. The first argument is the quantity of the item. The second argument is the name of the item.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lldx %2$@" + } + } + } + }, + "•" : { + + }, + "+ %@" : { + "comment" : "A small, secondary text label displaying the price of an item.", + "isCommentAutoGenerated" : true + }, + "0000 0000 0000 0000" : { + + }, + "1km" : { + "comment" : "A label for the minimum distance filter option.", + "isCommentAutoGenerated" : true + }, + "10km" : { + "comment" : "A label displayed next to the far end of the distance slider in the filters modal.", + "isCommentAutoGenerated" : true + }, + "Abrir Ajustes" : { + + }, + "Acesse sua conta" : { + + }, + "Acompanhamento em tempo real" : { + + }, + "Acompanhar" : { + "comment" : "A button label that translates to \"Track\" in English.", + "isCommentAutoGenerated" : true + }, + "Add" : { + "comment" : "Button in items list that will cause a new item to be added", + "extractionState" : "stale", + "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Añadir" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajouter" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "追加" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "添加" + } + } + } + }, + "Adicionais" : { + "comment" : "A heading for the additional options available for a product.", + "isCommentAutoGenerated" : true + }, + "Adicionar fotos" : { + "comment" : "A label for adding photos in the order review section.", + "isCommentAutoGenerated" : true + }, + "Adicionar loja aos favoritos" : { + + }, + "Adicionar novo cartão" : { + + }, + "Adicionar novo endereço" : { + "comment" : "A button label that translates to \"Add new address\" in English.", + "isCommentAutoGenerated" : true + }, + "Adicione produtos para continuar." : { + "comment" : "A message displayed when the cart is empty, encouraging the user to add products.", + "isCommentAutoGenerated" : true + }, + "Agora não" : { + + }, + "AGUARDANDO PAGAMENTO" : { + "comment" : "A status text indicating that the payment is pending.", + "isCommentAutoGenerated" : true + }, + "Ajuda" : { + "comment" : "A link to the help section of the app.", + "isCommentAutoGenerated" : true + }, + "Alterar" : { + "comment" : "A button label that translates to \"Change\" in English.", + "isCommentAutoGenerated" : true + }, + "Ao menos um cartão deve permanecer" : { + + }, + "Ao menos um endereço deve permanecer" : { + + }, + "Ao se cadastrar, você concorda com nossos [Termos de Uso](app://terms) e [Política de Privacidade](app://policy)" : { + "comment" : "A text that informs the user that registering implies acceptance of the app's terms of use and privacy policy. The text is clickable and navigates to the respective views when tapped.", + "isCommentAutoGenerated" : true + }, + "Aplicar" : { + "comment" : "A button that applies a coupon code to a cart.", + "isCommentAutoGenerated" : true + }, + "Aplicar Filtros" : { + "comment" : "A button label that translates to \"Apply Filters\".", + "isCommentAutoGenerated" : true + }, + "Appearance" : { + "comment" : "Settings select label for the interface style of the controls (light, dark, or default)", + "extractionState" : "stale", + "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apariencia" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apparence" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "外観" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "外观" + } + } + } + }, + "Até %lldkm" : { + "comment" : "A label displaying the maximum distance filter value in kilometers. The argument is the current maximum distance filter value in kilometers.", + "isCommentAutoGenerated" : true + }, + "Ativar" : { + + }, + "Ative as notificações" : { + + }, + "Ative as notificações para acompanhar em tempo real as atualizações do seu pedido." : { + + }, + "Atualizando status do pedido..." : { + + }, + "Atualizando..." : { + + }, + "Avaliar Pedido" : { + + }, + "AVALIAR PEDIDO" : { + + }, + "Avaliar pedido #%@" : { + "comment" : "A button that lets the user review a previously placed order. The placeholder `#\\(initialOrder.shortId ?? initialOrder.orderId)` will be replaced with the actual order number when displayed.", + "isCommentAutoGenerated" : true + }, + "Avalie o Pedi Foods também" : { + "comment" : "A heading that asks users to review Pedi Foods.", + "isCommentAutoGenerated" : true + }, + "Boas-vindas!" : { + "comment" : "A welcome message displayed in the login view.", + "isCommentAutoGenerated" : true + }, + "Buscando endereço pelo CEP..." : { + "comment" : "A message displayed while an address is being looked up by ZIP code.", + "isCommentAutoGenerated" : true + }, + "Buscando estabelecimentos próximos..." : { + "comment" : "A message indicating that the app is searching for nearby stores.", + "isCommentAutoGenerated" : true + }, + "Cancel" : { + "comment" : "Button title indicating that the operation should be cancelled", + "extractionState" : "stale", + "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancelar" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Annuler" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "キャンセル" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "取消" + } + } + } + }, + "Cancelar" : { + "comment" : "The buttons in an alert that ask the user to confirm leaving their account.", + "isCommentAutoGenerated" : true + }, + "Cardápio indisponível no momento." : { + "comment" : "A message displayed when a store's menu is unavailable.", + "isCommentAutoGenerated" : true + }, + "Carregando cardápio..." : { + + }, + "Carregando endereços..." : { + + }, + "Carregando pedido..." : { + + }, + "Carregando sua sessão..." : { + "comment" : "A loading message displayed while bootstrapping the user's session.", + "isCommentAutoGenerated" : true + }, + "Cartão de Crédito" : { + + }, + "Categorias" : { + "comment" : "A heading for the list of available categories in the filters modal.", + "isCommentAutoGenerated" : true + }, + "Categories" : { + "comment" : "A section header that lists available food categories.", + "isCommentAutoGenerated" : true + }, + "Código PIX" : { + "comment" : "The title of the field that displays the PIX code.", + "isCommentAutoGenerated" : true + }, + "Colar código" : { + "comment" : "A button that allows the user to paste their OTP code directly into the field.", + "isCommentAutoGenerated" : true + }, + "Comentário" : { + "comment" : "A label for the comment section in the order review view.", + "isCommentAutoGenerated" : true + }, + "Conexão segura e criptografada" : { + "comment" : "A description below the \"Verificar e Entrar\" button, emphasizing the security of the login process.", + "isCommentAutoGenerated" : true + }, + "Confirmar e Pagar" : { + "comment" : "A button label that translates to \"Confirm and Pay\".", + "isCommentAutoGenerated" : true + }, + "CONTATO" : { + "comment" : "The text on a button that takes the user to contact support.", + "isCommentAutoGenerated" : true + }, + "Conte-nos se gostou ou não." : { + "comment" : "A prompt asking users to share their opinion about the delivery.", + "isCommentAutoGenerated" : true + }, + "Crie sua conta" : { + "comment" : "A link that directs users to create an account.", + "isCommentAutoGenerated" : true + }, + "Cupom aplicado: %@" : { + "comment" : "A text label displaying the code of the coupon that has been successfully applied to the cart. The argument is the code of the applied coupon.", + "isCommentAutoGenerated" : true + }, + "Cupom de Desconto" : { + "comment" : "A section header for entering and applying a discount coupon.", + "isCommentAutoGenerated" : true + }, + "Cupons de Desconto" : { + + }, + "Dark" : { + "comment" : "Menu item indicating that the appearance should be in dark mode", + "extractionState" : "stale", + "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oscuro" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sombre" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ダーク" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "深色" + } + } + } + }, + "Date" : { + "comment" : "Item editor form label for the Date field", + "extractionState" : "stale", + "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fecha" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Date" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "日付" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "日期" + } + } + } + }, + "Definir como principal" : { + + }, + "Desconto" : { + "comment" : "A label for a discount applied to an order.", + "isCommentAutoGenerated" : true + }, + "Detalhes" : { + "comment" : "The title of the navigation bar at the top of the product detail sheet.", + "isCommentAutoGenerated" : true + }, + "Detalhes do Pedido" : { + + }, + "Distância" : { + + }, + "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.", + "isCommentAutoGenerated" : true + }, + "Endereço de cobrança" : { + + }, + "ENDEREÇO DE ENTREGA" : { + "comment" : "A label displayed above the address section when delivery is selected.", + "isCommentAutoGenerated" : true + }, + "Entrar" : { + "comment" : "A link that directs the user to the login screen.", + "isCommentAutoGenerated" : true + }, + "Entrar ou Cadastrar" : { + + }, + "Entre na sua conta" : { + + }, + "Entrega" : { + "comment" : "A text describing delivery mode.", + "isCommentAutoGenerated" : true + }, + "ENTREGAR EM:" : { + "comment" : "A label displayed next to the button that lets the user select an address.", + "isCommentAutoGenerated" : true + }, + "Enviar Avaliação" : { + + }, + "Enviaremos um código de verificação por SMS \ne E-mail para confirmar seu acesso." : { + + }, + "Escaneie o QR Code abaixo ou copie o código para pagar no seu aplicativo do banco." : { + "comment" : "A description under the QR code that instructs the user on how to pay using their bank app.", + "isCommentAutoGenerated" : true + }, + "Escolha de 1 a 5 estrelas para classificar." : { + "comment" : "A description under the star rating section of the order review view.", + "isCommentAutoGenerated" : true + }, + "Escolha o tamanho da sua fome" : { + "comment" : "A label displayed below the pizza size selection.", + "isCommentAutoGenerated" : true + }, + "Escolha seu estado" : { + + }, + "Escolha seu sabor" : { + + }, + "Escolha sua cidade" : { + + }, + "Essa ação é permanente. Seus dados de perfil, endereços, cartões e favoritos serão excluídos e não poderão ser recuperados." : { + + }, + "Este sabor não possui adicionais." : { + "comment" : "A message displayed when a pizza flavor does not have any add-ons.", + "isCommentAutoGenerated" : true + }, + "Excluir" : { + "comment" : "A button label that translates to \"Delete\".", + "isCommentAutoGenerated" : true + }, + "Excluir Conta" : { + + }, + "Excluir sua conta?" : { + + }, + "Faça login ou cadastre-se para ver seu perfil, pedidos e endereços." : { + + }, + "Favorite" : { + "comment" : "Item editor title label for marking the item as a favorite", + "extractionState" : "stale", + "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Favorito" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Favori" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "お気に入り" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "收藏" + } + } + } + }, + "Fechar" : { + "comment" : "A button to close the current view.", + "isCommentAutoGenerated" : true + }, + "Filtros" : { + "comment" : "A label for the filter options in the modal.", + "isCommentAutoGenerated" : true + }, + "Finalizar Pedido" : { + "comment" : "The title of the checkout screen.", + "isCommentAutoGenerated" : true + }, + "Home" : { + "comment" : "Tab bar item title for the Home tab", + "extractionState" : "stale", + "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inicio" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Accueil" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ホーム" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "首页" + } + } + } + }, + "Inclui adicionais: %@" : { + "comment" : "A footnote at the bottom of the checkout view showing the cost of any selected additional items. The argument is the total cost of the selected additional items.", + "isCommentAutoGenerated" : true + }, + "Inserir cupom" : { + "comment" : "A placeholder text for a text field where a user can input a coupon code.", + "isCommentAutoGenerated" : true + }, + "Insira o código de 8 dígitos enviado" : { + "comment" : "A description below the text field where the user inputs their OTP code.", + "isCommentAutoGenerated" : true + }, + "Ir para o Pagamento" : { + "comment" : "A button label that translates to \"Go to Payment\" in English.", + "isCommentAutoGenerated" : true + }, + "Itens do Pedido" : { + + }, + "Já tem uma conta?" : { + "comment" : "A question displayed below the \"Cadastrar e Receber Código\" button in the registration view. The text is a hyperlink to the login view.", + "isCommentAutoGenerated" : true + }, + "Ligar para %@" : { + + }, + "Ligar para a loja?" : { + + }, + "Light" : { + "comment" : "Menu item indicating that the appearance should be in light mode", + "extractionState" : "stale", + "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Claro" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Clair" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "明るい" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "浅色" + } + } + } + }, + "Limpar" : { + "comment" : "A button that clears all filters when pressed.", + "isCommentAutoGenerated" : true + }, + "Limpar carrinho e adicionar" : { + "comment" : "A button that clears the current cart and adds the selected product.", + "isCommentAutoGenerated" : true + }, + "Limpar e adicionar" : { + + }, + "Loja não informou métodos presenciais." : { + + }, + "Mais rápido e seguro" : { + + }, + "MÉTODO DE PAGAMENTO" : { + "comment" : "A label displayed above the payment methods section in the checkout view.", + "isCommentAutoGenerated" : true + }, + "Meu Carrinho" : { + "comment" : "The title of the user's cart screen.", + "isCommentAutoGenerated" : true + }, + "Meu Perfil" : { + "comment" : "The title of the user profile view.", + "isCommentAutoGenerated" : true + }, + "Meus Cartões" : { + + }, + "Meus Endereços" : { + + }, + "Meus Pedidos" : { + + }, + "Minhas Avaliações" : { + "comment" : "A button that links to their reviews.", + "isCommentAutoGenerated" : true + }, + "Monte sua pizza" : { + "comment" : "The title of the sheet that allows users to customize and add pizzas to their cart.", + "isCommentAutoGenerated" : true + }, + "Motivo do cancelamento" : { + + }, + "Na entrega/retirada com a loja" : { + + }, + "Name" : { + "comment" : "Placeholder title for the Name field in a form", + "extractionState" : "stale", + "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nombre" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nom" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "名前" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "姓名" + } + } + } + }, + "Não" : { + "comment" : "The label of a button in an alert that says \"No\".", + "isCommentAutoGenerated" : true + }, + "Não foi possível carregar %@." : { + + }, + "Não recebeu o código?" : { + "comment" : "A question displayed below the button to re-send the OTP.", + "isCommentAutoGenerated" : true + }, + "Nenhum cartão cadastrado" : { + + }, + "Nenhum endereço cadastrado" : { + "comment" : "A message displayed when a user has no addresses saved.", + "isCommentAutoGenerated" : true + }, + "Nenhum pedido encontrado." : { + + }, + "Nota do pedido" : { + "comment" : "A title for the rating of a review.", + "isCommentAutoGenerated" : true + }, + "Notes" : { + "comment" : "Item editor form label for the Notes field", + "extractionState" : "stale", + "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notas" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notes" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ノート" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "笔记" + } + } + } + }, + "Notificações desativadas" : { + + }, + "Novo cartão" : { + + }, + "Novo Cartão" : { + + }, + "Novo endereço" : { + + }, + "Novo por aqui?" : { + "comment" : "A text that appears at the bottom of the screen, inviting users to create an account.", + "isCommentAutoGenerated" : true + }, + "Número do cartão" : { + + }, + "O que pode melhorar?" : { + "comment" : "A label for the section where users can select tags they think could improve an order.", + "isCommentAutoGenerated" : true + }, + "O que podemos melhorar? Conte-nos sua experiência..." : { + "comment" : "A placeholder text displayed in the text editor of the review draft view, when the text editor is empty.", + "isCommentAutoGenerated" : true + }, + "O que vai querer \npedir hoje?" : { + "comment" : "A title displayed above the search bar in the home view.", + "isCommentAutoGenerated" : true + }, + "O que você achou do pedido?" : { + "comment" : "A question prompt asking users to rate their experience with the order.", + "isCommentAutoGenerated" : true + }, + "O que você achou do pedido? Conte-nos sua experiência..." : { + "comment" : "A placeholder text displayed in the text editor of the order review view, instructing the user to leave a comment about their experience with the order.", + "isCommentAutoGenerated" : true + }, + "Obrigatório" : { + "comment" : "A label indicating a required field.", + "isCommentAutoGenerated" : true + }, + "Ordenar por" : { + "comment" : "A label describing the sorting options available in the filters modal.", + "isCommentAutoGenerated" : true + }, + "ou" : { + + }, + "Pagamento via PIX" : { + "comment" : "The navigation title for the PaymentPixView.", + "isCommentAutoGenerated" : true + }, + "Pagar Na Maquininha" : { + + }, + "Pagar Pelo App" : { + + }, + "Pague mais rápido nas próximas compras" : { + + }, + "Para receber notificações sobre seus pedidos, ative a permissão nos Ajustes do iPhone." : { + + }, + "Pedido #%@" : { + + }, + "Pedido #%@ • %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Pedido #%1$@ • %2$@" + } + } + } + }, + "Pedido %@" : { + + }, + "Pedir Novamente" : { + "comment" : "A button that allows a user to order the same items again.", + "isCommentAutoGenerated" : true + }, + "Pizza de varios sabores" : { + "comment" : "A title displayed above the main content of the view.", + "extractionState" : "stale", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escolha seu sabor" + } + } + } + }, + "Política de Privacidade" : { + "comment" : "The title of the privacy policy section.", + "isCommentAutoGenerated" : true + }, + "Precisa de ajuda com esse pedido?" : { + + }, + "Preço" : { + "comment" : "A label describing the price filter section in the filters modal.", + "isCommentAutoGenerated" : true + }, + "Preencha os dados abaixo para começar." : { + "comment" : "A description below the form fields in the registration view, instructing the user to fill them out to start.", + "isCommentAutoGenerated" : true + }, + "Principal" : { + + }, + "PRINCIPAL" : { + "comment" : "A label indicating that a particular address is the user's primary address.", + "isCommentAutoGenerated" : true + }, + "Progresso do Pedido" : { + + }, + "Reavaliar" : { + "comment" : "The text for a button that allows a user to re-evaluate an order.", + "isCommentAutoGenerated" : true + }, + "Remover" : { + "comment" : "A button that deletes the user's profile picture.", + "isCommentAutoGenerated" : true + }, + "Remover loja dos favoritos" : { + + }, + "RESTAURANT" : { + + }, + "Resumo de Valores" : { + "comment" : "A section header in the cart view that translates to \"Summary of Values\".", + "isCommentAutoGenerated" : true + }, + "RESUMO DO PEDIDO" : { + "comment" : "The title of the section that summarizes the order details.", + "isCommentAutoGenerated" : true + }, + "Retirada" : { + "comment" : "A label for the \"Pickup\" option in the checkout interface.", + "isCommentAutoGenerated" : true + }, + "Sair" : { + + }, + "Sair da Conta" : { + "comment" : "A button label that translates to \"Log out\" in English.", + "isCommentAutoGenerated" : true + }, + "Sair da conta?" : { + + }, + "Salvando..." : { + "comment" : "The title of a button while it is \"saving\" (e.g. waiting for a network request to complete).", + "isCommentAutoGenerated" : true + }, + "Salvar Alterações" : { + "comment" : "The text on a button that saves changes to a user's profile.", + "isCommentAutoGenerated" : true + }, + "Salvar cartão" : { + + }, + "Salvar Cartão" : { + + }, + "Save" : { + "comment" : "Button title indicating that the current contents should be saved", + "extractionState" : "stale", + "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Guardar" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enregistrer" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "保存" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "保存" + } + } + } + }, + "Selecionar Cartão" : { + + }, + "Selecionar endereço" : { + + }, + "Sem adicionais" : { + + }, + "Settings" : { + "comment" : "Tab bar item title for the Settings tab", + "extractionState" : "stale", + "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Paramètres" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "設定" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "设置" + } + } + } + }, + "Seu carrinho está vazio" : { + "comment" : "A message indicating that the user's cart is empty.", + "isCommentAutoGenerated" : true + }, + "Seu carrinho tem itens de %@. Deseja limpar e adicionar itens de %@?" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Seu carrinho tem itens de %1$@. Deseja limpar e adicionar itens de %2$@?" + } + } + } + }, + "Seu carrinho tem itens de outra loja. Deseja limpar o carrinho atual para adicionar este produto?" : { + "comment" : "A message displayed when the user attempts to add a product to their cart from a different store. Asks if the user wants to clear their current cart before adding the new product.", + "isCommentAutoGenerated" : true + }, + "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 + }, + "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.", + "isCommentAutoGenerated" : true + }, + "Sua vontade, no seu tempo.\nTudo o que você precisa, em um toque." : { + "comment" : "A tagline displayed in the login view.", + "isCommentAutoGenerated" : true + }, + "Substituir carrinho?" : { + + }, + "Subtotal" : { + + }, + "System" : { + "comment" : "Menu item indicating that the appearance should be in the default system mode", + "extractionState" : "stale", + "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sistema" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Système" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "システム" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "系统" + } + } + } + }, + "Taxa de entrega" : { + "comment" : "A label describing the delivery fee.", + "isCommentAutoGenerated" : true + }, + "Tem certeza que deseja sair da sua conta?" : { + + }, + "Tentar novamente" : { + "comment" : "A button label that attempts to reload data when an error occurs.", + "isCommentAutoGenerated" : true + }, + "Termos de Uso" : { + "comment" : "A link to the app's \"Terms of Use\".", + "isCommentAutoGenerated" : true + }, + "TIPO DE ENTREGA" : { + "comment" : "A label displayed above the section that lets users choose between delivery and pickup.", + "isCommentAutoGenerated" : true + }, + "Title" : { + "comment" : "Label for the item editor form indicating the title of the item", + "extractionState" : "stale", + "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Título" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Titre" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "タイトル" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "标题" + } + } + } + }, + "Toque no sabor para escolher adicionais." : { + "comment" : "A description below the list of pizza flavors, explaining that tapping a flavor will open an additional options list.", + "isCommentAutoGenerated" : true + }, + "Toque para avaliar" : { + + }, + "Total" : { + "comment" : "A label displayed above the total price in the cart.", + "isCommentAutoGenerated" : true + }, + "Total a pagar" : { + "comment" : "A label describing the total amount to be paid.", + "isCommentAutoGenerated" : true + }, + "Total do Pedido" : { + "comment" : "The label above the total price of the order in the payment card view.", + "isCommentAutoGenerated" : true + }, + "Trocar" : { + + }, + "Trocar de loja?" : { + "comment" : "A title for an alert that prompts the user to switch stores.", + "isCommentAutoGenerated" : true + }, + "Trocar Foto" : { + "comment" : "A button label that allows a user to change their profile picture.", + "isCommentAutoGenerated" : true + }, + "Trocar Pagamento" : { + + }, + "UPLOAD" : { + "comment" : "A button label that says \"UPLOAD\".", + "isCommentAutoGenerated" : true + }, + "Vence %@" : { + + }, + "Ver Detalhes" : { + "comment" : "A button that shows order details when pressed.", + "isCommentAutoGenerated" : true + }, + "Ver loja" : { + + }, + "Ver motivo do cancelamento" : { + + }, + "Ver Perfil" : { + "comment" : "A button label that translates to \"View Profile\" in English.", + "isCommentAutoGenerated" : true + }, + "Ver todas" : { + "comment" : "A link that allows users to see all available categories in the filters.", + "isCommentAutoGenerated" : true + }, + "Verificação" : { + "comment" : "A title for the OTP verification screen.", + "isCommentAutoGenerated" : true + }, + "Versão 1.0b" : { + "comment" : "The version number of the app.", + "isCommentAutoGenerated" : true + }, + "Version %@ (%@)" : { + "comment" : "Settings label showing the current version of the app", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Version %1$@ (%2$@)" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Versión %1$@ (%2$@)" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Version %1$@ (%2$@)" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "バージョン %1$@ (%2$@)" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "版本 %1$@ (%2$@)" + } + } + } + }, + "Você gostou da entrega?" : { + + }, + "Você não tem avaliações nem pendências no momento." : { + "comment" : "A description displayed when a user has no reviews or pending reviews.", + "isCommentAutoGenerated" : true + }, + "Welcome" : { + "comment" : "Tab bar item title for the Welcome tab", + "extractionState" : "stale", + "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bienvenido" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bienvenue" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ようこそ" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "欢迎" + } + } + } + }, + "WhatsApp não encontrado. Deseja ligar para %@?" : { + + } + }, + "version" : "1.1" +} \ No newline at end of file diff --git a/PediFoods/Services/ApiCardModels.swift b/PediFoods/Services/ApiCardModels.swift new file mode 100644 index 0000000..c4c7b94 --- /dev/null +++ b/PediFoods/Services/ApiCardModels.swift @@ -0,0 +1,83 @@ +import Foundation + +struct SavedCard: Decodable, Identifiable, Hashable { + let id: String + let nickname: String? + let holderName: String + let last4: String + let brand: String? + let expiryMonth: String + let expiryYear: String + let isDefault: Bool + + var displayLabel: String { + if let nickname, nickname.isEmpty == false { return nickname } + let brandLabel = (brand ?? "Cartão").capitalized + return "\(brandLabel) •••• \(last4)" + } + + var expiryLabel: String { "\(expiryMonth)/\(expiryYear)" } +} + +struct SaveCardCreditCardPayload: Encodable { + let holderName: String + let number: String + let expiryMonth: String + let expiryYear: String + let ccv: String +} + +struct SaveCardHolderInfoPayload: Encodable { + let name: String + let email: String + let cpfCnpj: String + let postalCode: String + let addressNumber: String + let phone: String +} + +struct SaveCardPayload: Encodable { + let creditCard: SaveCardCreditCardPayload + let creditCardHolderInfo: SaveCardHolderInfoPayload + let nickname: String? + let isDefault: Bool +} + +struct UpdateCardPayload: Encodable { + let nickname: String? + let isDefault: Bool? +} + +struct SavedCardResult: Decodable { + let id: String + let holderName: String + let last4: String + let brand: String? + let expiryMonth: String + let expiryYear: String + let isDefault: Bool +} + +struct CreditCardOrderPayload: Encodable { + let holderName: String + let number: String + let expiryMonth: String + let expiryYear: String + let ccv: String +} + +struct ChangePaymentMethodPayload: Encodable { + let paymentMethod: String + let clientCpfCnpj: String? + let creditCard: CreditCardOrderPayload? + let creditCardHolderInfo: SaveCardHolderInfoPayload? + let savedCardId: String? +} + +struct ChangePaymentMethodResult: Decodable { + let paymentMethod: String? + let paymentLocation: String? + let paymentId: String? + let paymentPayload: CreateOrderPaymentPayload? + let payment: CreateOrderPaymentInfo? +} diff --git a/PediFoods/Services/ApiClient.swift b/PediFoods/Services/ApiClient.swift new file mode 100644 index 0000000..cfc38c9 --- /dev/null +++ b/PediFoods/Services/ApiClient.swift @@ -0,0 +1,498 @@ +import Foundation +#if canImport(LCEssentials) +import LCEssentials +#endif + +enum NetworkError: Error, LocalizedError { + case invalidURL + case invalidResponse + case httpError(Int, String?) + case unauthorized(String?) + case decodeError(String?) + case rateLimited(Int?) + case cancelled + case timedOut + case transportError(String) + + var errorDescription: String? { + switch self { + case .invalidURL: return "URL invalida" + case .invalidResponse: return "Resposta invalida do servidor" + case .httpError(let code, let message): + return message ?? "Erro HTTP (\(code))" + case .unauthorized(let message): + return message ?? "Sessao expirada. Faca login novamente." + case .decodeError(let payload): + if let payload, payload.isEmpty == false { + return "Erro ao interpretar dados: \(payload)" + } + return "Erro ao interpretar dados" + case .rateLimited(let retryAfter): + if let retryAfter { + return "Muitas requisicoes. Tente novamente em \(retryAfter)s." + } + return "Muitas requisicoes. Tente novamente." + case .cancelled: + return "Requisicao cancelada" + case .timedOut: + return "O servidor demorou demais para responder. Tente novamente." + case .transportError(let message): + return "Erro de rede: \(message)" + } + } +} + +struct ApiErrorDescriptor { + let code: String? + let message: String? +} + +struct ApiRequest: Sendable { + let path: String + let method: String + let module: ApiModule + let requiresAuth: Bool + let queryItems: [URLQueryItem] + let body: Data? + /// Overrides `ApiConfig.baseURL` (Atomenta) for this single request — used + /// to reach the PediFoods BFF (`ApiConfig.pediFoodsBFFURL`) instead. + let baseURLOverride: URL? + /// Sent as `Authorization: Bearer ` instead of the customer JWT. + /// Used for guest-session-authenticated public locator calls, which must + /// never touch `TokenStore`'s customer session. + let customBearerToken: String? + + init(path: String, + method: String = "GET", + module: ApiModule = .none, + requiresAuth: Bool = true, + queryItems: [URLQueryItem] = [], + body: Data? = nil, + baseURLOverride: URL? = nil, + customBearerToken: String? = nil) { + self.path = path + self.method = method + self.module = module + self.requiresAuth = requiresAuth + self.queryItems = queryItems + self.body = body + self.baseURLOverride = baseURLOverride + self.customBearerToken = customBearerToken + } +} + +final class ApiClient: @unchecked Sendable { + private let session: URLSession + private let tokenStore: TokenStore + private let maxAttempts = 3 + private let baseBackoffNanoseconds: UInt64 = 300_000_000 + + init(session: URLSession = .shared, tokenStore: TokenStore = DefaultTokenStore()) { + self.session = session + self.tokenStore = tokenStore + } + + func send(_ request: ApiRequest) async throws -> T { + // Hard client-side cutoff independent of whatever timeout logic + // lives inside the underlying transport (LCEssentials or plain + // URLSession). If that transport ever stalls without ever + // 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) + try await sendWithLCEssentials(request) +#else + try await sendWithURLSession(request) +#endif + } + } + + private func withTimeout(seconds: Double, operation: @escaping @Sendable () async throws -> T) async throws -> T { + try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { + try await operation() + } + group.addTask { + try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + throw NetworkError.timedOut + } + guard let result = try await group.next() else { + throw NetworkError.timedOut + } + group.cancelAll() + return result + } + } +} + +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 + let headers = buildHeaders(for: request) + let params = request.body + + var attempt = 1 + while attempt <= maxAttempts { + do { + let responseString = try await Self.performLCERequest( + url: urlString, + params: params, + method: method, + headers: headers + ) + guard let data = responseString.data(using: .utf8) else { + throw NetworkError.decodeError("Resposta nao UTF-8") + } + if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let errorFlag = object["error"] as? Bool, errorFlag { + let message = object["message"] as? String ?? object["msg"] as? String + throw NetworkError.httpError(200, message ?? "Erro no servidor") + } + do { + return try JSONDecoder().decode(T.self, from: data) + } catch { + throw NetworkError.decodeError(sanitizedBody(data)) + } + } catch { + let mapped = mapError(error) + guard shouldRetry(mapped), attempt < maxAttempts else { + throw mapped + } + try await Task.sleep(nanoseconds: backoff(for: attempt, error: mapped)) + attempt += 1 + } + } + throw NetworkError.invalidResponse + } + + @MainActor + static func performLCERequest( + url: String, + params: Data?, + method: String, + headers: [String: String] + ) async throws -> String { + let httpMethod = toHTTPMethod(method) + return try await API.shared.request( + url: url, + params: params, + method: httpMethod, + headers: headers, + jsonEncoding: true, + debug: true + ) + } + + static func toHTTPMethod(_ method: String) -> httpMethod { + switch method.uppercased() { + case "POST": return .post + case "PUT": return .put + case "DELETE": return .delete + case "PATCH": return .patch + default: return .get + } + } + + func mapError(_ error: Error) -> NetworkError { + if let network = error as? NetworkError { + return network + } + if let decoding = error as? DecodingError { + return .decodeError(String(describing: decoding)) + } + + let nsError = error as NSError + let apiMessage = serverMessage(from: nsError) + let payload = serverPayload(from: nsError) + if isSessionExpiredPayload(code: payload?.code, message: payload?.message ?? apiMessage) { + return .unauthorized(payload?.message ?? apiMessage) + } + + if nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorCancelled { + return .cancelled + } + + printError(title: "httpReqError", msg: error.localizedDescription) + + switch nsError.code { + case 401, 403: + return .unauthorized(apiMessage) + case 429: + return .rateLimited(nil) + case 400...599: + return .httpError(nsError.code, apiMessage) + default: + break + } + + if nsError.domain == NSURLErrorDomain { + return .transportError(nsError.localizedDescription) + } + + return .transportError(nsError.localizedDescription) + } +#endif + + func sendWithURLSession(_ request: ApiRequest) async throws -> T { + let url = try buildURL(path: request.path, query: request.queryItems, baseURL: request.baseURLOverride ?? ApiConfig.baseURL) + var urlRequest = URLRequest(url: url) + urlRequest.httpMethod = request.method + urlRequest.httpBody = request.body + + for (key, value) in buildHeaders(for: request) { + urlRequest.setValue(value, forHTTPHeaderField: key) + } + + var attempt = 1 + while attempt <= maxAttempts { + do { + return try await perform(urlRequest, as: T.self) + } catch is CancellationError { + throw NetworkError.cancelled + } catch let error as NetworkError { + guard shouldRetry(error), attempt < maxAttempts else { + throw error + } + try await Task.sleep(nanoseconds: backoff(for: attempt, error: error)) + attempt += 1 + } catch { + let wrapped = NetworkError.transportError(error.localizedDescription) + guard attempt < maxAttempts else { throw wrapped } + try await Task.sleep(nanoseconds: backoff(for: attempt, error: wrapped)) + attempt += 1 + } + } + throw NetworkError.invalidResponse + } + + func perform(_ request: URLRequest, as type: T.Type) async throws -> T { + let (data, response): (Data, URLResponse) + do { + (data, response) = try await session.data(for: request) + } catch { + if let urlError = error as? URLError, urlError.code == .cancelled { + throw NetworkError.cancelled + } + throw NetworkError.transportError(error.localizedDescription) + } + + guard let http = response as? HTTPURLResponse else { + throw NetworkError.invalidResponse + } + + let payload = serverPayload(from: data) + if isSessionExpiredPayload(code: payload?.code, message: payload?.message) { + throw NetworkError.unauthorized(payload?.message) + } + + if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let errorFlag = object["error"] as? Bool, errorFlag { + let message = object["message"] as? String ?? object["msg"] as? String + throw NetworkError.httpError(http.statusCode, message ?? "Erro no servidor") + } + + if http.statusCode == 429 { + let retryAfter = Int(http.value(forHTTPHeaderField: "Retry-After") ?? "") + throw NetworkError.rateLimited(retryAfter) + } + + if http.statusCode == 401 || http.statusCode == 403 { + throw NetworkError.unauthorized(serverMessage(from: data)) + } + + if !(200...299).contains(http.statusCode) { + throw NetworkError.httpError(http.statusCode, serverMessage(from: data)) + } + + do { + return try JSONDecoder().decode(type, from: data) + } catch { + throw NetworkError.decodeError(sanitizedBody(data)) + } + } + + func buildHeaders(for request: ApiRequest) -> [String: String] { + var headers: [String: String] = [ + "Accept": "application/json", + "Content-Type": "application/json" + ] + + if let token = ApiConfig.token(for: request.module) { + headers["Atomenta-Token"] = token + } + if let customBearerToken = request.customBearerToken { + headers["Authorization"] = "Bearer \(customBearerToken)" + } else if request.requiresAuth, let jwt = tokenStore.jwt { + headers["Authorization"] = "Bearer \(jwt)" + } + return headers + } + + func shouldRetry(_ error: NetworkError) -> Bool { + switch error { + case .rateLimited, .transportError: + return true + case .httpError(let statusCode, _): + return statusCode >= 500 + case .invalidURL, .invalidResponse, .decodeError, .unauthorized, .cancelled, .timedOut: + return false + } + } + + func backoff(for attempt: Int, error: NetworkError) -> UInt64 { + if case .rateLimited(let retryAfter) = error, let retryAfter { + return UInt64(retryAfter) * 1_000_000_000 + } + let multiplier = UInt64(max(1, attempt)) + return min(baseBackoffNanoseconds * multiplier, 2_000_000_000) + } + + func serverMessage(from data: Data) -> String? { + let payload = serverPayload(from: data) + if let message = payload?.message, message.isEmpty == false { + return message + } + if let code = payload?.code, code.isEmpty == false { + return "Erro: \(code)" + } + + // fallback if error true is present but without a classic structure + if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let errorFlag = object["error"] as? Bool, errorFlag { + if let msg = object["message"] as? String ?? object["msg"] as? String { + return msg + } + } + return sanitizedBody(data) + } + + func serverPayload(from data: Data) -> ApiErrorDescriptor? { + if let envelope = try? JSONDecoder().decode(ApiEnvelope.self, from: data) { + return ApiErrorDescriptor(code: envelope.code, message: envelope.message) + } + + if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + let errorFlag = object["error"] as? Bool ?? false + let code = object["code"] as? String + let message = object["message"] as? String ?? object["msg"] as? String ?? object["error_description"] as? String + if errorFlag || code != nil || message != nil { + return ApiErrorDescriptor(code: code, message: message) + } + } + + return nil + } + + func isSessionExpiredPayload(code: String?, message: String?) -> Bool { + let normalizedCode = (code ?? "").lowercased() + let normalizedMessage = (message ?? "").lowercased() + + if normalizedCode.contains("auth") || normalizedCode.contains("token") || normalizedCode.contains("unauthorized") { + return true + } + if normalizedMessage.contains("token") && (normalizedMessage.contains("expir") || normalizedMessage.contains("invalid") || normalizedMessage.contains("sess")) { + return true + } + + return false + } + +#if canImport(LCEssentials) + func serverMessage(from error: NSError) -> String? { + for key in [NSLocalizedFailureReasonErrorKey, "body", "responseBody", "data", "message"] { + if let value = error.userInfo[key] as? String, + !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + if let data = value.data(using: .utf8), + let parsed = serverMessage(from: data), + !parsed.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + let t = parsed.trimmingCharacters(in: .whitespacesAndNewlines) + if t.hasPrefix("{") { continue } + if let safe = sanitizedMessage(t) { return safe } + } + let t = value.trimmingCharacters(in: .whitespacesAndNewlines) + if !t.hasPrefix("{"), let safe = sanitizedMessage(t) { return safe } + } else if let valueData = error.userInfo[key] as? Data { + if let parsed = serverMessage(from: valueData) { + let t = parsed.trimmingCharacters(in: .whitespacesAndNewlines) + if !t.hasPrefix("{"), let safe = sanitizedMessage(t) { return safe } + } + } + } + + for (_, value) in error.userInfo { + if let str = value as? String, + str.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("{"), + let data = str.data(using: .utf8), + let parsed = serverMessage(from: data) { + let t = parsed.trimmingCharacters(in: .whitespacesAndNewlines) + if !t.hasPrefix("{"), let safe = sanitizedMessage(t) { return safe } + } + } + + if let description = error.userInfo[NSLocalizedDescriptionKey] as? String, + !description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + !description.lowercased().contains("nsurlerrordomain"), + let safe = sanitizedMessage(description) { + return safe + } + + return nil + } + + func serverPayload(from error: NSError) -> ApiErrorDescriptor? { + for key in [NSLocalizedFailureReasonErrorKey, "body", "responseBody", "data"] { + if let value = error.userInfo[key] as? String, + let data = value.data(using: .utf8), + let payload = serverPayload(from: data) { + return payload + } else if let valueData = error.userInfo[key] as? Data, + let payload = serverPayload(from: valueData) { + return payload + } + } + + for (_, value) in error.userInfo { + if let str = value as? String, + str.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("{"), + let data = str.data(using: .utf8), + let payload = serverPayload(from: data) { + return payload + } + } + return nil + } +#endif + + func sanitizedBody(_ data: Data) -> String? { + guard let raw = String(data: data, encoding: .utf8) else { return nil } + return sanitizedMessage(raw) + } + + func sanitizedMessage(_ raw: String) -> String? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.isEmpty == false else { return nil } + // Drop data URLs (base64 images) + if trimmed.lowercased().hasPrefix("data:image") { return "Erro ao processar imagem." } + // Drop fields containing base64, + if trimmed.contains("base64,") { return "Resposta do servidor inválida." } + // Truncate long strings (raw JSON bodies, etc.) + if trimmed.count > 300 { + return String(trimmed.prefix(300)) + "…" + } + return trimmed + } + + func buildURL(path: String, query: [URLQueryItem], baseURL: URL) throws -> URL { + guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else { + throw NetworkError.invalidURL + } + components.path = components.path.appending(path) + if !query.isEmpty { + components.queryItems = query + } + guard let url = components.url else { throw NetworkError.invalidURL } + return url + } +} diff --git a/PediFoods/Services/ApiConfig.swift b/PediFoods/Services/ApiConfig.swift new file mode 100644 index 0000000..028105c --- /dev/null +++ b/PediFoods/Services/ApiConfig.swift @@ -0,0 +1,71 @@ +import Foundation + +enum ApiModule: Sendable { + case app + case customer + case store + case resource + case none +} + +enum ApiConfig { + // Atomenta directly — customer/store/cards/orders/addresses/etc. Only + // the public locator (session, locations, categories, stores-by-location, + // store detail) goes through pediFoodsBFFURL below via explicit + // baseURLOverride on those specific requests, per + // docs/plans/app-migrate-atomenta-calls-to-pedifoods-bff.md. + static var baseURL: URL { + let raw = ProcessInfo.processInfo.environment["ATOMENTA_API_URL"] ?? "https://atomenta.com.br" + return URL(string: raw) ?? URL(string: "https://atomenta.com.br")! + } + + static var featureControlBffURL: URL { + let raw = ProcessInfo.processInfo.environment["FEATURE_CONTROL_BFF_URL"] ?? "http://localhost:8787" + return URL(string: raw) ?? URL(string: "http://localhost:8787")! + } + + /// BFF (`PediFoods_web`) base URL. The public store locator (guest session, + /// states/cities/stores-by-location, store detail) must go through here — + /// never call `baseURL` (Atomenta) directly for these, per + /// docs/plans/app-migrate-atomenta-calls-to-pedifoods-bff.md. + static var pediFoodsBFFURL: URL { + let raw = ProcessInfo.processInfo.environment["PEDIFOODS_BFF_URL"] ?? "https://pedifoods.com.br" + return URL(string: raw) ?? URL(string: "https://pedifoods.com.br")! + } + + static var featureControlEnvironment: String { + let raw = ProcessInfo.processInfo.environment["FEATURE_CONTROL_ENVIRONMENT"] ?? "production" + let clean = raw.trimmingCharacters(in: .whitespacesAndNewlines) + return clean.isEmpty ? "production" : clean + } + + // Tokens provided by backend modules + static let storeToken = "550e8400-e29b-41d4-a716-446655440008" + static let customerToken = "550e8400-e29b-41d4-a716-44665544000a" + static let resourceToken = "550e8400-e29b-41d4-a716-446655440009" + + static func token(for module: ApiModule) -> String? { + switch module { + case .store: return storeToken + case .customer: return customerToken + case .resource: return resourceToken + case .app, .none: return nil + } + } +} + +enum LegalDocument: Sendable { + case terms + case privacyPolicy + + private var path: String { + switch self { + case .terms: return "api/public/pedi-foods-customer/terms" + case .privacyPolicy: return "api/public/pedi-foods-customer/privacy-policy" + } + } + + var url: URL { + ApiConfig.baseURL.appendingPathComponent(path) + } +} diff --git a/PediFoods/Services/ApiCustomerPayloadModels.swift b/PediFoods/Services/ApiCustomerPayloadModels.swift new file mode 100644 index 0000000..3dc5fa9 --- /dev/null +++ b/PediFoods/Services/ApiCustomerPayloadModels.swift @@ -0,0 +1,122 @@ +import Foundation + +struct PublicCategory: Decodable { + let id: String + let name: String + let icon: String? +} + +struct CustomerProfileUpdatePayload: Encodable { + let addressBook: [CustomerAddressPayload] + + enum CodingKeys: String, CodingKey { + case addressBook = "address_book" + } +} + +struct CustomerIdentityUpdatePayload: Encodable { + let name: String? + let email: String? + let phoneNumber: String? + let profilePicture: String? + + enum CodingKeys: String, CodingKey { + case name + case email + case phoneNumber + case profilePicture + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(name, forKey: .name) + try container.encodeIfPresent(email, forKey: .email) + try container.encodeIfPresent(phoneNumber, forKey: .phoneNumber) + if let profilePicture, profilePicture.isEmpty == false { + try container.encode(profilePicture, forKey: .profilePicture) + } + } +} + +/// `POST /api/customer/:id` — partial update, sibling of `CustomerProfileUpdatePayload`. +/// See docs/api/push-notifications-integration-guide.md §2b. +struct CustomerNotificationsUpdatePayload: Encodable { + let notificationsEnabled: Bool +} + +/// `POST /api/customer/:id` — biometric-login preference. Persistence only for +/// now; the actual Face ID/Touch ID unlock flow is a separate, later plan. +struct CustomerFaceIdUpdatePayload: Encodable { + let faceIdEnabled: Bool +} + +/// `PUT /api/customer/:id/push-token` — see docs/api/push-notifications-integration-guide.md §2. +struct CustomerPushTokenPayload: Encodable { + let pushToken: String + let deviceId: String + let deviceOS: String +} + +/// `PUT /api/customer/:id/attributes` — wholesale replace, not a merge. +/// See docs/api/push-notifications-integration-guide.md §2a. +struct CustomerAttributesUpdatePayload: Encodable { + let appVersion: String? + let attributes: [String: String]? + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(appVersion, forKey: .appVersion) + try container.encodeIfPresent(attributes, forKey: .attributes) + } + + enum CodingKeys: String, CodingKey { + case appVersion + case attributes + } +} + +/// `POST /api/customer/:id/push-campaigns/opened` — see +/// docs/api/push-notifications-integration-guide.md §6a. +struct PushCampaignOpenedPayload: Encodable { + let campaignId: String +} + +struct PushCampaignOpenedResult: Decodable { + let recorded: Bool +} + +struct CustomerAddressPayload: Encodable { + let label: String? + let address: String? + let number: String? + let complement: String? + let neighborhood: String? + let city: String? + let state: String? + let zipCode: String? + let latLong: [Double]? + + enum CodingKeys: String, CodingKey { + case label + case address + case number + case complement + case neighborhood + case city + case state + case zipCode + case latLong = "lat_long" + } + + init(from address: CustomerAddress) { + self.label = address.label + self.address = address.address + self.number = address.number + self.complement = address.complement + self.neighborhood = address.neighborhood + self.city = address.city + self.state = address.state + self.zipCode = address.zipCode + self.latLong = address.latLong + } +} diff --git a/PediFoods/Services/ApiFavoriteModels.swift b/PediFoods/Services/ApiFavoriteModels.swift new file mode 100644 index 0000000..317be1d --- /dev/null +++ b/PediFoods/Services/ApiFavoriteModels.swift @@ -0,0 +1,6 @@ +import Foundation + +struct CustomerFavoritesMutationResult: Decodable { + let favorites: [String] + let store: StoreSummary? +} diff --git a/PediFoods/Services/ApiModels.swift b/PediFoods/Services/ApiModels.swift new file mode 100644 index 0000000..a156139 --- /dev/null +++ b/PediFoods/Services/ApiModels.swift @@ -0,0 +1,552 @@ +import Foundation + +// MARK: - DTOs + +struct EmptyResult: Decodable {} + +struct ProfilePatchEnvelope: Decodable { + let error: Bool + let code: String? + let message: String? + let profilePictureUrl: String? +} + +struct RegistrationResult: Decodable { + let id: String? + let name: String? + let email: String? +} + +struct LoginResult: Decodable { + let token: String + let customer: CustomerProfile? +} + +struct CustomerProfile: Decodable { + let id: String + let name: String + let email: String + let phoneNumber: String? + let profilePicture: String? + let favorites: [String]? + let addressBook: [CustomerAddress]? + let notificationsEnabled: Bool? + let faceIdEnabled: Bool? + + enum CodingKeys: String, CodingKey { + case id + case name + case email + case phoneNumber + case profilePicture + case favorites + case addressBook = "address_book" + case notificationsEnabled + case faceIdEnabled + } +} + +struct CustomerAddress: Decodable { + let id: String? + let label: String? + let address: String? + let number: String? + let complement: String? + let neighborhood: String? + let city: String? + let state: String? + let zipCode: String? + let latLong: [Double]? + let isDefault: Bool? + + init(id: String?, label: String?, address: String?, number: String?, + complement: String?, neighborhood: String?, city: String?, + state: String?, zipCode: String?, latLong: [Double]?, isDefault: Bool?) { + self.id = id; self.label = label; self.address = address + self.number = number; self.complement = complement + self.neighborhood = neighborhood; self.city = city + self.state = state; self.zipCode = zipCode + self.latLong = latLong; self.isDefault = isDefault + } + + enum CodingKeys: String, CodingKey { + case id, label, address, number, complement + case neighborhood, city, state, zipCode + case latLong = "lat_long" + case isDefault + } +} + +struct StoreSummary: Decodable { + let id: String + let name: String + let logo: String? + let cover: String? + let category: String? + let rating: Double? + let reviewsCount: Int? + let positiveReviews: Int? + let deliveryTime: String? + let deliveryFee: Double? + let distance: Double? + let isOpen: Bool? + let statusLabel: String? + + enum CodingKeys: String, CodingKey { + case id + case name + case logo + case cover + case category + case rating + case reviewsCount + case totalReviews + case reviews + case positiveReviews + case positive_reviews + case deliveryTime + case deliveryFee + case distance + case isOpen + case statusLabel + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString + name = (try? container.decode(String.self, forKey: .name)) ?? "Loja" + logo = try? container.decode(String.self, forKey: .logo) + cover = try? container.decode(String.self, forKey: .cover) + category = try? container.decode(String.self, forKey: .category) + rating = ApiService.decodeFlexibleDouble(from: container, keys: [.rating]) + reviewsCount = ApiService.decodeFlexibleInt(from: container, keys: [.reviewsCount, .totalReviews, .reviews]) + positiveReviews = ApiService.decodeFlexibleInt(from: container, keys: [.positiveReviews, .positive_reviews]) + deliveryTime = try? container.decode(String.self, forKey: .deliveryTime) + deliveryFee = ApiService.decodeFlexibleDouble(from: container, keys: [.deliveryFee]) + distance = ApiService.decodeFlexibleDouble(from: container, keys: [.distance]) + isOpen = try? container.decode(Bool.self, forKey: .isOpen) + statusLabel = try? container.decode(String.self, forKey: .statusLabel) + } +} + +struct StoreInfoResult: Decodable { + let isOpen: Bool? + let statusLabel: String? + let fantasyName: String? + let phone: String? + let whatsapp: String? + let logo: String? + let cover: String? + let deliveryTime: String? + let minOrder: Double? + let address: StoreAddressInfo? + let paymentMethods: StorePaymentMethodsInfo? + + enum CodingKeys: String, CodingKey { + case isOpen + case statusLabel + case fantasyName + case phone + case whatsapp + case logo + case cover + case deliveryTime + case minOrder + case address + case paymentMethods + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + isOpen = try? container.decode(Bool.self, forKey: .isOpen) + statusLabel = try? container.decode(String.self, forKey: .statusLabel) + fantasyName = try? container.decode(String.self, forKey: .fantasyName) + phone = ApiService.decodeFlexibleString(from: container, keys: [.phone]) + whatsapp = ApiService.decodeFlexibleString(from: container, keys: [.whatsapp, .phone]) + logo = try? container.decode(String.self, forKey: .logo) + cover = try? container.decode(String.self, forKey: .cover) + deliveryTime = try? container.decode(String.self, forKey: .deliveryTime) + minOrder = ApiService.decodeFlexibleDouble(from: container, keys: [.minOrder]) + address = try? container.decode(StoreAddressInfo.self, forKey: .address) + paymentMethods = try? container.decode(StorePaymentMethodsInfo.self, forKey: .paymentMethods) + } +} + +struct StoreAddressInfo: Decodable { + let street: String? + let number: String? + let neighborhood: String? + let city: String? + let state: String? + let zipCode: String? + let latitude: Double? + let longitude: Double? + + enum CodingKeys: String, CodingKey { + case street + case number + case neighborhood + case city + case state + case zipCode + case zipcode + case latitude + case longitude + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + street = try? container.decode(String.self, forKey: .street) + number = try? container.decode(String.self, forKey: .number) + neighborhood = try? container.decode(String.self, forKey: .neighborhood) + city = try? container.decode(String.self, forKey: .city) + state = try? container.decode(String.self, forKey: .state) + zipCode = (try? container.decode(String.self, forKey: .zipCode)) + ?? (try? container.decode(String.self, forKey: .zipcode)) + latitude = ApiService.decodeFlexibleDouble(from: container, keys: [.latitude]) + longitude = ApiService.decodeFlexibleDouble(from: container, keys: [.longitude]) + } +} + +struct StorePaymentMethodsInfo: Decodable { + let paymentOnDelivery: Bool? + let paymentOnPickup: Bool? + let acceptPix: Bool? + let acceptCash: Bool? + let acceptCreditCard: Bool? + let acceptDebitCard: Bool? + let acceptCreditVisa: Bool? + let acceptCreditMaster: Bool? + let acceptCreditElo: Bool? + let acceptCreditAmex: Bool? + let acceptCreditHipercard: Bool? + let acceptDebitVisa: Bool? + let acceptDebitMaster: Bool? + let acceptDebitElo: Bool? + let acceptVoucherAlelo: Bool? + let acceptVoucherSodexo: Bool? + let acceptVoucherTicket: Bool? + let acceptVoucherVR: Bool? + + enum CodingKeys: String, CodingKey { + case paymentOnDelivery + case paymentOnPickup + case acceptPix + case acceptCash + case acceptCreditCard + case acceptDebitCard + case acceptCreditVisa + case acceptCreditMaster + case acceptCreditElo + case acceptCreditAmex + case acceptCreditHipercard + case acceptDebitVisa + case acceptDebitMaster + case acceptDebitElo + case acceptVoucherAlelo + case acceptVoucherSodexo + case acceptVoucherTicket + case acceptVoucherVR + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + paymentOnDelivery = try? container.decode(Bool.self, forKey: .paymentOnDelivery) + paymentOnPickup = try? container.decode(Bool.self, forKey: .paymentOnPickup) + acceptPix = try? container.decode(Bool.self, forKey: .acceptPix) + acceptCash = try? container.decode(Bool.self, forKey: .acceptCash) + acceptCreditCard = try? container.decode(Bool.self, forKey: .acceptCreditCard) + acceptDebitCard = try? container.decode(Bool.self, forKey: .acceptDebitCard) + acceptCreditVisa = try? container.decode(Bool.self, forKey: .acceptCreditVisa) + acceptCreditMaster = try? container.decode(Bool.self, forKey: .acceptCreditMaster) + acceptCreditElo = try? container.decode(Bool.self, forKey: .acceptCreditElo) + acceptCreditAmex = try? container.decode(Bool.self, forKey: .acceptCreditAmex) + acceptCreditHipercard = try? container.decode(Bool.self, forKey: .acceptCreditHipercard) + acceptDebitVisa = try? container.decode(Bool.self, forKey: .acceptDebitVisa) + acceptDebitMaster = try? container.decode(Bool.self, forKey: .acceptDebitMaster) + acceptDebitElo = try? container.decode(Bool.self, forKey: .acceptDebitElo) + acceptVoucherAlelo = try? container.decode(Bool.self, forKey: .acceptVoucherAlelo) + acceptVoucherSodexo = try? container.decode(Bool.self, forKey: .acceptVoucherSodexo) + acceptVoucherTicket = try? container.decode(Bool.self, forKey: .acceptVoucherTicket) + acceptVoucherVR = try? container.decode(Bool.self, forKey: .acceptVoucherVR) + } + + var hasAnyCreditCard: Bool { + (acceptCreditCard ?? false) + || (acceptCreditVisa ?? false) + || (acceptCreditMaster ?? false) + || (acceptCreditElo ?? false) + || (acceptCreditAmex ?? false) + || (acceptCreditHipercard ?? false) + } + + var hasAnyDebitCard: Bool { + (acceptDebitCard ?? false) + || (acceptDebitVisa ?? false) + || (acceptDebitMaster ?? false) + || (acceptDebitElo ?? false) + } + + var hasAnyVoucher: Bool { + (acceptVoucherAlelo ?? false) + || (acceptVoucherSodexo ?? false) + || (acceptVoucherTicket ?? false) + || (acceptVoucherVR ?? false) + } +} + +struct StoreCatalogCategory: Decodable { + let id: String + let name: String + let isPizzaCategory: Bool + let pizzaConfig: StorePizzaConfig? + let products: [StoreCatalogProduct] + + enum CodingKeys: String, CodingKey { + case id + case name + case isPizzaCategory + case pizzaConfig + case products + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString + name = (try? container.decode(String.self, forKey: .name)) ?? "Categoria" + isPizzaCategory = (try? container.decode(Bool.self, forKey: .isPizzaCategory)) ?? false + pizzaConfig = try? container.decode(StorePizzaConfig.self, forKey: .pizzaConfig) + products = (try? container.decode([StoreCatalogProduct].self, forKey: .products)) ?? [] + } +} + +struct StoreCatalogProduct: Decodable, Identifiable { + let id: String + let type: String? + let name: String + let description: String? + let image: String? + let price: Double? + let originalPrice: Double? + let pizzaPrices: [String: Double] + let addonGroups: [StoreAddonGroup] + + enum CodingKeys: String, CodingKey { + case id + case type + case name + case description + case desc + case image + case cover + case photo + case price + case originalPrice + case oldPrice + case pizzaPrices + case addonGroups + case addons + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString + type = try? container.decode(String.self, forKey: .type) + name = (try? container.decode(String.self, forKey: .name)) ?? "Produto" + description = (try? container.decode(String.self, forKey: .description)) ?? (try? container.decode(String.self, forKey: .desc)) + image = (try? container.decode(String.self, forKey: .image)) + ?? (try? container.decode(String.self, forKey: .cover)) + ?? (try? container.decode(String.self, forKey: .photo)) + price = ApiService.decodeFlexibleDouble(from: container, keys: [.price]) + originalPrice = ApiService.decodeFlexibleDouble(from: container, keys: [.originalPrice, .oldPrice]) + pizzaPrices = StoreCatalogProduct.decodePizzaPrices(container: container) + addonGroups = (try? container.decode([StoreAddonGroup].self, forKey: .addonGroups)) + ?? (try? container.decode([StoreAddonGroup].self, forKey: .addons)) + ?? [] + } + + private static func decodePizzaPrices(container: KeyedDecodingContainer) -> [String: Double] { + if let direct = try? container.decode([String: Double].self, forKey: .pizzaPrices) { + return direct + } + if let asInt = try? container.decode([String: Int].self, forKey: .pizzaPrices) { + return asInt.mapValues { Double($0) } + } + if let asString = try? container.decode([String: String].self, forKey: .pizzaPrices) { + var parsed: [String: Double] = [:] + for (key, value) in asString { + let normalized = value + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: ",", with: ".") + if let number = Double(normalized) { + parsed[key] = number + } + } + return parsed + } + return [:] + } +} + +struct StoreAddonGroup: Decodable, Identifiable { + let id: String + let name: String + let minSelectors: Int? + let maxSelectors: Int? + let items: [StoreAddonItem] + + enum CodingKeys: String, CodingKey { + case id + case name + case minSelectors + case maxSelectors + case items + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString + name = (try? container.decode(String.self, forKey: .name)) ?? "Adicionais" + minSelectors = try? container.decode(Int.self, forKey: .minSelectors) + maxSelectors = try? container.decode(Int.self, forKey: .maxSelectors) + items = (try? container.decode([StoreAddonItem].self, forKey: .items)) ?? [] + } +} + +struct StoreAddonItem: Decodable, Identifiable { + let id: String + let name: String + let price: Double? + + enum CodingKeys: String, CodingKey { + case id + case name + case price + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString + name = (try? container.decode(String.self, forKey: .name)) ?? "Item" + price = ApiService.decodeFlexibleDouble(from: container, keys: [.price]) + } +} + +struct CepLookupResult: Decodable { + let zipCode: String? + let street: String? + let neighborhood: String? + let city: String? + let state: String? + let complement: String? + let latitude: Double? + let longitude: Double? + + enum CodingKeys: String, CodingKey { + case zipCode + case cep + case zip + case normalized + case raw + case street + case logradouro + case address + case neighborhood + case bairro + case district + case city + case cidade + case localidade + case state + case estado + case uf + case complement + case complemento + case latitude + case lat + case longitude + case lng + } + + enum NormalizedKeys: String, CodingKey { + case cep + case logradouro + case bairro + case cidade + case uf + case latitude + case longitude + } + + enum RawKeys: String, CodingKey { + case cep + case address + case district + case city + case state + case lat + case lng + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let normalizedContainer = try? container.nestedContainer(keyedBy: NormalizedKeys.self, forKey: .normalized) + let rawContainer = try? container.nestedContainer(keyedBy: RawKeys.self, forKey: .raw) + + let directZip = CepLookupResult.decodeString(from: container, keys: [.zipCode, .cep, .zip]) + let directStreet = CepLookupResult.decodeString(from: container, keys: [.street, .logradouro, .address]) + let directNeighborhood = CepLookupResult.decodeString(from: container, keys: [.neighborhood, .bairro, .district]) + let directCity = CepLookupResult.decodeString(from: container, keys: [.city, .cidade, .localidade]) + let directState = CepLookupResult.decodeString(from: container, keys: [.state, .estado, .uf]) + let directComplement = CepLookupResult.decodeString(from: container, keys: [.complement, .complemento]) + let directLatitude = CepLookupResult.decodeFlexibleDouble(from: container, keys: [.latitude, .lat]) + let directLongitude = CepLookupResult.decodeFlexibleDouble(from: container, keys: [.longitude, .lng]) + + let normalizedZip = normalizedContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.cep]) } + let normalizedStreet = normalizedContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.logradouro]) } + let normalizedNeighborhood = normalizedContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.bairro]) } + let normalizedCity = normalizedContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.cidade]) } + let normalizedState = normalizedContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.uf]) } + let normalizedLatitude = normalizedContainer.flatMap { CepLookupResult.decodeFlexibleDouble(from: $0, keys: [.latitude]) } + let normalizedLongitude = normalizedContainer.flatMap { CepLookupResult.decodeFlexibleDouble(from: $0, keys: [.longitude]) } + + let rawZip = rawContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.cep]) } + let rawStreet = rawContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.address]) } + let rawNeighborhood = rawContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.district]) } + let rawCity = rawContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.city]) } + let rawState = rawContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.state]) } + let rawLatitude = rawContainer.flatMap { CepLookupResult.decodeFlexibleDouble(from: $0, keys: [.lat]) } + let rawLongitude = rawContainer.flatMap { CepLookupResult.decodeFlexibleDouble(from: $0, keys: [.lng]) } + + zipCode = directZip ?? normalizedZip ?? rawZip + street = directStreet ?? normalizedStreet ?? rawStreet + neighborhood = directNeighborhood ?? normalizedNeighborhood ?? rawNeighborhood + city = directCity ?? normalizedCity ?? rawCity + state = directState ?? normalizedState ?? rawState + complement = directComplement + latitude = directLatitude ?? normalizedLatitude ?? rawLatitude + longitude = directLongitude ?? normalizedLongitude ?? rawLongitude + } + + private static func decodeString(from container: KeyedDecodingContainer, keys: [K]) -> String? { + for key in keys { + if let value = try? container.decode(String.self, forKey: key) { + return value + } + } + return nil + } + + private static func decodeFlexibleDouble(from container: KeyedDecodingContainer, keys: [K]) -> Double? { + for key in keys { + if let value = try? container.decode(Double.self, forKey: key) { + return value + } + if let valueAsString = try? container.decode(String.self, forKey: key), + let parsed = Double(valueAsString.replacingOccurrences(of: ",", with: ".")) { + return parsed + } + } + return nil + } +} diff --git a/PediFoods/Services/ApiOrderModels.swift b/PediFoods/Services/ApiOrderModels.swift new file mode 100644 index 0000000..b4627b5 --- /dev/null +++ b/PediFoods/Services/ApiOrderModels.swift @@ -0,0 +1,196 @@ +import Foundation + +struct CreateOrderPayload: Encodable { + let customer: CreateOrderCustomerPayload + let items: [CreateOrderItemPayload] + let total: Double + let paymentMethod: String + let deliveryType: String + let address: CreateOrderAddressPayload? + // Cartão salvo + let savedCardId: String? + // Novo cartão (checkout transparente) + let clientCpfCnpj: String? + let creditCard: CreditCardOrderPayload? + let creditCardHolderInfo: SaveCardHolderInfoPayload? +} + +struct CreateOrderCustomerPayload: Encodable { + let name: String + let phone: String + let email: String + let asaasId: String? +} + +struct CreateOrderItemPayload: Codable { + let productId: String + let name: String + let qty: Int + let price: Double + let addons: [CreateOrderAddonPayload] + let choices: [String]? +} + +struct CreateOrderAddonPayload: Codable { + let addonId: String + let name: String + let qty: Int + let price: Double +} + +struct CreateOrderAddressPayload: Encodable { + let street: String + let number: String + let neighborhood: String + let city: String? + let state: String? + let zip: String? + let complement: String? +} + +struct CreateOrderResult: Decodable { + let id: String? + let shortId: String? + let status: String? + let paymentStatus: String? + let paymentConfirmed: Bool? + let paymentMethod: String? + let paymentPayload: CreateOrderPaymentPayload? + let payment: CreateOrderPaymentInfo? + + enum CodingKeys: String, CodingKey { + case id + case shortId + case status + case paymentStatus + case paymentConfirmed + case paymentMethod + case paymentPayload + case payment + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = ApiService.decodeFlexibleString(from: container, keys: [.id]) + shortId = ApiService.decodeFlexibleString(from: container, keys: [.shortId]) + status = ApiService.decodeFlexibleString(from: container, keys: [.status]) + paymentStatus = ApiService.decodeFlexibleString(from: container, keys: [.paymentStatus]) + paymentConfirmed = try? container.decode(Bool.self, forKey: .paymentConfirmed) + paymentMethod = ApiService.decodeFlexibleString(from: container, keys: [.paymentMethod]) + payment = try? container.decode(CreateOrderPaymentInfo.self, forKey: .payment) + + if let objectPayload = try? container.decode(CreateOrderPaymentPayload.self, forKey: .paymentPayload) { + paymentPayload = objectPayload + } else if let stringPayload = try? container.decode(String.self, forKey: .paymentPayload) { + paymentPayload = CreateOrderPaymentPayload( + copyPaste: stringPayload, + qrCodeImage: nil, + expirationDate: nil + ) + } else { + paymentPayload = nil + } + } +} + +struct CreateOrderPaymentInfo: Codable { + let method: String? + let status: String? + let pix: CreateOrderPaymentPayload? +} + +struct CreateOrderPaymentPayload: Codable { + let copyPaste: String? + let qrCodeImage: String? + let expirationDate: String? + + enum CodingKeys: String, CodingKey { + case copyPaste + case payload + case qrCodeImage + case encodedImage + case expirationDate + } + + init(copyPaste: String?, qrCodeImage: String?, expirationDate: String?) { + self.copyPaste = copyPaste + self.qrCodeImage = qrCodeImage + self.expirationDate = expirationDate + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + copyPaste = (try? container.decode(String.self, forKey: .copyPaste)) + ?? (try? container.decode(String.self, forKey: .payload)) + qrCodeImage = (try? container.decode(String.self, forKey: .qrCodeImage)) + ?? (try? container.decode(String.self, forKey: .encodedImage)) + expirationDate = try? container.decode(String.self, forKey: .expirationDate) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(copyPaste, forKey: .copyPaste) + try container.encodeIfPresent(qrCodeImage, forKey: .qrCodeImage) + try container.encodeIfPresent(expirationDate, forKey: .expirationDate) + } +} + +struct ValidateDeliveryAddressPayload: Encodable { + let address: ValidateDeliveryAddressDataPayload +} + +struct ValidateDeliveryAddressDataPayload: Encodable { + let street: String? + let number: String? + let neighborhood: String? + let city: String? + let state: String? + let zip: String? + let lat: Double? + let lng: Double? +} + +struct ValidateDeliveryAddressResult: Decodable { + let deliveryAllowed: Bool? + let reasonCode: String? + let reasonMessage: String? + let deliveryMode: String? + let distance: Double? + let deliveryFee: Double? + let deliveryTime: String? + let sameCity: Bool? + + enum CodingKeys: String, CodingKey { + case deliveryAllowed + case delivery_allowed + case reasonCode + case reason_code + case reasonMessage + case reason_message + case deliveryMode + case delivery_mode + case distance + case deliveryFee + case delivery_fee + case fee + case taxa + case deliveryTime + case delivery_time + case sameCity + case same_city + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + deliveryAllowed = (try? c.decode(Bool.self, forKey: .deliveryAllowed)) + ?? (try? c.decode(Bool.self, forKey: .delivery_allowed)) + reasonCode = ApiService.decodeFlexibleString(from: c, keys: [.reasonCode, .reason_code]) + reasonMessage = ApiService.decodeFlexibleString(from: c, keys: [.reasonMessage, .reason_message]) + deliveryMode = ApiService.decodeFlexibleString(from: c, keys: [.deliveryMode, .delivery_mode]) + distance = ApiService.decodeFlexibleDouble(from: c, keys: [.distance]) + deliveryFee = ApiService.decodeFlexibleDouble(from: c, keys: [.deliveryFee, .delivery_fee, .fee, .taxa]) + deliveryTime = ApiService.decodeFlexibleString(from: c, keys: [.deliveryTime, .delivery_time]) + sameCity = (try? c.decode(Bool.self, forKey: .sameCity)) + ?? (try? c.decode(Bool.self, forKey: .same_city)) + } +} diff --git a/PediFoods/Services/ApiOrderTrackingModels.swift b/PediFoods/Services/ApiOrderTrackingModels.swift new file mode 100644 index 0000000..bba14fc --- /dev/null +++ b/PediFoods/Services/ApiOrderTrackingModels.swift @@ -0,0 +1,715 @@ +import Foundation + +struct AppOrderSummary: Decodable, Identifiable { + let id: String + let orderId: String? + let realId: String? + let storeId: String? + let shortId: String? + let total: Double? + let status: String? + let statusDetailed: String? + let statusLabel: String? + let nextAction: String? + let paymentStatus: String? + let paymentMethod: String? + let deliveryType: String? + let storeName: String? + let storePhone: String? + let storeLogoURL: String? + let createdAt: String? + let updatedAt: String? + + enum CodingKeys: String, CodingKey { + case id + case orderId + case realId + case storeId + case store_id + case shortId + case total + case status + case statusDetailed + case statusLabel + case nextAction + case paymentStatus + case paymentMethod + case deliveryType + case storeName + case storePhone + case storeLogo + case store_logo + case logo + case storeImage + case store_image + case storeImageUrl + case logoUrl + case date + case createdAt + case updatedAt + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + orderId = ApiService.decodeFlexibleString(from: container, keys: [.orderId]) + id = ApiService.decodeFlexibleString(from: container, keys: [.orderId, .realId, .id]) ?? UUID().uuidString + realId = ApiService.decodeFlexibleString(from: container, keys: [.realId]) + storeId = ApiService.decodeFlexibleString(from: container, keys: [.storeId, .store_id]) + shortId = ApiService.decodeFlexibleString(from: container, keys: [.shortId]) + total = ApiService.decodeFlexibleDouble(from: container, keys: [.total]) + status = ApiService.decodeFlexibleString(from: container, keys: [.status]) + statusDetailed = ApiService.decodeFlexibleString(from: container, keys: [.statusDetailed]) + statusLabel = ApiService.decodeFlexibleString(from: container, keys: [.statusLabel]) + nextAction = ApiService.decodeFlexibleString(from: container, keys: [.nextAction]) + paymentStatus = ApiService.decodeFlexibleString(from: container, keys: [.paymentStatus]) + paymentMethod = ApiService.decodeFlexibleString(from: container, keys: [.paymentMethod]) + deliveryType = ApiService.decodeFlexibleString(from: container, keys: [.deliveryType]) + storeName = ApiService.decodeFlexibleString(from: container, keys: [.storeName]) + storePhone = ApiService.decodeFlexibleString(from: container, keys: [.storePhone]) + storeLogoURL = ApiService.decodeFlexibleString(from: container, keys: [.storeLogo, .store_logo, .logo, .storeImage, .store_image, .storeImageUrl, .logoUrl]) + let fallbackDate = ApiService.decodeFlexibleString(from: container, keys: [.date]) + createdAt = ApiService.decodeFlexibleString(from: container, keys: [.createdAt]) ?? fallbackDate + updatedAt = ApiService.decodeFlexibleString(from: container, keys: [.updatedAt]) ?? fallbackDate + } +} + +struct PublicOrderResult: Codable, Identifiable { + let id: String + let shortId: String? + let realId: String? + let storeId: String? + let status: String? + let paymentStatus: String? + let paymentConfirmed: Bool? + let paymentMethod: String? + let paymentMethodCode: String? + let paymentPayload: CreateOrderPaymentPayload? + let payment: CreateOrderPaymentInfo? + let nextAction: String? + let deliveryType: String? + let deliveryTypeLabel: String? + let subtotal: Double? + let deliveryFee: Double? + let discount: Double? + let total: Double? + let storeName: String? + let storePhone: String? + let storeLogoURL: String? + let createdAt: String? + let updatedAt: String? + let otp: String? + let customerOtp: String? + let confirmOtp: String? + let cancellationReason: String? + let fullAddress: String? + let deliveryAddress: PublicOrderDeliveryAddress? + let review: PublicOrderReview? + let items: [PublicOrderItem] + let timeline: [PublicOrderTimelineEvent] + + enum CodingKeys: String, CodingKey { + case id + case shortId + case realId + case storeId + case store_id + case status + case paymentStatus + case paymentConfirmed + case paymentMethod + case paymentMethodCode + case paymentPayload + case payment + case nextAction + case deliveryType + case deliveryTypeLabel + case subtotal + case subTotal + case itemsTotal + case deliveryFee + case delivery_fee + case fee + case discount + case desconto + case couponDiscount + case total + case storeName + case storePhone + case storeLogo + case store_logo + case logo + case storeImage + case store_image + case storeImageUrl + case logoUrl + case createdAt + case updatedAt + case otp + case customerOtp + case confirmOtp + case cancellationReason + case fullAddress + case address + case deliveryAddress + case delivery_address + case customerAddress + case customer_address + case review + case orderReview + case items + case timeline + case history + case orderedAt + } + + init( + id: String, + shortId: String? = nil, + realId: String? = nil, + storeId: String? = nil, + status: String? = nil, + paymentStatus: String? = nil, + paymentConfirmed: Bool? = nil, + paymentMethod: String? = nil, + paymentMethodCode: String? = nil, + paymentPayload: CreateOrderPaymentPayload? = nil, + payment: CreateOrderPaymentInfo? = nil, + nextAction: String? = nil, + deliveryType: String? = nil, + deliveryTypeLabel: String? = nil, + subtotal: Double? = nil, + deliveryFee: Double? = nil, + discount: Double? = nil, + total: Double? = nil, + storeName: String? = nil, + storePhone: String? = nil, + storeLogoURL: String? = nil, + createdAt: String? = nil, + updatedAt: String? = nil, + otp: String? = nil, + customerOtp: String? = nil, + confirmOtp: String? = nil, + cancellationReason: String? = nil, + fullAddress: String? = nil, + deliveryAddress: PublicOrderDeliveryAddress? = nil, + review: PublicOrderReview? = nil, + items: [PublicOrderItem] = [], + timeline: [PublicOrderTimelineEvent] = [] + ) { + self.id = id + self.shortId = shortId + self.realId = realId + self.storeId = storeId + self.status = status + self.paymentStatus = paymentStatus + self.paymentConfirmed = paymentConfirmed + self.paymentMethod = paymentMethod + self.paymentMethodCode = paymentMethodCode + self.paymentPayload = paymentPayload + self.payment = payment + self.nextAction = nextAction + self.deliveryType = deliveryType + self.deliveryTypeLabel = deliveryTypeLabel + self.subtotal = subtotal + self.deliveryFee = deliveryFee + self.discount = discount + self.total = total + self.storeName = storeName + self.storePhone = storePhone + self.storeLogoURL = storeLogoURL + self.createdAt = createdAt + self.updatedAt = updatedAt + self.otp = otp + self.customerOtp = customerOtp + self.confirmOtp = confirmOtp + self.cancellationReason = cancellationReason + self.fullAddress = fullAddress + self.deliveryAddress = deliveryAddress + self.review = review + self.items = items + self.timeline = timeline + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = ApiService.decodeFlexibleString(from: container, keys: [.id]) ?? UUID().uuidString + shortId = ApiService.decodeFlexibleString(from: container, keys: [.shortId]) + realId = ApiService.decodeFlexibleString(from: container, keys: [.realId]) + storeId = ApiService.decodeFlexibleString(from: container, keys: [.storeId, .store_id]) + status = ApiService.decodeFlexibleString(from: container, keys: [.status]) + paymentStatus = ApiService.decodeFlexibleString(from: container, keys: [.paymentStatus]) + paymentConfirmed = try? container.decode(Bool.self, forKey: .paymentConfirmed) + paymentMethod = ApiService.decodeFlexibleString(from: container, keys: [.paymentMethod]) + paymentMethodCode = ApiService.decodeFlexibleString(from: container, keys: [.paymentMethodCode]) + nextAction = ApiService.decodeFlexibleString(from: container, keys: [.nextAction]) + payment = try? container.decode(CreateOrderPaymentInfo.self, forKey: .payment) + if let objectPayload = try? container.decode(CreateOrderPaymentPayload.self, forKey: .paymentPayload) { + paymentPayload = objectPayload + } else if let stringPayload = try? container.decode(String.self, forKey: .paymentPayload) { + paymentPayload = CreateOrderPaymentPayload( + copyPaste: stringPayload, + qrCodeImage: nil, + expirationDate: nil + ) + } else { + paymentPayload = nil + } + deliveryType = ApiService.decodeFlexibleString(from: container, keys: [.deliveryType]) + deliveryTypeLabel = ApiService.decodeFlexibleString(from: container, keys: [.deliveryTypeLabel]) + subtotal = ApiService.decodeFlexibleDouble(from: container, keys: [.subtotal, .subTotal, .itemsTotal]) + deliveryFee = ApiService.decodeFlexibleDouble(from: container, keys: [.deliveryFee, .delivery_fee, .fee]) + discount = ApiService.decodeFlexibleDouble(from: container, keys: [.discount, .desconto, .couponDiscount]) + total = ApiService.decodeFlexibleDouble(from: container, keys: [.total]) + storeName = ApiService.decodeFlexibleString(from: container, keys: [.storeName]) + storePhone = ApiService.decodeFlexibleString(from: container, keys: [.storePhone]) + storeLogoURL = ApiService.decodeFlexibleString(from: container, keys: [.storeLogo, .store_logo, .logo, .storeImage, .store_image, .storeImageUrl, .logoUrl]) + createdAt = ApiService.decodeFlexibleString(from: container, keys: [.createdAt, .orderedAt]) + updatedAt = ApiService.decodeFlexibleString(from: container, keys: [.updatedAt]) + otp = ApiService.decodeFlexibleString(from: container, keys: [.otp]) + customerOtp = ApiService.decodeFlexibleString(from: container, keys: [.customerOtp]) + confirmOtp = ApiService.decodeFlexibleString(from: container, keys: [.confirmOtp]) + cancellationReason = ApiService.decodeFlexibleString(from: container, keys: [.cancellationReason]) + fullAddress = ApiService.decodeFlexibleString(from: container, keys: [.fullAddress]) + deliveryAddress = (try? container.decode(PublicOrderDeliveryAddress.self, forKey: .address)) + ?? (try? container.decode(PublicOrderDeliveryAddress.self, forKey: .deliveryAddress)) + ?? (try? container.decode(PublicOrderDeliveryAddress.self, forKey: .delivery_address)) + ?? (try? container.decode(PublicOrderDeliveryAddress.self, forKey: .customerAddress)) + ?? (try? container.decode(PublicOrderDeliveryAddress.self, forKey: .customer_address)) + review = (try? container.decode(PublicOrderReview.self, forKey: .review)) + ?? (try? container.decode(PublicOrderReview.self, forKey: .orderReview)) + items = (try? container.decode([PublicOrderItem].self, forKey: .items)) ?? [] + timeline = (try? container.decode([PublicOrderTimelineEvent].self, forKey: .timeline)) + ?? (try? container.decode([PublicOrderTimelineEvent].self, forKey: .history)) + ?? [] + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encodeIfPresent(shortId, forKey: .shortId) + try container.encodeIfPresent(realId, forKey: .realId) + try container.encodeIfPresent(storeId, forKey: .storeId) + try container.encodeIfPresent(status, forKey: .status) + try container.encodeIfPresent(paymentStatus, forKey: .paymentStatus) + try container.encodeIfPresent(paymentConfirmed, forKey: .paymentConfirmed) + try container.encodeIfPresent(paymentMethod, forKey: .paymentMethod) + try container.encodeIfPresent(paymentMethodCode, forKey: .paymentMethodCode) + try container.encodeIfPresent(paymentPayload, forKey: .paymentPayload) + try container.encodeIfPresent(payment, forKey: .payment) + try container.encodeIfPresent(nextAction, forKey: .nextAction) + try container.encodeIfPresent(deliveryType, forKey: .deliveryType) + try container.encodeIfPresent(deliveryTypeLabel, forKey: .deliveryTypeLabel) + try container.encodeIfPresent(subtotal, forKey: .subtotal) + try container.encodeIfPresent(deliveryFee, forKey: .deliveryFee) + try container.encodeIfPresent(discount, forKey: .discount) + try container.encodeIfPresent(total, forKey: .total) + try container.encodeIfPresent(storeName, forKey: .storeName) + try container.encodeIfPresent(storePhone, forKey: .storePhone) + try container.encodeIfPresent(createdAt, forKey: .createdAt) + try container.encodeIfPresent(updatedAt, forKey: .updatedAt) + try container.encodeIfPresent(otp, forKey: .otp) + try container.encodeIfPresent(customerOtp, forKey: .customerOtp) + try container.encodeIfPresent(confirmOtp, forKey: .confirmOtp) + try container.encodeIfPresent(cancellationReason, forKey: .cancellationReason) + try container.encodeIfPresent(fullAddress, forKey: .fullAddress) + try container.encodeIfPresent(deliveryAddress, forKey: .address) + try container.encodeIfPresent(review, forKey: .review) + try container.encode(items, forKey: .items) + try container.encode(timeline, forKey: .timeline) + } + + var displayOtpCode: String? { + let values = [customerOtp, otp, confirmOtp] + for value in values { + let trimmed = (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty == false { return trimmed } + } + return nil + } + + var isInDeliveryRoute: Bool { + let normalized = (status ?? "").uppercased() + if normalized.contains("OUT_FOR_DELIVERY") { return true } + if normalized.contains("EM_ROTA") { return true } + if normalized.contains("ON_ROUTE") { return true } + if normalized.contains("ROTA") { return true } + return false + } + + var isFinalStatus: Bool { + let normalized = (status ?? "").uppercased() + return normalized == "COMPLETED" || normalized == "CANCELED" || normalized == "REFUNDED" + } + + var isPaymentConfirmed: Bool { + if let paymentConfirmed { + return paymentConfirmed + } + + let payment = (paymentStatus ?? "").uppercased() + let currentStatus = (status ?? "").uppercased() + if Self.looksConfirmed(payment) || Self.looksConfirmed(currentStatus) { + return true + } + + // Fallback: alguns ambientes atualizam apenas a timeline primeiro. + if timeline.contains(where: { event in + let statusValue = (event.status ?? "").uppercased() + let messageValue = (event.message ?? "").uppercased() + return Self.looksConfirmed(statusValue) || Self.looksConfirmed(messageValue) + }) { + return true + } + + return false + } + + private static func looksConfirmed(_ value: String) -> Bool { + if value.isEmpty { return false } + if value.contains("PENDING") || value.contains("AWAIT") { return false } + if value.contains("FAILED") || value.contains("ERROR") { return false } + if value.contains("CANCEL") || value.contains("REFUND") { return false } + + if value.contains("CONFIRM") { return true } + if value.contains("APPROV") { return true } + if value.contains("PAID") { return true } + if value.contains("RECEIV") { return true } + + return value == "SUCCESS" || value == "DONE" + } +} + +struct PublicOrderDeliveryAddress: Codable { + let label: String? + let street: String? + let number: String? + let neighborhood: String? + let city: String? + let state: String? + let zip: String? + let complement: String? + + enum CodingKeys: String, CodingKey { + case label + case street + case address + case number + case neighborhood + case district + case city + case state + case zip + case zipCode + case zipcode + case complement + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + label = ApiService.decodeFlexibleString(from: container, keys: [.label]) + street = ApiService.decodeFlexibleString(from: container, keys: [.street, .address]) + number = ApiService.decodeFlexibleString(from: container, keys: [.number]) + neighborhood = ApiService.decodeFlexibleString(from: container, keys: [.neighborhood, .district]) + city = ApiService.decodeFlexibleString(from: container, keys: [.city]) + state = ApiService.decodeFlexibleString(from: container, keys: [.state]) + zip = ApiService.decodeFlexibleString(from: container, keys: [.zip, .zipCode, .zipcode]) + complement = ApiService.decodeFlexibleString(from: container, keys: [.complement]) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(label, forKey: .label) + try container.encodeIfPresent(street, forKey: .street) + try container.encodeIfPresent(number, forKey: .number) + try container.encodeIfPresent(neighborhood, forKey: .neighborhood) + try container.encodeIfPresent(city, forKey: .city) + try container.encodeIfPresent(state, forKey: .state) + try container.encodeIfPresent(zip, forKey: .zip) + try container.encodeIfPresent(complement, forKey: .complement) + } +} + +struct PublicOrderReview: Codable { + let id: String? + let orderId: String? + let rate: Int? + let message: String? + let orderRate: Int? + let orderComment: String? + let orderPositiveTags: [String]? + let orderImprovementTags: [String]? + let deliverySentiment: String? + let deliveryPositiveTags: [String]? + let deliveryNegativeTags: [String]? + let appNps: Int? + let platform: String? + let date: String? + + enum CodingKeys: String, CodingKey { + case id + case orderId + case rate + case message + case orderRate + case orderComment + case orderPositiveTags + case orderImprovementTags + case itemFeedback + case improvementFeedback + case deliverySentiment + case deliveryFeedback + case deliveryPositiveTags + case deliveryNegativeTags + case appNps + case app_nps + case platform + case date + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = ApiService.decodeFlexibleString(from: container, keys: [.id]) + orderId = ApiService.decodeFlexibleString(from: container, keys: [.orderId]) + rate = ApiService.decodeFlexibleInt(from: container, keys: [.rate]) + message = ApiService.decodeFlexibleString(from: container, keys: [.message]) + orderRate = ApiService.decodeFlexibleInt(from: container, keys: [.orderRate, .rate]) + orderComment = ApiService.decodeFlexibleString(from: container, keys: [.orderComment, .message]) + orderPositiveTags = Self.decodeStringList( + from: container, + keys: [.orderPositiveTags, .itemFeedback] + ) + orderImprovementTags = Self.decodeStringList( + from: container, + keys: [.orderImprovementTags, .improvementFeedback] + ) + deliverySentiment = ApiService.decodeFlexibleString(from: container, keys: [.deliverySentiment, .deliveryFeedback]) + deliveryPositiveTags = Self.decodeStringList(from: container, keys: [.deliveryPositiveTags]) + deliveryNegativeTags = Self.decodeStringList(from: container, keys: [.deliveryNegativeTags]) + appNps = Self.decodeNps(from: container, keys: [.appNps, .app_nps]) + platform = ApiService.decodeFlexibleString(from: container, keys: [.platform]) + date = ApiService.decodeFlexibleString(from: container, keys: [.date]) + } + + private static func decodeStringList( + from container: KeyedDecodingContainer, + keys: [CodingKeys] + ) -> [String]? { + for key in keys { + if let list = try? container.decode([String].self, forKey: key) { + return list + } + if let single = try? container.decode(String.self, forKey: key) { + let normalized = single.trimmingCharacters(in: .whitespacesAndNewlines) + if normalized.isEmpty == false { + return [normalized] + } + } + } + return nil + } + + private static func decodeNps( + from container: KeyedDecodingContainer, + keys: [CodingKeys] + ) -> Int? { + for key in keys { + if let value = try? container.decode(Int.self, forKey: key) { + return value + } + if let value = try? container.decode(Double.self, forKey: key) { + return Int(value.rounded()) + } + if let raw = try? container.decode(String.self, forKey: key) { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { continue } + if let asInt = Int(trimmed) { + return asInt + } + let normalized = trimmed.replacingOccurrences(of: ",", with: ".") + if let asDouble = Double(normalized) { + return Int(asDouble.rounded()) + } + } + } + return nil + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(id, forKey: .id) + try container.encodeIfPresent(orderId, forKey: .orderId) + try container.encodeIfPresent(rate, forKey: .rate) + try container.encodeIfPresent(message, forKey: .message) + try container.encodeIfPresent(orderRate, forKey: .orderRate) + try container.encodeIfPresent(orderComment, forKey: .orderComment) + try container.encodeIfPresent(orderPositiveTags, forKey: .orderPositiveTags) + try container.encodeIfPresent(orderImprovementTags, forKey: .orderImprovementTags) + try container.encodeIfPresent(deliverySentiment, forKey: .deliverySentiment) + try container.encodeIfPresent(deliveryPositiveTags, forKey: .deliveryPositiveTags) + try container.encodeIfPresent(deliveryNegativeTags, forKey: .deliveryNegativeTags) + try container.encodeIfPresent(appNps, forKey: .appNps) + try container.encodeIfPresent(platform, forKey: .platform) + try container.encodeIfPresent(date, forKey: .date) + } +} + +struct PublicOrderItem: Codable, Identifiable { + let id: String + let productId: String? + let name: String? + let qty: Int? + let price: Double? + + enum CodingKeys: String, CodingKey { + case id + case productId + case name + case qty + case quantity + case price + } + + init(id: String = UUID().uuidString, productId: String? = nil, name: String?, qty: Int?, price: Double?) { + self.id = id + self.productId = productId + self.name = name + self.qty = qty + self.price = price + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString + productId = try? container.decode(String.self, forKey: .productId) + name = try? container.decode(String.self, forKey: .name) + qty = ApiService.decodeFlexibleInt(from: container, keys: [.qty, .quantity]) + price = ApiService.decodeFlexibleDouble(from: container, keys: [.price]) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encodeIfPresent(productId, forKey: .productId) + try container.encodeIfPresent(name, forKey: .name) + try container.encodeIfPresent(qty, forKey: .qty) + try container.encodeIfPresent(price, forKey: .price) + } +} + +struct PublicOrderTimelineEvent: Codable, Identifiable { + let id: String + let status: String? + let label: String? + let active: Bool? + let completed: Bool? + let message: String? + let time: String? + let date: String? + + enum CodingKeys: String, CodingKey { + case id + case status + case label + case active + case completed + case message + case event + case time + case date + case createdAt + case updatedAt + } + + init( + id: String = UUID().uuidString, + status: String?, + label: String? = nil, + active: Bool? = nil, + completed: Bool? = nil, + message: String?, + time: String?, + date: String? = nil + ) { + self.id = id + self.status = status + self.label = label + self.active = active + self.completed = completed + self.message = message + self.time = time + self.date = date + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString + status = try? container.decode(String.self, forKey: .status) + label = try? container.decode(String.self, forKey: .label) + active = try? container.decode(Bool.self, forKey: .active) + completed = try? container.decode(Bool.self, forKey: .completed) + message = (try? container.decode(String.self, forKey: .message)) + ?? (try? container.decode(String.self, forKey: .event)) + time = (try? container.decode(String.self, forKey: .time)) + ?? (try? container.decode(String.self, forKey: .createdAt)) + ?? (try? container.decode(String.self, forKey: .updatedAt)) + date = try? container.decode(String.self, forKey: .date) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encodeIfPresent(status, forKey: .status) + try container.encodeIfPresent(label, forKey: .label) + try container.encodeIfPresent(active, forKey: .active) + try container.encodeIfPresent(completed, forKey: .completed) + try container.encodeIfPresent(message, forKey: .message) + try container.encodeIfPresent(time, forKey: .time) + try container.encodeIfPresent(date, forKey: .date) + } +} + +struct OrderRealtimeUpdate: Decodable { + let id: String? + let shortId: String? + let storeId: String? + let userId: String? + let status: String? + let paymentStatus: String? + let updatedAt: String? + + enum CodingKeys: String, CodingKey { + case id + case shortId + case storeId + case userId + case status + case paymentStatus + case updatedAt + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = ApiService.decodeFlexibleString(from: container, keys: [.id]) + shortId = ApiService.decodeFlexibleString(from: container, keys: [.shortId]) + storeId = ApiService.decodeFlexibleString(from: container, keys: [.storeId]) + userId = ApiService.decodeFlexibleString(from: container, keys: [.userId]) + status = ApiService.decodeFlexibleString(from: container, keys: [.status]) + paymentStatus = ApiService.decodeFlexibleString(from: container, keys: [.paymentStatus]) + updatedAt = ApiService.decodeFlexibleString(from: container, keys: [.updatedAt]) + } +} + +extension CreateOrderResult { + func asPublicOrderResult() -> PublicOrderResult { + PublicOrderResult( + id: id ?? UUID().uuidString, + shortId: shortId, + status: status, + paymentStatus: paymentStatus, + paymentConfirmed: paymentConfirmed, + paymentMethod: paymentMethod, + paymentPayload: paymentPayload, + payment: payment + ) + } +} diff --git a/PediFoods/Services/ApiPizzaModels.swift b/PediFoods/Services/ApiPizzaModels.swift new file mode 100644 index 0000000..56c0b75 --- /dev/null +++ b/PediFoods/Services/ApiPizzaModels.swift @@ -0,0 +1,80 @@ +import Foundation + +struct StorePizzaConfig: Decodable { + let sizes: [StorePizzaSize] + let doughs: [StorePizzaDough] + let crusts: [StorePizzaCrust] + + enum CodingKeys: String, CodingKey { + case sizes + case doughs + case crusts + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + sizes = (try? container.decode([StorePizzaSize].self, forKey: .sizes)) ?? [] + doughs = (try? container.decode([StorePizzaDough].self, forKey: .doughs)) ?? [] + crusts = (try? container.decode([StorePizzaCrust].self, forKey: .crusts)) ?? [] + } +} + +struct StorePizzaSize: Decodable, Identifiable { + let id: String + let name: String? + let slices: Int? + let maxFlavors: Int? + + enum CodingKeys: String, CodingKey { + case id + case name + case slices + case maxFlavors + } +} + +struct StorePizzaDough: Decodable, Identifiable { + let id: String + let name: String? + let active: Bool? + + enum CodingKeys: String, CodingKey { + case id + case name + case active + } +} + +struct StorePizzaCrust: Decodable, Identifiable { + let id: String + let name: String? + let active: Bool? + let priceModifier: Double? + + enum CodingKeys: String, CodingKey { + case id + case name + case active + case priceModifier + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString + name = try? container.decode(String.self, forKey: .name) + active = try? container.decode(Bool.self, forKey: .active) + + if let value = try? container.decode(Double.self, forKey: .priceModifier) { + priceModifier = value + } else if let value = try? container.decode(Int.self, forKey: .priceModifier) { + priceModifier = Double(value) + } else if let value = try? container.decode(String.self, forKey: .priceModifier) { + let normalized = value + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: ",", with: ".") + priceModifier = Double(normalized) + } else { + priceModifier = nil + } + } +} diff --git a/PediFoods/Services/ApiReviewModels.swift b/PediFoods/Services/ApiReviewModels.swift new file mode 100644 index 0000000..d4184b4 --- /dev/null +++ b/PediFoods/Services/ApiReviewModels.swift @@ -0,0 +1,268 @@ +import Foundation + +enum ReviewPlatform: String, Encodable { + case ios + case android + case web + + static var current: ReviewPlatform { + #if os(iOS) + return .ios + #else + return .web + #endif + } +} + +struct SubmitOrderReviewPayload: Encodable { + let rate: Int + let message: String + let orderRate: Int + let orderComment: String + let orderPositiveTags: [String] + let orderImprovementTags: [String] + let deliverySentiment: String + let deliveryPositiveTags: [String] + let deliveryNegativeTags: [String] + let appNps: Int + let platform: String +} + +struct ReviewTagItem: Decodable, Hashable, Identifiable { + let id: String + let label: String +} + +struct ReviewOrderTagRules: Decodable { + let positiveAllowedWhenRateGte: Int? + let improvementAllowedWhenRateLte: Int? +} + +struct ReviewOrderTagsCatalog: Decodable { + let positive: [ReviewTagItem] + let improvement: [ReviewTagItem] + let rules: ReviewOrderTagRules? +} + +struct ReviewDeliverySentimentRule: Decodable { + let id: String + let allowedTags: [String] +} + +struct ReviewDeliveryTagsCatalog: Decodable { + let sentiments: [ReviewDeliverySentimentRule] + let positive: [ReviewTagItem] + let negative: [ReviewTagItem] +} + +struct ReviewNpsCatalog: Decodable { + let min: Int? + let max: Int? +} + +struct ReviewAppTagsCatalog: Decodable { + let nps: ReviewNpsCatalog? + let platforms: [String]? +} + +struct ReviewTagsCatalog: Decodable { + let version: String? + let order: ReviewOrderTagsCatalog? + let delivery: ReviewDeliveryTagsCatalog? + let app: ReviewAppTagsCatalog? +} + +struct SubmitOrderReviewResult: Decodable { + let id: String? + let storeId: String? + let userId: String? + let clientName: String? + let rate: Int? + let message: String? + let orderRate: Int? + let orderComment: String? + let deliverySentiment: String? + let orderPositiveTags: [String]? + let orderImprovementTags: [String]? + let deliveryPositiveTags: [String]? + let deliveryNegativeTags: [String]? + let appNps: Int? + let platform: String? + let orderId: String? + let date: String? + let editableUntil: String? + let storeReplyUntil: String? + let reviewWindowExpiresAt: String? + let storeReplyMessage: String? + let storeReplyAt: String? + + enum CodingKeys: String, CodingKey { + case id + case storeId + case userId + case clientName + case rate + case message + case orderRate + case orderComment + case deliverySentiment + case deliveryFeedback + case itemFeedback + case improvementFeedback + case orderPositiveTags + case orderImprovementTags + case deliveryPositiveTags + case deliveryNegativeTags + case appNps + case app_nps + case platform + case orderId + case date + case editableUntil + case storeReplyUntil + case reviewWindowExpiresAt + case storeReply + case store_response + case storeResponse + case reply + case storeReplyMessage + case storeReplyAt + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = ApiService.decodeFlexibleString(from: container, keys: [.id]) + storeId = ApiService.decodeFlexibleString(from: container, keys: [.storeId]) + userId = ApiService.decodeFlexibleString(from: container, keys: [.userId]) + clientName = ApiService.decodeFlexibleString(from: container, keys: [.clientName]) + rate = ApiService.decodeFlexibleInt(from: container, keys: [.rate]) + message = ApiService.decodeFlexibleString(from: container, keys: [.message]) + orderRate = ApiService.decodeFlexibleInt(from: container, keys: [.orderRate, .rate]) + orderComment = ApiService.decodeFlexibleString(from: container, keys: [.orderComment, .message]) + deliverySentiment = ApiService.decodeFlexibleString(from: container, keys: [.deliverySentiment, .deliveryFeedback]) + orderPositiveTags = Self.decodeStringList(from: container, keys: [.orderPositiveTags, .itemFeedback]) + orderImprovementTags = Self.decodeStringList(from: container, keys: [.orderImprovementTags, .improvementFeedback]) + deliveryPositiveTags = (try? container.decode([String].self, forKey: .deliveryPositiveTags)) ?? nil + deliveryNegativeTags = (try? container.decode([String].self, forKey: .deliveryNegativeTags)) ?? nil + appNps = Self.decodeNps(from: container, keys: [.appNps, .app_nps]) + platform = ApiService.decodeFlexibleString(from: container, keys: [.platform]) + orderId = ApiService.decodeFlexibleString(from: container, keys: [.orderId]) + date = ApiService.decodeFlexibleString(from: container, keys: [.date]) + editableUntil = ApiService.decodeFlexibleString(from: container, keys: [.editableUntil]) + storeReplyUntil = ApiService.decodeFlexibleString(from: container, keys: [.storeReplyUntil]) + reviewWindowExpiresAt = ApiService.decodeFlexibleString(from: container, keys: [.reviewWindowExpiresAt]) + storeReplyMessage = Self.decodeReplyMessage(from: container) + storeReplyAt = Self.decodeReplyDate(from: container) + } + + private static func decodeStringList( + from container: KeyedDecodingContainer, + keys: [CodingKeys] + ) -> [String]? { + for key in keys { + if let list = try? container.decode([String].self, forKey: key) { + return list + } + if let single = try? container.decode(String.self, forKey: key) { + let normalized = single.trimmingCharacters(in: .whitespacesAndNewlines) + if normalized.isEmpty == false { + return [normalized] + } + } + } + return nil + } + + private static func decodeNps( + from container: KeyedDecodingContainer, + keys: [CodingKeys] + ) -> Int? { + for key in keys { + if let value = try? container.decode(Int.self, forKey: key) { + return value + } + if let value = try? container.decode(Double.self, forKey: key) { + return Int(value.rounded()) + } + if let raw = try? container.decode(String.self, forKey: key) { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { continue } + if let asInt = Int(trimmed) { + return asInt + } + let normalized = trimmed.replacingOccurrences(of: ",", with: ".") + if let asDouble = Double(normalized) { + return Int(asDouble.rounded()) + } + } + } + return nil + } + + private static func decodeReplyMessage(from container: KeyedDecodingContainer) -> String? { + if let value = ApiService.decodeFlexibleString(from: container, keys: [.storeReplyMessage]) { + return value + } + if let value = ApiService.decodeFlexibleString(from: container, keys: [.storeReply, .store_response, .storeResponse, .reply]) { + return value + } + for key in [CodingKeys.storeReply, .store_response, .storeResponse, .reply] { + if let object = try? container.decode([String: String].self, forKey: key) { + let candidates = ["message", "text", "reply", "content", "body"] + for candidate in candidates { + let value = (object[candidate] ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if value.isEmpty == false { return value } + } + } + } + return nil + } + + private static func decodeReplyDate(from container: KeyedDecodingContainer) -> String? { + if let value = ApiService.decodeFlexibleString(from: container, keys: [.storeReplyAt]) { + return value + } + for key in [CodingKeys.storeReply, .store_response, .storeResponse, .reply] { + if let object = try? container.decode([String: String].self, forKey: key) { + let candidates = ["date", "createdAt", "updatedAt", "repliedAt", "replyAt"] + for candidate in candidates { + let value = (object[candidate] ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if value.isEmpty == false { return value } + } + } + } + return nil + } +} + +struct PublicStoreReviewsResult: Decodable { + let reviews: [SubmitOrderReviewResult] + + enum CodingKeys: String, CodingKey { + case reviews + case data + case items + } + + init(from decoder: Decoder) throws { + if let list = try? [SubmitOrderReviewResult](from: decoder) { + reviews = list + return + } + + let container = try decoder.container(keyedBy: CodingKeys.self) + if let list = try? container.decode([SubmitOrderReviewResult].self, forKey: .reviews) { + reviews = list + return + } + if let list = try? container.decode([SubmitOrderReviewResult].self, forKey: .data) { + reviews = list + return + } + if let list = try? container.decode([SubmitOrderReviewResult].self, forKey: .items) { + reviews = list + return + } + reviews = [] + } +} diff --git a/PediFoods/Services/ApiService.swift b/PediFoods/Services/ApiService.swift new file mode 100644 index 0000000..19aa0e7 --- /dev/null +++ b/PediFoods/Services/ApiService.swift @@ -0,0 +1,650 @@ +import Foundation + +enum ApiServiceError: Error, LocalizedError { + case sessionExpired(String?) + + var errorDescription: String? { + switch self { + case .sessionExpired(let message): + return message ?? "Sessao expirada. Faca login novamente." + } + } +} + +struct ApiEnvelope: Decodable, Sendable { + let error: Bool + let code: String? + let message: String? + let result: T? +} + +final class ApiService { + private let client: ApiClient + private var tokenStore: TokenStore + private let profileCachePrefix = "api:profile:" + private let ordersCachePrefix = "api:orders:" + private let favoritesCachePrefix = "api:favorites:" + private let publicCategoriesCacheKey = "api:public-categories" + + init(client: ApiClient = ApiClient(), tokenStore: TokenStore = DefaultTokenStore()) { + self.client = client + self.tokenStore = tokenStore + } + + private func send(_ req: ApiRequest) async throws -> T { + do { + return try await client.send(req) + } catch let error as NetworkError { + if canTriggerSessionExpiry(for: req), case .unauthorized(let message) = error { + expireSession(message) + throw ApiServiceError.sessionExpired(message) + } + throw error + } + } + + private func sendEnvelope(_ req: ApiRequest) async throws -> ApiEnvelope { + let envelope: ApiEnvelope = try await send(req) + if canTriggerSessionExpiry(for: req), isSessionExpiredEnvelope(envelope) { + expireSession(envelope.message) + throw ApiServiceError.sessionExpired(envelope.message) + } + return envelope + } + + /// A request that never carried the customer JWT (public/unauthenticated + /// calls) can never mean *the customer's* session expired — an unrelated + /// error (e.g. Atomenta's module-token check) must not force-logout a + /// user, anonymous or not, just because its error code happens to + /// contain the substring "token". See app-migrate-atomenta-calls-to-pedifoods-bff.md. + private func canTriggerSessionExpiry(for req: ApiRequest) -> Bool { + req.requiresAuth && tokenStore.jwt != nil + } + + private func isSessionExpiredEnvelope(_ envelope: ApiEnvelope) -> Bool { + guard envelope.error else { return false } + let code = (envelope.code ?? "").lowercased() + let message = (envelope.message ?? "").lowercased() + if code.contains("auth") || code.contains("token") || code.contains("unauthorized") { + return true + } + if message.contains("token") && (message.contains("expir") || message.contains("invalid") || message.contains("sess")) { + return true + } + return false + } + + private func expireSession(_ message: String?) { + tokenStore.clear() + AppContentCache.shared.invalidate(prefix: profileCachePrefix) + AppContentCache.shared.invalidate(prefix: ordersCachePrefix) + AppContentCache.shared.invalidate(prefix: favoritesCachePrefix) + AppContentCache.shared.invalidate(prefix: publicCategoriesCacheKey) + NotificationCenter.default.post(name: .sessionExpired, object: message) + } + + private func invalidateFavoritesCache() { + AppContentCache.shared.invalidate(prefix: profileCachePrefix) + AppContentCache.shared.invalidate(prefix: favoritesCachePrefix) + } + + /// Every successful profile mutation must leave `profile()`'s cache + /// holding the server's authoritative post-mutation state — never + /// patched locally from a write-response of possibly different shape, + /// and never left merely invalidated for some future caller to lazily + /// refetch (which may never happen, leaving stale data visible + /// indefinitely within the TTL). Always does a real GET. + @discardableResult + private func refreshProfileCache() async -> ApiEnvelope? { + try? await profile(forceRefresh: true) + } + + private func scopedCacheSuffix() -> String { + let jwt = tokenStore.jwt ?? "anonymous" + if jwt.count <= 16 { return jwt } + return String(jwt.prefix(16)) + } + + // MARK: - Auth + + func registerCustomer(name: String, email: String, phoneNumber: String, birthDate: String? = nil) async throws -> ApiEnvelope { + var payload: [String: String] = [ + "name": name, + "email": email, + "phoneNumber": phoneNumber + ] + if let birthDate, birthDate.isEmpty == false { + payload["birthDate"] = birthDate + } + // If the visitor picked a state/city via the public locator before + // signing up, forward it so the backend can set it as the account's + // default city. NOTE: as of this writing Atomenta's customer create + // controller only reads name/email/phoneNumber — these two fields + // are a no-op server-side until that controller is updated to + // persist them (see docs/plans/app-migrate-atomenta-calls-to-pedifoods-bff.md). + if let state = GuestLocationStore.shared.selectedState, let city = GuestLocationStore.shared.selectedCity { + payload["defaultState"] = state + payload["defaultCity"] = city + } + let body = try JSONEncoder().encode(payload) + let req = ApiRequest(path: "/api/customer", method: "POST", module: .customer, requiresAuth: false, body: body) + return try await sendEnvelope(req) + } + + func requestOtp(email: String, phoneNumber: String) async throws -> ApiEnvelope { + let body = try makeLoginBody(email: email, phoneNumber: phoneNumber, otp: nil) + let req = ApiRequest(path: "/api/customer/login", method: "POST", module: .customer, requiresAuth: false, body: body) + return try await sendEnvelope(req) + } + + func validateOtp(email: String, phoneNumber: String, otp: String) async throws -> ApiEnvelope { + let body = try makeLoginBody(email: email, phoneNumber: phoneNumber, otp: otp) + let req = ApiRequest(path: "/api/customer/login", method: "POST", module: .customer, requiresAuth: false, body: body) + let response: ApiEnvelope = try await sendEnvelope(req) + if let token = response.result?.token { + tokenStore.jwt = token + } + return response + } + + private func makeLoginBody(email: String, phoneNumber: String, otp: String?) throws -> Data { + let sanitizedEmail = email.trimmingCharacters(in: .whitespacesAndNewlines) + let sanitizedPhone = phoneNumber.trimmingCharacters(in: .whitespacesAndNewlines) + + guard sanitizedEmail.isEmpty == false, sanitizedPhone.isEmpty == false else { + throw NetworkError.httpError(400, "Email e telefone são obrigatórios.") + } + + var payload: [String: String] = [ + "email": sanitizedEmail, + "phoneNumber": sanitizedPhone, + "phone": sanitizedPhone + ] + if let otp, otp.isEmpty == false { + payload["otp"] = otp + } + + guard JSONSerialization.isValidJSONObject(payload) else { + throw NetworkError.invalidResponse + } + return try JSONSerialization.data(withJSONObject: payload, options: []) + } + + func profile(forceRefresh: Bool = false) async throws -> ApiEnvelope { + let cacheKey = "\(profileCachePrefix)\(scopedCacheSuffix())" + if forceRefresh == false, + let cached: ApiEnvelope = AppContentCache.shared.value(for: cacheKey, as: ApiEnvelope.self) { + return cached + } + + let req = ApiRequest(path: "/api/customer/profile", method: "GET", module: .customer, requiresAuth: true) + let envelope: ApiEnvelope = try await sendEnvelope(req) + if envelope.error == false, envelope.result != nil { + AppContentCache.shared.set(envelope, for: cacheKey, ttl: AppCacheTTL.twoHours) + } + return envelope + } + + func updateCustomerProfile(name: String, email: String, phoneNumber: String, profilePicture: String?) async throws -> ProfilePatchEnvelope { + let payload = CustomerIdentityUpdatePayload( + name: name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : name, + email: email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : email, + phoneNumber: phoneNumber.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : phoneNumber, + profilePicture: profilePicture + ) + let body = try JSONEncoder().encode(payload) + let req = ApiRequest(path: "/api/customer/profile", method: "PATCH", module: .customer, requiresAuth: true, body: body) + let envelope: ProfilePatchEnvelope = try await send(req) + if envelope.error == false { + await refreshProfileCache() + } + return envelope + } + + /// See docs/api/push-notifications-integration-guide.md §2b — only reachable + /// via `POST /api/customer/:id` today, not `PATCH /profile`. + func updateNotificationsEnabled(_ enabled: Bool) async throws -> ApiEnvelope { + let currentProfile = try await profile(forceRefresh: true) + guard currentProfile.error == false, let customer = currentProfile.result else { + throw NetworkError.invalidResponse + } + + let payload = CustomerNotificationsUpdatePayload(notificationsEnabled: enabled) + let body = try JSONEncoder().encode(payload) + let req = ApiRequest(path: "/api/customer/\(customer.id)", method: "POST", module: .customer, requiresAuth: true, body: body) + let envelope: ApiEnvelope = try await sendEnvelope(req) + if envelope.error == false { + await refreshProfileCache() + } + return envelope + } + + /// Persists the biometric-login preference only — no LocalAuthentication + /// wiring yet, that's a separate later plan. + func updateFaceIdEnabled(_ enabled: Bool) async throws -> ApiEnvelope { + let currentProfile = try await profile(forceRefresh: true) + guard currentProfile.error == false, let customer = currentProfile.result else { + throw NetworkError.invalidResponse + } + + let payload = CustomerFaceIdUpdatePayload(faceIdEnabled: enabled) + let body = try JSONEncoder().encode(payload) + let req = ApiRequest(path: "/api/customer/\(customer.id)", method: "POST", module: .customer, requiresAuth: true, body: body) + let envelope: ApiEnvelope = try await sendEnvelope(req) + if envelope.error == false { + await refreshProfileCache() + } + return envelope + } + + /// See docs/api/push-notifications-integration-guide.md §2. + func registerPushToken(_ token: String, deviceId: String, deviceOS: String) async throws -> ApiEnvelope { + let currentProfile = try await profile(forceRefresh: true) + guard currentProfile.error == false, let customer = currentProfile.result else { + throw NetworkError.invalidResponse + } + + let payload = CustomerPushTokenPayload(pushToken: token, deviceId: deviceId, deviceOS: deviceOS) + let body = try JSONEncoder().encode(payload) + let req = ApiRequest(path: "/api/customer/\(customer.id)/push-token", method: "PUT", module: .customer, requiresAuth: true, body: body) + return try await sendEnvelope(req) + } + + /// See docs/api/push-notifications-integration-guide.md §2a. Wholesale + /// replace, not a merge — callers must pass every `attributes` key they + /// still want kept, not just the changed ones. + func updateCustomerAttributes(appVersion: String?, attributes: [String: String]?) async throws -> ApiEnvelope { + let currentProfile = try await profile(forceRefresh: true) + guard currentProfile.error == false, let customer = currentProfile.result else { + throw NetworkError.invalidResponse + } + + let payload = CustomerAttributesUpdatePayload(appVersion: appVersion, attributes: attributes) + let body = try JSONEncoder().encode(payload) + let req = ApiRequest(path: "/api/customer/\(customer.id)/attributes", method: "PUT", module: .customer, requiresAuth: true, body: body) + return try await sendEnvelope(req) + } + + /// See docs/api/push-notifications-integration-guide.md §6a. Fire on tap + /// only, for `type: "campaign"` pushes — idempotent server-side. + func reportPushCampaignOpened(campaignId: String) async throws -> ApiEnvelope { + let currentProfile = try await profile(forceRefresh: true) + guard currentProfile.error == false, let customer = currentProfile.result else { + throw NetworkError.invalidResponse + } + + let payload = PushCampaignOpenedPayload(campaignId: campaignId) + let body = try JSONEncoder().encode(payload) + let req = ApiRequest(path: "/api/customer/\(customer.id)/push-campaigns/opened", method: "POST", module: .customer, requiresAuth: true, body: body) + return try await sendEnvelope(req) + } + + func addCustomerAddress(_ address: CustomerAddress) async throws -> ApiEnvelope { + return try await saveCustomerAddress(address, replacingAddressId: nil) + } + + func saveCustomerAddress(_ address: CustomerAddress, replacingAddressId: String?) async throws -> ApiEnvelope { + let currentProfile = try await profile(forceRefresh: true) + guard currentProfile.error == false, let customer = currentProfile.result else { + throw NetworkError.invalidResponse + } + + let currentAddressBook = customer.addressBook ?? [] + var addressBook = currentAddressBook.map(CustomerAddressPayload.init(from:)) + let addressPayload = CustomerAddressPayload(from: address) + + if let replacingAddressId, + let replaceIndex = currentAddressBook.firstIndex(where: { $0.id == replacingAddressId }) { + addressBook[replaceIndex] = addressPayload + } else { + addressBook.insert(addressPayload, at: 0) + } + + return try await updateCustomerAddressBook(customerId: customer.id, addressBook: addressBook) + } + + func deleteCustomerAddress(_ address: CustomerAddress) async throws -> ApiEnvelope { + let currentProfile = try await profile(forceRefresh: true) + guard currentProfile.error == false, let customer = currentProfile.result else { + throw NetworkError.invalidResponse + } + + var currentAddressBook = customer.addressBook ?? [] + if let targetId = address.id, targetId.isEmpty == false { + if let index = currentAddressBook.firstIndex(where: { $0.id == targetId }) { + currentAddressBook.remove(at: index) + } + } else if let index = currentAddressBook.firstIndex(where: { matchesAddress($0, address) }) { + currentAddressBook.remove(at: index) + } + + let updatedAddressBook = currentAddressBook.map(CustomerAddressPayload.init(from:)) + return try await updateCustomerAddressBook(customerId: customer.id, addressBook: updatedAddressBook) + } + + func setDefaultAddress(addressId: String) async throws -> ApiEnvelope { + let req = ApiRequest(path: "/api/customer/addresses/\(addressId)/default", method: "PATCH", module: .customer, requiresAuth: true) + let envelope: ApiEnvelope = try await sendEnvelope(req) + if envelope.error == false { + await refreshProfileCache() + } + return envelope + } + + func lookupZipCode(_ zipCode: String) async throws -> ApiEnvelope { + let digits = zipCode.filter(\.isNumber) + let normalized = String(digits.prefix(8)) + let formatted: String + if normalized.count == 8 { + let prefix = String(normalized.prefix(5)) + let suffix = String(normalized.dropFirst(5)) + formatted = "\(prefix)-\(suffix)" + } else { + formatted = normalized + } + + let req = ApiRequest(path: "/api/public/cep/\(formatted)", method: "GET", module: .none, requiresAuth: true) + return try await sendEnvelope(req) + } + + private func updateCustomerAddressBook(customerId: String, addressBook: [CustomerAddressPayload]) async throws -> ApiEnvelope { + let payload = CustomerProfileUpdatePayload(addressBook: addressBook) + let body = try JSONEncoder().encode(payload) + let req = ApiRequest(path: "/api/customer/\(customerId)", method: "POST", module: .customer, requiresAuth: true, body: body) + let envelope: ApiEnvelope = try await sendEnvelope(req) + if envelope.error == false { + await refreshProfileCache() + } else { + invalidateFavoritesCache() + } + return envelope + } + + func listFavoriteStores(forceRefresh: Bool = false) async throws -> ApiEnvelope<[StoreSummary]> { + let cacheKey = "\(favoritesCachePrefix)\(scopedCacheSuffix())" + if forceRefresh == false, + let cached: ApiEnvelope<[StoreSummary]> = AppContentCache.shared.value(for: cacheKey, as: ApiEnvelope<[StoreSummary]>.self) { + return cached + } + + let req = ApiRequest(path: "/api/customer/favorites", method: "GET", module: .customer, requiresAuth: true) + let envelope: ApiEnvelope<[StoreSummary]> = try await sendEnvelope(req) + if envelope.error == false, envelope.result != nil { + AppContentCache.shared.set(envelope, for: cacheKey, ttl: AppCacheTTL.twoHours) + } + return envelope + } + + func addStoreToFavorites(storeId: String) async throws -> ApiEnvelope { + let req = ApiRequest(path: "/api/customer/favorites/\(storeId)", method: "POST", module: .customer, requiresAuth: true) + let envelope: ApiEnvelope = try await sendEnvelope(req) + if envelope.error == false { + invalidateFavoritesCache() + } + return envelope + } + + func removeStoreFromFavorites(storeId: String) async throws -> ApiEnvelope { + let req = ApiRequest(path: "/api/customer/favorites/\(storeId)", method: "DELETE", module: .customer, requiresAuth: true) + let envelope: ApiEnvelope = try await sendEnvelope(req) + if envelope.error == false { + invalidateFavoritesCache() + } + return envelope + } + + func setStoreFavorite(storeId: String, isFavorite: Bool) async throws -> ApiEnvelope { + if isFavorite { + return try await addStoreToFavorites(storeId: storeId) + } + return try await removeStoreFromFavorites(storeId: storeId) + } + + private func matchesAddress(_ lhs: CustomerAddress, _ rhs: CustomerAddress) -> Bool { + lhs.label == rhs.label && + lhs.address == rhs.address && + lhs.number == rhs.number && + lhs.complement == rhs.complement && + lhs.neighborhood == rhs.neighborhood && + lhs.city == rhs.city && + lhs.state == rhs.state && + lhs.zipCode == rhs.zipCode + } + + // MARK: - Stores + + func listPublicCategories(forceRefresh: Bool = false) async throws -> ApiEnvelope<[PublicCategory]> { + if forceRefresh == false, + let cached: ApiEnvelope<[PublicCategory]> = AppContentCache.shared.value(for: publicCategoriesCacheKey, as: ApiEnvelope<[PublicCategory]>.self) { + return cached + } + + // Open endpoint, no guest session needed, but it lives on the BFF + // domain (pedifoods.com.br), not Atomenta — see + // docs/plans/app-migrate-atomenta-calls-to-pedifoods-bff.md. + let req = ApiRequest(path: "/api/public/categories", method: "GET", module: .none, requiresAuth: false, baseURLOverride: ApiConfig.pediFoodsBFFURL) + let envelope: ApiEnvelope<[PublicCategory]> = try await sendEnvelope(req) + if envelope.error == false { + AppContentCache.shared.set(envelope, for: publicCategoriesCacheKey, ttl: AppCacheTTL.twoHours) + } + return envelope + } + + func listStores(lat: Double? = nil, lng: Double? = nil, category: String? = nil, search: String? = nil) async throws -> ApiEnvelope<[StoreSummary]> { + var items: [URLQueryItem] = [] + if let lat, let lng { + items.append(URLQueryItem(name: "lat", value: String(lat))) + items.append(URLQueryItem(name: "lng", value: String(lng))) + } + if let category { + items.append(URLQueryItem(name: "category", value: category)) + } + if let search { + items.append(URLQueryItem(name: "search", value: search)) + } + let req = ApiRequest(path: "/api/app/stores", method: "GET", module: .app, requiresAuth: true, queryItems: items) + return try await sendEnvelope(req) + } + + func storeInfo(storeId: String) async throws -> ApiEnvelope { + let req = ApiRequest(path: "/api/store/\(storeId)/info", method: "GET", module: .store, requiresAuth: true) + return try await sendEnvelope(req) + } + + func storeCatalog(storeId: String) async throws -> ApiEnvelope<[StoreCatalogCategory]> { + let req = ApiRequest(path: "/api/store/\(storeId)/catalog", method: "GET", module: .store, requiresAuth: true) + return try await sendEnvelope(req) + } + + func createOrder(storeId: String, payload: CreateOrderPayload) async throws -> ApiEnvelope { + let body = try JSONEncoder().encode(payload) + let req = ApiRequest(path: "/api/store/\(storeId)/orders", method: "POST", module: .store, requiresAuth: true, body: body) + let envelope: ApiEnvelope = try await sendEnvelope(req) + if envelope.error == false { + AppContentCache.shared.invalidate(prefix: ordersCachePrefix) + } + return envelope + } + + func validateDeliveryAddress(storeId: String, payload: ValidateDeliveryAddressPayload) async throws -> ApiEnvelope { + let body = try JSONEncoder().encode(payload) + let req = ApiRequest(path: "/api/store/\(storeId)/delivery/validate-address", method: "POST", module: .store, requiresAuth: true, body: body) + return try await sendEnvelope(req) + } + + func listOrders(forceRefresh: Bool = false) async throws -> ApiEnvelope<[AppOrderSummary]> { + let cacheKey = "\(ordersCachePrefix)\(scopedCacheSuffix())" + if forceRefresh == false, + let cached: ApiEnvelope<[AppOrderSummary]> = AppContentCache.shared.value(for: cacheKey, as: ApiEnvelope<[AppOrderSummary]>.self) { + return cached + } + + let req = ApiRequest( + path: "/api/app/orders", + method: "GET", + module: .app, + requiresAuth: true, + queryItems: [URLQueryItem(name: "_ts", value: String(Int(Date().timeIntervalSince1970)))] + ) + let envelope: ApiEnvelope<[AppOrderSummary]> = try await sendEnvelope(req) + if envelope.error == false { + AppContentCache.shared.set(envelope, for: cacheKey, ttl: AppCacheTTL.twoHours) + } + return envelope + } + + func publicOrder(orderId: String) async throws -> ApiEnvelope { + let req = ApiRequest( + path: "/api/public/orders/\(orderId)", + method: "GET", + module: .none, + requiresAuth: true, + queryItems: [URLQueryItem(name: "_ts", value: String(Int(Date().timeIntervalSince1970)))] + ) + return try await sendEnvelope(req) + } + + func submitOrderReview(orderId: String, payload: SubmitOrderReviewPayload) async throws -> ApiEnvelope { + let body = try JSONEncoder().encode(payload) + let req = ApiRequest( + path: "/api/public/orders/\(orderId)/review", + method: "POST", + module: .none, + requiresAuth: true, + body: body + ) + return try await sendEnvelope(req) + } + + func reviewTagsCatalog() async throws -> ApiEnvelope { + let req = ApiRequest( + path: "/api/public/reviews/tags", + method: "GET", + module: .none, + requiresAuth: false + ) + return try await sendEnvelope(req) + } + + func publicStoreReviews(storeId: String) async throws -> ApiEnvelope { + let req = ApiRequest( + path: "/api/public/store/\(storeId)/reviews", + method: "GET", + module: .none, + requiresAuth: false, + queryItems: [URLQueryItem(name: "_ts", value: String(Int(Date().timeIntervalSince1970)))] + ) + return try await sendEnvelope(req) + } + + // MARK: - Cards + + func listCards() async throws -> ApiEnvelope<[SavedCard]> { + let req = ApiRequest(path: "/api/customer/cards", method: "GET", module: .customer, requiresAuth: true) + return try await sendEnvelope(req) + } + + func saveCard(payload: SaveCardPayload) async throws -> ApiEnvelope { + let body = try JSONEncoder().encode(payload) + let req = ApiRequest(path: "/api/customer/cards", method: "POST", module: .customer, requiresAuth: true, body: body) + return try await sendEnvelope(req) + } + + func updateCard(cardId: String, payload: UpdateCardPayload) async throws -> ApiEnvelope { + let body = try JSONEncoder().encode(payload) + let req = ApiRequest(path: "/api/customer/cards/\(cardId)", method: "PATCH", module: .customer, requiresAuth: true, body: body) + return try await sendEnvelope(req) + } + + func deleteCard(cardId: String) async throws -> ApiEnvelope { + let req = ApiRequest(path: "/api/customer/cards/\(cardId)", method: "DELETE", module: .customer, requiresAuth: true) + return try await sendEnvelope(req) + } + + func deleteAccount() async throws -> ApiEnvelope { + let req = ApiRequest(path: "/api/customer/account", method: "DELETE", module: .customer, requiresAuth: true) + return try await sendEnvelope(req) + } + + func changePaymentMethod(storeId: String, orderId: String, payload: ChangePaymentMethodPayload) async throws -> ApiEnvelope { + let body = try JSONEncoder().encode(payload) + let req = ApiRequest(path: "/api/store/\(storeId)/orders/\(orderId)/payment-method", method: "PATCH", module: .store, requiresAuth: true, body: body) + return try await sendEnvelope(req) + } + + // MARK: - Profile CPF + + func updateProfileCpf(cpf: String) async throws -> ApiEnvelope { + let payload = ["cpf": cpf] + let body = try JSONEncoder().encode(payload) + let req = ApiRequest(path: "/api/customer/profile", method: "PATCH", module: .customer, requiresAuth: true, body: body) + let result: ApiEnvelope = try await sendEnvelope(req) + if result.error == false { + await refreshProfileCache() + } + return result + } +} +extension ApiService { + static func decodeFlexibleString(from container: KeyedDecodingContainer, keys: [K]) -> String? { + for key in keys { + if let value = try? container.decode(String.self, forKey: key) { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty == false { + return trimmed + } + } + if let asInt = try? container.decode(Int.self, forKey: key) { + return String(asInt) + } + if let asDouble = try? container.decode(Double.self, forKey: key) { + if asDouble.rounded() == asDouble { + return String(Int(asDouble)) + } + return String(asDouble) + } + } + return nil + } + + static func decodeFlexibleDouble(from container: KeyedDecodingContainer, keys: [K]) -> Double? { + for key in keys { + if let value = try? container.decode(Double.self, forKey: key) { + return value + } + if let asInt = try? container.decode(Int.self, forKey: key) { + return Double(asInt) + } + if let asString = try? container.decode(String.self, forKey: key) { + let normalized = asString + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: ",", with: ".") + if let parsed = Double(normalized) { + return parsed + } + } + } + return nil + } + + static func decodeFlexibleInt(from container: KeyedDecodingContainer, keys: [K]) -> Int? { + for key in keys { + if let value = try? container.decode(Int.self, forKey: key) { + return value + } + if let asDouble = try? container.decode(Double.self, forKey: key) { + return Int(asDouble) + } + if let asString = try? container.decode(String.self, forKey: key) { + let normalized = asString + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: ".", with: "") + .replacingOccurrences(of: ",", with: "") + if let parsed = Int(normalized) { + return parsed + } + } + } + return nil + } +} diff --git a/PediFoods/Services/AppCache.swift b/PediFoods/Services/AppCache.swift new file mode 100644 index 0000000..afd4f3f --- /dev/null +++ b/PediFoods/Services/AppCache.swift @@ -0,0 +1,131 @@ +import Foundation +import UIKit +typealias PlatformImage = UIImage + +enum AppCacheTTL { + static let twoHours: TimeInterval = 2 * 60 * 60 + static let homeStores: TimeInterval = 5 * 60 +} + +enum AppCacheKey { + static let homeStoresLatestSnapshot = "home-stores.latest.snapshot" +} + +final class AppContentCache: @unchecked Sendable { + static let shared = AppContentCache() + + private struct Entry { + let value: Any + let expiry: Date + } + + private var entries: [String: Entry] = [:] + private let queue = DispatchQueue(label: "com.pedifoods.content-cache", qos: .userInitiated) + + private init() {} + + func value(for key: String, as type: T.Type = T.self) -> T? { + queue.sync { + guard let entry = entries[key] else { return nil } + if entry.expiry <= Date() { + entries.removeValue(forKey: key) + return nil + } + return entry.value as? T + } + } + + func set(_ value: T, for key: String, ttl: TimeInterval) { + queue.sync { + entries[key] = Entry(value: value, expiry: Date().addingTimeInterval(ttl)) + } + } + + func invalidate(prefix: String? = nil) { + queue.sync { + guard let prefix, prefix.isEmpty == false else { + entries.removeAll() + return + } + + let keys = entries.keys.filter { $0.hasPrefix(prefix) } + for key in keys { + entries.removeValue(forKey: key) + } + } + } +} + +final class AppImageCache: @unchecked Sendable { + static let shared = AppImageCache() + + private struct Entry { + let image: PlatformImage + let expiry: Date + } + + private var entries: [String: Entry] = [:] + private let queue = DispatchQueue(label: "com.pedifoods.image-cache", qos: .userInitiated) + + private init() { + configureURLCacheIfNeeded() + } + + func image(for url: URL, ttl: TimeInterval, forceRefresh: Bool = false) async -> PlatformImage? { + let key = url.absoluteString + let now = Date() + + if forceRefresh == false { + let cached = queue.sync { entries[key] } + if let cached, cached.expiry > now { + return cached.image + } + } + + var request = URLRequest(url: url) + request.timeoutInterval = 20 + request.cachePolicy = forceRefresh ? .reloadIgnoringLocalCacheData : .returnCacheDataElseLoad + + if forceRefresh == false, + let diskCached = URLCache.shared.cachedResponse(for: request), + let image = platformImage(from: diskCached.data) { + queue.sync { + entries[key] = Entry(image: image, expiry: now.addingTimeInterval(ttl)) + } + return image + } + + do { + let (data, response) = try await URLSession.shared.data(for: request) + guard let image = platformImage(from: data) else { return nil } + URLCache.shared.storeCachedResponse(CachedURLResponse(response: response, data: data), for: request) + queue.sync { + entries[key] = Entry(image: image, expiry: now.addingTimeInterval(ttl)) + } + return image + } catch { + return nil + } + } + + func invalidateAll() { + queue.sync { + entries.removeAll() + } + URLCache.shared.removeAllCachedResponses() + } + + private func configureURLCacheIfNeeded() { + let current = URLCache.shared + let minMemoryCapacity = 64 * 1024 * 1024 + let minDiskCapacity = 256 * 1024 * 1024 + + if current.memoryCapacity < minMemoryCapacity || current.diskCapacity < minDiskCapacity { + URLCache.shared = URLCache(memoryCapacity: minMemoryCapacity, diskCapacity: minDiskCapacity) + } + } + + private func platformImage(from data: Data) -> PlatformImage? { + UIImage(data: data) + } +} diff --git a/PediFoods/Services/FeatureControlService.swift b/PediFoods/Services/FeatureControlService.swift new file mode 100644 index 0000000..cc756b8 --- /dev/null +++ b/PediFoods/Services/FeatureControlService.swift @@ -0,0 +1,278 @@ +import Foundation + +struct FeatureControlRawFlag: Codable, Equatable { + let enabled: Bool + let variant: String + let payload: FeatureControlJSONValue? + let reason: String? +} + +enum FeatureControlJSONValue: Codable, Equatable { + case string(String) + case number(Double) + case bool(Bool) + case object([String: FeatureControlJSONValue]) + case array([FeatureControlJSONValue]) + case null + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Double.self) { + self = .number(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([String: FeatureControlJSONValue].self) { + self = .object(value) + } else if let value = try? container.decode([FeatureControlJSONValue].self) { + self = .array(value) + } else { + self = .null + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .string(let value): try container.encode(value) + case .number(let value): try container.encode(value) + case .bool(let value): try container.encode(value) + case .object(let value): try container.encode(value) + case .array(let value): try container.encode(value) + case .null: try container.encodeNil() + } + } +} + +private struct FeatureControlBootstrapRequest: Codable { + struct Context: Codable { + let subjectType: String + let subjectId: String + let storeId: String? + let platform: String + let appVersion: String + let attributes: [String: String] + } + + let environment: String + let keys: [String] + let context: Context +} + +private struct FeatureControlBootstrapResponse: Codable { + let ok: Bool + let source: String? + let configVersion: Int + let evaluatedAt: String? + let flags: [String: FeatureFlagValue] + let raw: [String: FeatureControlRawFlag] +} + +private struct FeatureControlExposureRequest: Codable { + struct Event: Codable { + let featureKey: String + let variant: String + let subjectType: String + let storeId: String? + } + + let events: [Event] +} + +private struct FeatureControlCacheEntry: Codable { + let expiresAtUnixMs: Int64 + let snapshot: FeatureFlagsState +} + +struct FeatureControlEvaluationContext { + let subjectType: String + let subjectId: String + let storeId: String? + let attributes: [String: String] +} + +@MainActor +final class FeatureControlService { + static let shared = FeatureControlService() + + private let session: URLSession + private let cacheTTL: TimeInterval + private let decoder = JSONDecoder() + private let encoder = JSONEncoder() + private let userDefaults: UserDefaults + private let defaultsPrefix = "feature-control.cache.v1." + + init( + session: URLSession = .shared, + cacheTTL: TimeInterval = 60, + userDefaults: UserDefaults = .standard + ) { + self.session = session + self.cacheTTL = cacheTTL + self.userDefaults = userDefaults + } + + func evaluate( + context: FeatureControlEvaluationContext, + jwt: String?, + forceRefresh: Bool = false + ) async -> FeatureFlagsState { + let key = storageKey(for: context) + if forceRefresh == false, let cached = loadFromCache(storageKey: key) { + return cached + } + + let requestBody = FeatureControlBootstrapRequest( + environment: ApiConfig.featureControlEnvironment, + keys: featureKeys(), + context: .init( + subjectType: context.subjectType, + subjectId: context.subjectId, + storeId: context.storeId, + platform: platformName(), + appVersion: appVersion(), + attributes: context.attributes + ) + ) + + do { + let response = try await performBootstrapRequest(body: requestBody, jwt: jwt) + let snapshot = FeatureFlagsState( + configVersion: response.configVersion, + evaluatedAt: response.evaluatedAt, + source: response.source ?? "live", + values: response.flags, + raw: response.raw + ) + saveToCache(snapshot: snapshot, storageKey: key) + return snapshot + } catch { + if let cached = loadFromCache(storageKey: key) { + return FeatureFlagsState( + configVersion: cached.configVersion, + evaluatedAt: cached.evaluatedAt, + source: "cache_fallback", + values: cached.values, + raw: cached.raw + ) + } + return FeatureFlagsState(source: "defaults") + } + } + + func sendExposureEvents(snapshot: FeatureFlagsState, context: FeatureControlEvaluationContext, jwt: String?) async { + guard snapshot.raw.isEmpty == false else { return } + + let events = snapshot.raw.compactMap { entry -> FeatureControlExposureRequest.Event? in + let key = entry.key + let value = entry.value + guard value.enabled || value.variant.lowercased() != "off" else { return nil } + return .init( + featureKey: key, + variant: value.variant, + subjectType: context.subjectType, + storeId: context.storeId + ) + } + + guard events.isEmpty == false else { return } + + let batched = Array(events.prefix(100)) + let payload = FeatureControlExposureRequest(events: batched) + guard let body = try? encoder.encode(payload) else { return } + + var request = URLRequest(url: ApiConfig.featureControlBffURL.appendingPathComponent("/feature-control/telemetry/exposure")) + request.httpMethod = "POST" + request.httpBody = body + request.timeoutInterval = 3 + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + if let jwt, jwt.isEmpty == false { + request.setValue("Bearer \(jwt)", forHTTPHeaderField: "Authorization") + } + + _ = try? await session.data(for: request) + } + + private func performBootstrapRequest(body: FeatureControlBootstrapRequest, jwt: String?) async throws -> FeatureControlBootstrapResponse { + var request = URLRequest(url: ApiConfig.featureControlBffURL.appendingPathComponent("/feature-control/bootstrap")) + request.httpMethod = "POST" + request.httpBody = try encoder.encode(body) + request.timeoutInterval = 3 + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + if let jwt, jwt.isEmpty == false { + request.setValue("Bearer \(jwt)", forHTTPHeaderField: "Authorization") + } + + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else { + throw NetworkError.invalidResponse + } + + return try decoder.decode(FeatureControlBootstrapResponse.self, from: data) + } + + private func storageKey(for context: FeatureControlEvaluationContext) -> String { + let tokens = [ + ApiConfig.featureControlEnvironment, + context.subjectType, + context.subjectId, + context.storeId ?? "none", + platformName(), + appVersion(), + featureKeys().joined(separator: "|") + ] + let base = tokens.joined(separator: "::") + .lowercased() + .replacingOccurrences(of: " ", with: "_") + return defaultsPrefix + base + } + + private func saveToCache(snapshot: FeatureFlagsState, storageKey: String) { + let expiresAt = Int64((Date().timeIntervalSince1970 + cacheTTL) * 1000) + let entry = FeatureControlCacheEntry(expiresAtUnixMs: expiresAt, snapshot: snapshot) + guard let data = try? encoder.encode(entry) else { return } + userDefaults.set(data, forKey: storageKey) + } + + private func loadFromCache(storageKey: String) -> FeatureFlagsState? { + guard let data = userDefaults.data(forKey: storageKey), + let entry = try? decoder.decode(FeatureControlCacheEntry.self, from: data) else { + return nil + } + + let now = Int64(Date().timeIntervalSince1970 * 1000) + guard entry.expiresAtUnixMs > now else { + userDefaults.removeObject(forKey: storageKey) + return nil + } + + return entry.snapshot + } + + private func featureKeys() -> [String] { + let raw = ProcessInfo.processInfo.environment["FEATURE_CONTROL_KEYS"] ?? "at.city.aguai,at.promo,at.ios.only,at.android.only,at.cupons" + let items = raw + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { $0.isEmpty == false } + return items.isEmpty ? ["at.ios.only"] : items + } + + private func platformName() -> String { + return "ios" + } + + private func appVersion() -> String { + let version = (Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + if let version, version.isEmpty == false { + return version + } + return "0.0.0" + } +} diff --git a/PediFoods/Services/GuestLocationStore.swift b/PediFoods/Services/GuestLocationStore.swift new file mode 100644 index 0000000..212af56 --- /dev/null +++ b/PediFoods/Services/GuestLocationStore.swift @@ -0,0 +1,77 @@ +import Foundation + +/// Local (device-only) state for the pre-login public store locator — +/// see docs/plans/app-migrate-atomenta-calls-to-pedifoods-bff.md. +final class GuestLocationStore: @unchecked Sendable { + static let shared = GuestLocationStore() + + private let serviceName = "com.br.pedifoods.app.guest" + private let deviceIdKey = "device_id" + private let attestationKey = "attestation" + private let attestKeyIdKey = "attest_key_id" + private let stateKey = "selected_state" + private let cityKey = "selected_city" + + /// Stable per-install identifier sent as `deviceId` in the guest handshake. + var deviceId: String { + if let existing = KeychainStore.load(service: serviceName, key: deviceIdKey) { + return existing + } + let generated = UUID().uuidString + KeychainStore.save(generated, service: serviceName, key: deviceIdKey) + return generated + } + + /// Fallback-only placeholder (backend just checks non-empty) for + /// environments that can't run real App Attest — Simulator, or non-iOS. + /// Real devices use DCAppAttestService via GuestSessionService instead. + var attestationPlaceholder: String { + if let existing = KeychainStore.load(service: serviceName, key: attestationKey) { + return existing + } + let generated = UUID().uuidString + KeychainStore.save(generated, service: serviceName, key: attestationKey) + return generated + } + + /// App Attest key ID already registered with the backend for this + /// device, if any. Present -> use it to sign assertions; absent -> this + /// device needs to attest a freshly generated key first. + var appAttestKeyId: String? { + get { KeychainStore.load(service: serviceName, key: attestKeyIdKey) } + set { + if let newValue { + KeychainStore.save(newValue, service: serviceName, key: attestKeyIdKey) + } else { + KeychainStore.delete(service: serviceName, key: attestKeyIdKey) + } + } + } + + var selectedState: String? { + get { KeychainStore.load(service: serviceName, key: stateKey) } + set { + if let newValue { + KeychainStore.save(newValue, service: serviceName, key: stateKey) + } else { + KeychainStore.delete(service: serviceName, key: stateKey) + } + } + } + + var selectedCity: String? { + get { KeychainStore.load(service: serviceName, key: cityKey) } + set { + if let newValue { + KeychainStore.save(newValue, service: serviceName, key: cityKey) + } else { + KeychainStore.delete(service: serviceName, key: cityKey) + } + } + } + + func clearSelectedLocation() { + KeychainStore.delete(service: serviceName, key: stateKey) + KeychainStore.delete(service: serviceName, key: cityKey) + } +} diff --git a/PediFoods/Services/GuestSessionService.swift b/PediFoods/Services/GuestSessionService.swift new file mode 100644 index 0000000..411bace --- /dev/null +++ b/PediFoods/Services/GuestSessionService.swift @@ -0,0 +1,251 @@ +import Foundation +#if os(iOS) +import DeviceCheck +import CryptoKit +#endif + +private struct ChallengeResult: Decodable, Sendable { + let challenge: String +} + +/// Fields differ by handshake step — Optional properties are omitted from +/// the encoded JSON entirely (Codable synthesis uses encodeIfPresent), so +/// this one struct covers both the fresh-attestation and assertion payloads. +private struct GuestSessionAttestPayload: Encodable, Sendable { + let platform: String + let deviceId: String + let challenge: String + let keyId: String? + let attestation: String? + let assertion: String? +} + +private struct GuestSessionPlaceholderPayload: Encodable, Sendable { + let platform: String + let deviceId: String + let attestation: String +} + +/// Issues and caches the short-lived (15min) guest JWT used by the pre-login +/// public store locator. Deliberately separate from ApiService/TokenStore — +/// a guest-session 401 must never be treated as the customer session +/// expiring (see docs/plans/app-migrate-atomenta-calls-to-pedifoods-bff.md). +/// +/// iOS uses real App Attest (2-call protocol: challenge, then +/// attest-a-fresh-key the first time per device or sign-an-assertion with +/// the already-registered key every time after). If the server doesn't +/// recognize a previously-registered key (e.g. it lost its in-memory +/// registration), the assertion call fails and this falls back to +/// re-attesting with a brand new key rather than leaving the guest stuck. +actor GuestSessionService { + static let shared = GuestSessionService() + + private var cachedToken: String? + private var expiresAt: Date? + private let client: ApiClient + private let store: GuestLocationStore + + // Actors are reentrant across `await` points — without this, two + // concurrent callers with no cached token yet (very possible: the + // location picker and Home's guest store load can both need a guest + // session near launch) would each independently run the App Attest + // flow, racing each other's key registration against the server. This + // makes every caller share the one in-flight handshake instead. + private var inFlightRefresh: Task? + + init(client: ApiClient = ApiClient(), store: GuestLocationStore = .shared) { + self.client = client + self.store = store + } + + func validToken() async throws -> String { + if let cachedToken, let expiresAt, expiresAt > Date() { + return cachedToken + } + return try await refreshToken() + } + + /// Forces a fresh handshake, used for the silent retry-on-401 flow. + func invalidateAndRefresh() async throws -> String { + cachedToken = nil + expiresAt = nil + inFlightRefresh = nil + return try await refreshToken() + } + + private func refreshToken() async throws -> String { + if let inFlightRefresh { + return try await inFlightRefresh.value + } + let task = Task { try await performRefresh() } + inFlightRefresh = task + defer { inFlightRefresh = nil } + return try await task.value + } + + private func performRefresh() async throws -> String { +#if os(iOS) + return try await refreshTokenWithAppAttest() +#else + return try await refreshTokenWithPlaceholder() +#endif + } + +#if os(iOS) + private func refreshTokenWithAppAttest() async throws -> String { + print("[GuestSessionService] DCAppAttestService.isSupported = \(DCAppAttestService.shared.isSupported), existingKeyId = \(store.appAttestKeyId ?? "nil")") + guard DCAppAttestService.shared.isSupported else { + // Simulator can never support App Attest (hardware limitation, + // not environment-specific) — server has its own documented + // bypass for this case, gated by an admin toggle server-side. + let challenge = try await fetchChallenge() + return try await handshakeWithSimulatorBypass(challenge: challenge) + } + + if let existingKeyId = store.appAttestKeyId { + do { + let challenge = try await fetchChallenge() + return try await handshakeWithAssertion(keyId: existingKeyId, challenge: challenge) + } catch let error as NetworkError where isKeyRejectedByServer(error) { + // Server explicitly rejected this key (403 + // APP_ATTEST_VERIFICATION_FAILED — e.g. it lost the + // credential registration) — re-attest with a new key. Any + // other error (network blip, timeout, decode issue) must + // NOT wipe a perfectly valid registered key. + store.appAttestKeyId = nil + } catch let error as DCError { + // DeviceCheck itself rejects the key locally (e.g. the app + // was reinstalled and the Secure Enclave key backing this + // keyId no longer exists) - distinct from the server + // rejecting it, but equally unrecoverable without a fresh + // key. Without this, generateAssertion fails the same way + // forever since appAttestKeyId is never cleared. + print("[GuestSessionService] existing key rejected locally, re-attesting with a fresh key: \(error)") + store.appAttestKeyId = nil + } + } + + let challenge = try await fetchChallenge() + return try await handshakeWithFreshAttestation(challenge: challenge) + } + + private func isKeyRejectedByServer(_ error: NetworkError) -> Bool { + if case .httpError(403, _) = error { return true } + return false + } + + private func fetchChallenge() async throws -> String { + let req = ApiRequest( + path: "/api/public/attest/challenge", + method: "GET", + module: .none, + requiresAuth: false, + queryItems: [URLQueryItem(name: "deviceId", value: store.deviceId)], + baseURLOverride: ApiConfig.pediFoodsBFFURL + ) + let envelope: ApiEnvelope = try await client.send(req) + guard envelope.error == false, let result = envelope.result else { + throw NetworkError.invalidResponse + } + return result.challenge + } + + private func handshakeWithFreshAttestation(challenge: String) async throws -> String { + let keyId: String + do { + keyId = try await DCAppAttestService.shared.generateKey() + } catch { + print("[GuestSessionService] generateKey failed: \(error)") + throw error + } + + let clientDataHash = Data(SHA256.hash(data: Data(challenge.utf8))) + let attestationObject: Data + do { + attestationObject = try await DCAppAttestService.shared.attestKey(keyId, clientDataHash: clientDataHash) + } catch { + print("[GuestSessionService] attestKey failed: \(error)") + throw error + } + + let payload = GuestSessionAttestPayload( + platform: "ios", + deviceId: store.deviceId, + challenge: challenge, + keyId: keyId, + attestation: attestationObject.base64EncodedString(), + assertion: nil + ) + let token = try await sendSessionRequest(body: JSONEncoder().encode(payload)) + store.appAttestKeyId = keyId + return token + } + + /// Simulator can never run real App Attest — server accepts this literal + /// bypass value instead, gated by its own admin toggle (403 + /// SIMULATOR_BYPASS_DISABLED if that toggle is off; not a client bug). + private func handshakeWithSimulatorBypass(challenge: String) async throws -> String { + let payload = GuestSessionAttestPayload( + platform: "ios", + deviceId: store.deviceId, + challenge: challenge, + keyId: nil, + attestation: "SIMULATOR_BYPASS", + assertion: nil + ) + return try await sendSessionRequest(body: JSONEncoder().encode(payload)) + } + + private func handshakeWithAssertion(keyId: String, challenge: String) async throws -> String { + let clientDataHash = Data(SHA256.hash(data: Data(challenge.utf8))) + let assertionObject: Data + do { + assertionObject = try await DCAppAttestService.shared.generateAssertion(keyId, clientDataHash: clientDataHash) + } catch { + print("[GuestSessionService] generateAssertion failed: \(error)") + throw error + } + + let payload = GuestSessionAttestPayload( + platform: "ios", + deviceId: store.deviceId, + challenge: challenge, + keyId: nil, + attestation: nil, + assertion: assertionObject.base64EncodedString() + ) + return try await sendSessionRequest(body: JSONEncoder().encode(payload)) + } +#endif + + /// Only reachable on non-iOS builds (this app's macOS target is test-only, + /// never a real distribution target — no documented contract for it, so + /// this stays a best-effort placeholder rather than matching a real spec. + private func refreshTokenWithPlaceholder() async throws -> String { + let payload = GuestSessionPlaceholderPayload( + platform: "ios", + deviceId: store.deviceId, + attestation: store.attestationPlaceholder + ) + return try await sendSessionRequest(body: JSONEncoder().encode(payload)) + } + + private func sendSessionRequest(body: Data) async throws -> String { + let req = ApiRequest( + path: "/api/public/session", + method: "POST", + module: .none, + requiresAuth: false, + body: body, + baseURLOverride: ApiConfig.pediFoodsBFFURL + ) + let envelope: ApiEnvelope = try await client.send(req) + guard envelope.error == false, let result = envelope.result else { + throw NetworkError.invalidResponse + } + cachedToken = result.guestToken + // Refresh a bit early so a request started near expiry doesn't race the server's own clock. + expiresAt = Date().addingTimeInterval(TimeInterval(result.expiresIn) - 30) + return result.guestToken + } +} diff --git a/PediFoods/Services/ImageSourceResolver.swift b/PediFoods/Services/ImageSourceResolver.swift new file mode 100644 index 0000000..54dbdfa --- /dev/null +++ b/PediFoods/Services/ImageSourceResolver.swift @@ -0,0 +1,44 @@ +import Foundation + +enum ImageSourceResolver { + static func resolve(_ raw: String?) -> String? { + guard var normalized = raw?.trimmingCharacters(in: .whitespacesAndNewlines), + normalized.isEmpty == false else { return nil } + + normalized = normalized.replacingOccurrences(of: "\\/", with: "/") + let lower = normalized.lowercased() + + if lower.hasPrefix("http://") || lower.hasPrefix("https://") || lower.hasPrefix("data:image") { + return normalized + } + +// if let base64DataURL = normalizedBase64DataURL(normalized) { +// return base64DataURL +// } + + let base = ApiConfig.baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + let path = normalized.hasPrefix("/") ? normalized : "/\(normalized)" + return "\(base)\(path)" + } + + private static func normalizedBase64DataURL(_ raw: String) -> String? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.isEmpty == false else { return nil } + + let payload: String + if let marker = trimmed.range(of: "base64,", options: [.caseInsensitive]) { + payload = String(trimmed[marker.upperBound...]) + } else { + payload = trimmed + } + + let sanitized = payload + .replacingOccurrences(of: "\n", with: "") + .replacingOccurrences(of: "\r", with: "") + .replacingOccurrences(of: " ", with: "") + + guard sanitized.count >= 64 else { return nil } + guard Data(base64Encoded: sanitized, options: [.ignoreUnknownCharacters]) != nil else { return nil } + return "data:image/png;base64,\(sanitized)" + } +} diff --git a/PediFoods/Services/KeychainStore.swift b/PediFoods/Services/KeychainStore.swift new file mode 100644 index 0000000..fcb48a7 --- /dev/null +++ b/PediFoods/Services/KeychainStore.swift @@ -0,0 +1,60 @@ +import Foundation +#if os(iOS) +import Security +#endif + +/// Small generic Keychain wrapper (iOS) with a UserDefaults fallback on other +/// platforms (macOS test target), namespaced by `service`+`key`. +enum KeychainStore { + static func save(_ value: String, service: String, key: String) { +#if os(iOS) + guard let data = value.data(using: .utf8) else { return } + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key + ] + SecItemDelete(query as CFDictionary) + var attributes = query + attributes[kSecValueData as String] = data + SecItemAdd(attributes as CFDictionary, nil) +#else + UserDefaults.standard.set(value, forKey: "\(service).\(key)") +#endif + } + + static func load(service: String, key: String) -> String? { +#if os(iOS) + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess, + let data = result as? Data, + let value = String(data: data, encoding: .utf8) else { + return nil + } + return value +#else + UserDefaults.standard.string(forKey: "\(service).\(key)") +#endif + } + + static func delete(service: String, key: String) { +#if os(iOS) + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key + ] + SecItemDelete(query as CFDictionary) +#else + UserDefaults.standard.removeObject(forKey: "\(service).\(key)") +#endif + } +} diff --git a/PediFoods/Services/LocationService.swift b/PediFoods/Services/LocationService.swift new file mode 100644 index 0000000..4083d80 --- /dev/null +++ b/PediFoods/Services/LocationService.swift @@ -0,0 +1,193 @@ +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 +final class LocationService: NSObject { + typealias LocationResult = Result<(Double, Double), LocationError> + static let shared = LocationService() + +#if os(iOS) + enum LocationError: Error { + case servicesDisabled + case denied + case unavailable + } +#else + enum LocationError: Error { + case denied + case unavailable + } +#endif + +#if os(iOS) + 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() + } +#endif + + func requestLocation(_ completion: @escaping (LocationResult) -> Void) { +#if os(iOS) + self.completion = completion + handleAuthorizationStatus(manager.authorizationStatus) +#else + let defaults = UserDefaults.standard + if defaults.bool(forKey: "location_permission_denied") { + completion(.failure(.denied)) + return + } + + guard let latRaw = defaults.string(forKey: "last_location_lat"), + let lngRaw = defaults.string(forKey: "last_location_lng"), + let lat = Double(latRaw), + let lng = Double(lngRaw) else { + completion(.failure(.unavailable)) + return + } + completion(.success((lat, lng))) +#endif + } + + func cachedLocation() -> (Double, Double)? { +#if os(iOS) + guard let location = manager.location else { + return nil + } + return (location.coordinate.latitude, location.coordinate.longitude) +#else + let defaults = UserDefaults.standard + guard let latRaw = defaults.string(forKey: "last_location_lat"), + let lngRaw = defaults.string(forKey: "last_location_lng"), + let lat = Double(latRaw), + let lng = Double(lngRaw) else { + return nil + } + return (lat, lng) +#endif + } + + /// Forward-geocodes a street address into coordinates. Used as a fallback + /// when a saved CustomerAddress has no lat/long (e.g. the CEP lookup at + /// creation time didn't return coordinates) — without this, delivery fee + /// validation silently can't distinguish that address from any other. + static func geocodeAddress( + street: String?, + number: String?, + neighborhood: String?, + city: String?, + state: String?, + zip: String? + ) async -> (Double, Double)? { +#if os(iOS) + let parts = [street, number, neighborhood, city, state, zip] + .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { $0.isEmpty == false } + guard parts.isEmpty == false else { return nil } + let fullAddress = parts.joined(separator: ", ") + + return await withCheckedContinuation { continuation in + CLGeocoder().geocodeAddressString(fullAddress) { placemarks, error in + guard error == nil, let coordinate = placemarks?.first?.location?.coordinate else { + continuation.resume(returning: nil) + return + } + continuation.resume(returning: (coordinate.latitude, coordinate.longitude)) + } + } +#else + return nil +#endif + } + + func requestLocationAsync(timeoutSeconds: TimeInterval = 8) async -> (Double, Double)? { + await withCheckedContinuation { continuation in + var hasResumed = false + + func resumeOnce(_ value: (Double, Double)?) { + guard hasResumed == false else { return } + hasResumed = true + continuation.resume(returning: value) + } + + requestLocation { result in + switch result { + case .success(let coordinate): + resumeOnce(coordinate) + case .failure: + resumeOnce(nil) + } + } + + DispatchQueue.main.asyncAfter(deadline: .now() + timeoutSeconds) { + resumeOnce(nil) + } + } + } +} + +#if os(iOS) +extension LocationService: @preconcurrency CLLocationManagerDelegate { + func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + handleAuthorizationStatus(manager.authorizationStatus) + } + + func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + guard let location = locations.first else { return } + completion?(.success((location.coordinate.latitude, location.coordinate.longitude))) + completion = nil + } + + func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + completion?(.failure(.unavailable)) + completion = nil + } + + private func handleAuthorizationStatus(_ status: CLAuthorizationStatus) { + switch status { + case .notDetermined: + manager.requestWhenInUseAuthorization() + case .authorizedAlways, .authorizedWhenInUse: + manager.requestLocation() + case .denied, .restricted: + completion?(.failure(.denied)) + completion = nil + @unknown default: + completion?(.failure(.unavailable)) + completion = nil + } + } +} +#endif diff --git a/PediFoods/Services/OrderRealtimeTracker.swift b/PediFoods/Services/OrderRealtimeTracker.swift new file mode 100644 index 0000000..bfb6bd7 --- /dev/null +++ b/PediFoods/Services/OrderRealtimeTracker.swift @@ -0,0 +1,250 @@ +import Foundation + +@MainActor +final class OrderRealtimeTracker { + // Keep realtime tracking on polling to avoid socket.io handshake failures + // on environments where websocket upgrade is not available. + private let useSocketRealtime = false + private var pollingTask: Task? = nil + private var socketClient: OrderSocketClient? = nil + private var activeOrderId: String? = nil + + var onOrderUpdated: ((PublicOrderResult) -> Void)? + + func start(orderId: String, jwt: String?) { + stop() + activeOrderId = orderId + + pollingTask = Task { @MainActor [weak self] in + guard let self else { return } + await self.runPollingLoop(orderId: orderId) + } + + guard useSocketRealtime, let jwt, jwt.isEmpty == false else { return } + let socket = OrderSocketClient() + socket.onOrderUpdate = { [weak self] update in + guard let self else { return } + guard update.id == orderId else { return } + Task { @MainActor [weak self] in + guard let self else { return } + await self.fetchLatest(orderId: orderId) + } + } + socket.connect(jwt: jwt) + socketClient = socket + } + + func stop() { + pollingTask?.cancel() + pollingTask = nil + socketClient?.disconnect() + socketClient = nil + activeOrderId = nil + } + + private func runPollingLoop(orderId: String) async { + var elapsedSeconds = 0 + + while Task.isCancelled == false { + if activeOrderId != orderId { return } + + let fetched = await fetchLatest(orderId: orderId) + if fetched?.isFinalStatus == true { + return + } + + let delay = pollingDelay(for: elapsedSeconds) + elapsedSeconds += delay + + do { + try await Task.sleep(nanoseconds: UInt64(delay) * 1_000_000_000) + } catch { + return + } + } + } + + private func pollingDelay(for elapsedSeconds: Int) -> Int { + if elapsedSeconds < 60 { return 3 } + if elapsedSeconds < 180 { return 5 } + return 10 + } + + @discardableResult + private func fetchLatest(orderId: String) async -> PublicOrderResult? { + do { + logger.debug("OrderTracking poll request orderId=\(orderId)") + let response = try await ApiService().publicOrder(orderId: orderId) + guard response.error == false, let order = response.result else { + logger.error("OrderTracking poll API error orderId=\(orderId) message=\(response.message ?? "unknown")") + return nil + } + clearPendingCartIfNeeded(for: order) + logger.info("OrderTracking poll success orderId=\(orderId) status=\(order.status ?? "nil") paymentStatus=\(order.paymentStatus ?? "nil")") + onOrderUpdated?(order) + return order + } catch { + logger.error("OrderTracking poll failure orderId=\(orderId) error=\(error.localizedDescription)") + return nil + } + } + + private func clearPendingCartIfNeeded(for order: PublicOrderResult) { + guard let pendingId = SessionStateStore.loadPendingCartOrderId() else { return } + let normalizedPending = pendingId.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if normalizedPending.isEmpty { return } + + let ids = [order.id, order.realId] + .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } + guard ids.contains(normalizedPending) else { return } + + if shouldClearCart(for: order) == false { return } + + SessionStateStore.clearCart() + SessionStateStore.clearPendingCartOrder() + NotificationCenter.default.post(name: .cartDidReset, object: nil) + } + + private func shouldClearCart(for order: PublicOrderResult) -> Bool { + if order.isPaymentConfirmed { + return true + } + + let status = (order.status ?? "").uppercased() + if status.contains("COMPLETED") || status.contains("DELIVERED") || status.contains("RECEIVED") { + return true + } + + return false + } +} + +final class OrderSocketClient: @unchecked Sendable { + var onOrderUpdate: ((OrderRealtimeUpdate) -> Void)? + +#if os(iOS) || os(macOS) + private var task: URLSessionWebSocketTask? = nil + private let session = URLSession(configuration: .default) + private var isConnected = false + private var pendingJWT: String? = nil +#endif + + func connect(jwt: String) { +#if os(iOS) || os(macOS) + disconnect() + guard let url = makeSocketURL() else { return } + + let wsTask = session.webSocketTask(with: url) + wsTask.resume() + task = wsTask + pendingJWT = jwt + receiveLoop() +#else + _ = jwt +#endif + } + + func disconnect() { +#if os(iOS) || os(macOS) + isConnected = false + pendingJWT = nil + task?.cancel(with: .goingAway, reason: nil) + task = nil +#endif + } + +#if os(iOS) || os(macOS) + private func receiveLoop() { + guard let task else { return } + task.receive { [weak self] result in + guard let self else { return } + switch result { + case .failure: + self.disconnect() + case .success(let message): + self.handleMessage(message) + self.receiveLoop() + } + } + } + + private func handleMessage(_ message: URLSessionWebSocketTask.Message) { + let text: String + switch message { + case .string(let value): + text = value + case .data(let data): + text = String(data: data, encoding: .utf8) ?? "" + @unknown default: + return + } + + guard text.isEmpty == false else { return } + + if text == "2" { + task?.send(.string("3")) { _ in } + return + } + + if text.hasPrefix("0"), let jwt = pendingJWT { + let authPacket = "40{\"token\":\"Bearer \(jwt)\"}" + task?.send(.string(authPacket)) { _ in } + pendingJWT = nil + return + } + + if text.hasPrefix("40") { + isConnected = true + return + } + + guard text.hasPrefix("42") else { return } + let eventPayload = String(text.dropFirst(2)) + guard let data = eventPayload.data(using: .utf8) else { return } + + if let rawArray = try? JSONSerialization.jsonObject(with: data) as? [Any], + rawArray.count >= 2, + let eventName = rawArray[0] as? String, + eventName == "order_update" { + let payloadAny = rawArray[1] + guard JSONSerialization.isValidJSONObject(payloadAny), + let payloadData = try? JSONSerialization.data(withJSONObject: payloadAny), + let update = try? JSONDecoder().decode(OrderRealtimeUpdate.self, from: payloadData) else { + return + } + onOrderUpdate?(update) + return + } + + // Compat: alguns servidores podem encapsular o evento como objeto. + if let rawObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let eventName = (rawObject["event"] as? String)?.lowercased(), + eventName == "order_update", + let payloadAny = rawObject["data"], + JSONSerialization.isValidJSONObject(payloadAny), + let payloadData = try? JSONSerialization.data(withJSONObject: payloadAny), + let update = try? JSONDecoder().decode(OrderRealtimeUpdate.self, from: payloadData) { + onOrderUpdate?(update) + } + } + + private func makeSocketURL() -> URL? { + guard var components = URLComponents(url: ApiConfig.baseURL, resolvingAgainstBaseURL: false) else { + return nil + } + + if components.scheme == "https" { + components.scheme = "wss" + } else { + components.scheme = "ws" + } + components.path = "/socket.io/" + components.queryItems = [ + URLQueryItem(name: "EIO", value: "4"), + URLQueryItem(name: "transport", value: "websocket") + ] + + return components.url + } +#endif +} diff --git a/PediFoods/Services/PublicLocationModels.swift b/PediFoods/Services/PublicLocationModels.swift new file mode 100644 index 0000000..f563acb --- /dev/null +++ b/PediFoods/Services/PublicLocationModels.swift @@ -0,0 +1,126 @@ +import Foundation + +// DTOs for the pre-login public store locator (pedifoods.com.br BFF). +// See docs/plans/app-migrate-atomenta-calls-to-pedifoods-bff.md for the +// exact contract these mirror. + +struct GuestSessionResult: Decodable, Sendable { + let guestToken: String + let expiresIn: Int +} + +/// `GET /api/public/locations` returns states+cities in one call, keyed by +/// state UF with an array of city names — e.g. `{"SP": ["Aguaí", "Campinas"]}`. +typealias PublicLocationsResult = [String: [String]] + +struct PublicStoreListItem: Decodable, Sendable, Identifiable { + let id: String + let storeId: String? + let name: String? + let logo: String? + let cover: String? + let category: String? + let isOpen: Bool? + let statusLabel: String? + let nextOpenLabel: String? + let rating: Double? + let totalReviews: Int? + let deliveryTime: String? + let deliveryFee: Double? + let minOrder: Double? + /// Kilometres from the picked city's centroid to the store - always + /// approximate, since the origin is the city rather than the visitor. + /// The server sends `0` for "unavailable" and never null; optional here + /// only so the app keeps decoding responses from before the field + /// shipped. See docs/plans/public-store-distance-consumer.md. + let distance: Double? +} + +struct PublicStoreDetail: Decodable, Sendable { + let id: String + let storeId: String? + let slug: String? + let fantasyName: String? + let razaoSocial: String? + let logo: String? + let cover: String? + let specialty: String? + let phone: String? + let responsiblePhone: String? + let address: String? + let neighborhood: String? + let city: String? + let state: String? + let zipcode: String? + let isOpen: Bool? + let statusLabel: String? + let nextOpenLabel: String? + let averageRate: Double? + let totalReviews: Int? + let deliveryTime: String? + let deliveryFee: Double? + let deliveryPrice: Double? + let minOrder: Double? + let acceptPix: Bool? + /// Same contract as `PublicStoreListItem.distance`, but only populated + /// when the detail request carries `state`/`city`. + let distance: Double? +} + +// Maps the public (anonymous) store-detail projection onto the same models +// StoreDetailView already renders for authenticated users, so the view +// itself doesn't need to know which source the data came from. +extension StoreInfoResult { + init(publicDetail: PublicStoreDetail) { + isOpen = publicDetail.isOpen + statusLabel = publicDetail.statusLabel + fantasyName = publicDetail.fantasyName + phone = publicDetail.phone + whatsapp = nil + logo = publicDetail.logo + cover = publicDetail.cover + deliveryTime = publicDetail.deliveryTime + minOrder = publicDetail.minOrder + address = StoreAddressInfo(publicDetail: publicDetail) + paymentMethods = StorePaymentMethodsInfo(acceptPix: publicDetail.acceptPix) + } +} + +extension StoreAddressInfo { + init(publicDetail: PublicStoreDetail) { + street = publicDetail.address + number = nil + neighborhood = publicDetail.neighborhood + city = publicDetail.city + state = publicDetail.state + zipCode = publicDetail.zipcode + latitude = nil + longitude = nil + } +} + +extension StorePaymentMethodsInfo { + /// The public projection only exposes whether Pix is accepted — every + /// other payment flag is unknown until the user is authenticated and can + /// see it via the real store-info call. + init(acceptPix: Bool?) { + paymentOnDelivery = nil + paymentOnPickup = nil + self.acceptPix = acceptPix + acceptCash = nil + acceptCreditCard = nil + acceptDebitCard = nil + acceptCreditVisa = nil + acceptCreditMaster = nil + acceptCreditElo = nil + acceptCreditAmex = nil + acceptCreditHipercard = nil + acceptDebitVisa = nil + acceptDebitMaster = nil + acceptDebitElo = nil + acceptVoucherAlelo = nil + acceptVoucherSodexo = nil + acceptVoucherTicket = nil + acceptVoucherVR = nil + } +} diff --git a/PediFoods/Services/PublicLocationService.swift b/PediFoods/Services/PublicLocationService.swift new file mode 100644 index 0000000..e5da6c5 --- /dev/null +++ b/PediFoods/Services/PublicLocationService.swift @@ -0,0 +1,128 @@ +import Foundation + +/// Pre-login public store locator — states/cities/stores-by-location, and +/// store detail. Always talks to `pedifoods.com.br` (the BFF), never +/// `atomenta.com.br` directly. See +/// docs/plans/app-migrate-atomenta-calls-to-pedifoods-bff.md. +/// +/// Deliberately does not go through ApiService.sendEnvelope — that treats +/// any session-expired-shaped response as the *customer* session expiring +/// (clears TokenStore, forces logout). A guest-session 401 here means the +/// short-lived guest JWT expired and must be silently refreshed instead. +final class PublicLocationService: @unchecked Sendable { + static let shared = PublicLocationService() + + private let client: ApiClient + private let guestSession: GuestSessionService + + init(client: ApiClient = ApiClient(), guestSession: GuestSessionService = .shared) { + self.client = client + self.guestSession = guestSession + } + + /// States + their cities in one call — keyed by UF, e.g. `{"SP": [...]}`. + func fetchLocations() async throws -> PublicLocationsResult { + try await sendGuestAuthed(path: "/api/public/locations") + } + + func fetchStores(state: String, city: String) async throws -> [PublicStoreListItem] { + let query = [ + URLQueryItem(name: "state", value: state), + URLQueryItem(name: "city", value: city) + ] + return try await sendGuestAuthed(path: "/api/public/stores/by-location", query: query) + } + + /// No guest token — this route is fully public/unauthenticated per the doc. + /// + /// `state`/`city` are optional on the server and are what let it resolve + /// the city centroid the store `distance` is measured from; without them + /// the response carries `distance: 0` (see + /// docs/plans/public-store-distance-consumer.md). Defaults to whatever the + /// visitor picked in "ENTREGAR EM:". + func fetchStoreDetail( + identifier: String, + state: String? = GuestLocationStore.shared.selectedState, + city: String? = GuestLocationStore.shared.selectedCity + ) async throws -> PublicStoreDetail { + let encodedIdentifier = identifier.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? identifier + var query: [URLQueryItem] = [] + if let state, state.isEmpty == false { + query.append(URLQueryItem(name: "state", value: state)) + } + if let city, city.isEmpty == false { + query.append(URLQueryItem(name: "city", value: city)) + } + let req = ApiRequest( + path: "/api/public/store/\(encodedIdentifier)", + method: "GET", + module: .none, + requiresAuth: false, + queryItems: query, + baseURLOverride: ApiConfig.pediFoodsBFFURL + ) + let envelope: ApiEnvelope = try await client.send(req) + guard envelope.error == false, let result = envelope.result else { + throw NetworkError.invalidResponse + } + return result + } + + /// No guest token — same open pattern as fetchStoreDetail. Reuses the + /// existing StoreCatalogCategory/StoreCatalogProduct models directly: + /// the public response is the same category+products shape (with extra + /// computed inventory/stockStatus fields the decoder just ignores). + func fetchStoreProducts(storeId: String) async throws -> [StoreCatalogCategory] { + let encodedStoreId = storeId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? storeId + let req = ApiRequest( + path: "/api/public/store/\(encodedStoreId)/products", + method: "GET", + module: .none, + requiresAuth: false, + baseURLOverride: ApiConfig.pediFoodsBFFURL + ) + let envelope: ApiEnvelope<[StoreCatalogCategory]> = try await client.send(req) + guard envelope.error == false, let result = envelope.result else { + throw NetworkError.invalidResponse + } + return result + } + + private func sendGuestAuthed(path: String, query: [URLQueryItem] = []) async throws -> T { + let token = try await guestSession.validToken() + do { + return try await performGuestRequest(path: path, query: query, token: token) + } catch let error as NetworkError where isGuestSessionExpired(error) { + let refreshed = try await guestSession.invalidateAndRefresh() + return try await performGuestRequest(path: path, query: query, token: refreshed) + } + } + + private func performGuestRequest(path: String, query: [URLQueryItem], token: String) async throws -> T { + let req = ApiRequest( + path: path, + method: "GET", + module: .none, + requiresAuth: false, + queryItems: query, + baseURLOverride: ApiConfig.pediFoodsBFFURL, + customBearerToken: token + ) + let envelope: ApiEnvelope = try await client.send(req) + guard envelope.error == false, let result = envelope.result else { + throw NetworkError.invalidResponse + } + return result + } + + private func isGuestSessionExpired(_ error: NetworkError) -> Bool { + switch error { + case .unauthorized: + return true + case .httpError(let statusCode, _): + return statusCode == 401 + default: + return false + } + } +} diff --git a/PediFoods/Services/PushNotificationCoordinator.swift b/PediFoods/Services/PushNotificationCoordinator.swift new file mode 100644 index 0000000..b319dc1 --- /dev/null +++ b/PediFoods/Services/PushNotificationCoordinator.swift @@ -0,0 +1,220 @@ +import Foundation + +/// Client-side half of docs/api/push-notifications-integration-guide.md. +/// Owns OS permission state, APNs device-token registration, the +/// "enable notifications" action shared by the profile toggle and the +/// order-tracking fallback prompt (§2b), foreground/tap notification +/// handling (§6), and campaign open tracking (§6a). +enum PushAuthorizationState { + case authorized + case denied + case notDetermined +} + +/// Posted when a tapped push resolves to a navigable `DeepLinkDestination` +/// (§6) so `ContentView` can route without this service depending on +/// `AppState`. One name for every destination, present and future — see +/// `DeepLinkDestination`. +extension Notification.Name { + static let pushDeepLinkReceived = Notification.Name("pushDeepLinkReceived") +} + +#if os(iOS) +import UIKit +import UserNotifications + +@MainActor +final class PushNotificationCoordinator: NSObject { + static let shared = PushNotificationCoordinator() + + private var deviceTokenObserver: NSObjectProtocol? + private var didBecomeDelegate = false + + private override init() {} + + /// Call once at app launch. Listens for the device token `PediFoodsAppDelegate` + /// posts after `registerForRemoteNotifications()` resolves, and forwards it + /// to Atomenta (§2). + func startObservingDeviceToken() { + guard deviceTokenObserver == nil else { return } + deviceTokenObserver = NotificationCenter.default.addObserver( + forName: NSNotification.Name("didRegisterForRemoteNotificationsWithDeviceToken"), + object: nil, + queue: .main + ) { notification in + guard let data = notification.userInfo?["deviceToken"] as? Data else { return } + let hexToken = data.map { String(format: "%02x", $0) }.joined() + Task { await PushNotificationCoordinator.shared.sendTokenToBackend(hexToken) } + } + } + + /// Call once at app launch, before the first notification could possibly + /// arrive — makes this the `UNUserNotificationCenterDelegate` so foreground + /// pushes actually display (§6) and taps get routed/tracked (§6, §6a). + func becomeNotificationCenterDelegate() { + guard didBecomeDelegate == false else { return } + didBecomeDelegate = true + UNUserNotificationCenter.current().delegate = self + } + + /// Re-registers silently (no OS prompt) if the user already granted + /// authorization in a previous session — tokens aren't guaranteed stable + /// across launches (§2, §3.3/§4.2 of the guide). Safe to call before login. + func refreshRegistrationIfAuthorized() async { + guard await currentAuthorizationState() == .authorized else { return } + UIApplication.shared.registerForRemoteNotifications() + } + + func currentAuthorizationState() async -> PushAuthorizationState { + let settings = await UNUserNotificationCenter.current().notificationSettings() + switch settings.authorizationStatus { + case .authorized, .provisional, .ephemeral: + return .authorized + case .denied: + return .denied + case .notDetermined: + return .notDetermined + @unknown default: + return .notDetermined + } + } + + /// Shows the OS permission dialog only if it hasn't been answered yet. + /// Always calls `registerForRemoteNotifications()` when authorized — + /// including when authorization was already granted in a past session — + /// so "enable" reliably produces a fresh device token this run instead of + /// relying solely on the once-per-launch refresh. + @discardableResult + private func requestAuthorizationIfNeeded() async -> Bool { + switch await currentAuthorizationState() { + case .authorized: + UIApplication.shared.registerForRemoteNotifications() + return true + case .denied: + return false + case .notDetermined: + let granted = (try? await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound])) ?? false + if granted { + UIApplication.shared.registerForRemoteNotifications() + } + return granted + } + } + + /// Shared body of §2b's two touchpoints: request OS permission (if + /// undetermined), then flip Atomenta's `notificationsEnabled` flag. + /// Returns the server's authoritative post-update profile — callers must + /// reflect `result.notificationsEnabled` from this, not assume `true` + /// just because the request succeeded. + func enableNotifications() async -> CustomerProfile? { + guard await requestAuthorizationIfNeeded() else { return nil } + do { + let response = try await ApiService().updateNotificationsEnabled(true) + return response.error == false ? response.result : nil + } catch { + logger.error("Failed to enable push notifications: \(error.localizedDescription)") + return nil + } + } + + /// See docs/api/push-notifications-integration-guide.md §2a. Best-effort, + /// silent on failure — Campaign `appVersion`/`attributes` targeting just + /// won't match this user until the next successful call. Call right after + /// login and once per app launch (covers an app update since last launch). + func syncCustomerAttributes() async { + guard DefaultTokenStore().jwt != nil else { return } + do { + _ = try await ApiService().updateCustomerAttributes(appVersion: currentAppVersion(), attributes: nil) + } catch { + logger.error("Failed to sync customer attributes: \(error.localizedDescription)") + } + } + + private func currentAppVersion() -> String { + (Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "0.0.0" + } + + private func sendTokenToBackend(_ hexToken: String) async { + guard DefaultTokenStore().jwt != nil else { return } + do { + _ = try await ApiService().registerPushToken(hexToken, deviceId: GuestLocationStore.shared.deviceId, deviceOS: "ios") + } catch { + logger.error("Push token registration failed: \(error.localizedDescription)") + } + } + + /// §6a — fire on tap only, for `type: "campaign"` pushes. Idempotent + /// server-side, so no client-side "already reported" guard needed. + private func reportCampaignOpened(campaignId: String) async { + guard DefaultTokenStore().jwt != nil else { return } + do { + _ = try await ApiService().reportPushCampaignOpened(campaignId: campaignId) + } catch { + logger.error("Failed to report campaign open: \(error.localizedDescription)") + } + } + + /// §6 — routes on the tapped push's `data` payload. Campaign-open + /// reporting (§6a, a side effect, not a navigation target) is decided + /// directly on `type` here; navigation is delegated to + /// `PushDeepLinkParser` and forwarded to `ContentView` via + /// `NotificationCenter` (this service has no `AppState` binding of its + /// own). A push can do both — e.g. a future campaign that also sets + /// `targetScreen` reports its open *and* navigates. + fileprivate func handleTap(userInfo: [AnyHashable: Any]) { + if let type = userInfo["type"] as? String, type == "campaign", + let campaignId = userInfo["campaignId"] as? String { + Task { await reportCampaignOpened(campaignId: campaignId) } + } + + guard let destination = PushDeepLinkParser.parse(userInfo) else { return } + NotificationCenter.default.post(name: .pushDeepLinkReceived, object: nil, userInfo: ["destination": destination]) + } +} + +/// `UNNotification.userInfo` is `[AnyHashable: Any]`, which the compiler +/// can't prove `Sendable` — but it's an immutable payload handed to us +/// once by the OS, so crossing the actor boundary with it is safe in +/// practice. +private struct UncheckedSendableBox: @unchecked Sendable { + let value: Value +} + +extension PushNotificationCoordinator: UNUserNotificationCenterDelegate { + /// Without a delegate, iOS silently drops push notifications while the + /// app is foregrounded — this is what makes them display as a banner too. + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + completionHandler([.banner, .list, .sound, .badge]) + } + + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + let userInfo = UncheckedSendableBox(value: response.notification.request.content.userInfo) + Task { @MainActor in + PushNotificationCoordinator.shared.handleTap(userInfo: userInfo.value) + } + completionHandler() + } +} +#else +@MainActor +final class PushNotificationCoordinator { + static let shared = PushNotificationCoordinator() + private init() {} + + func startObservingDeviceToken() {} + func becomeNotificationCenterDelegate() {} + func refreshRegistrationIfAuthorized() async {} + func currentAuthorizationState() async -> PushAuthorizationState { .denied } + func enableNotifications() async -> CustomerProfile? { nil } + func syncCustomerAttributes() async {} +} +#endif diff --git a/PediFoods/Services/SessionEvents.swift b/PediFoods/Services/SessionEvents.swift new file mode 100644 index 0000000..a25cc72 --- /dev/null +++ b/PediFoods/Services/SessionEvents.swift @@ -0,0 +1,8 @@ +import Foundation + +extension Notification.Name { + static let sessionExpired = Notification.Name("SessionExpiredNotification") + static let cartDidReset = Notification.Name("CartDidResetNotification") + static let orderReviewDidSave = Notification.Name("OrderReviewDidSaveNotification") + static let appDidResume = Notification.Name("AppDidResumeNotification") +} diff --git a/PediFoods/Services/SessionStateStore.swift b/PediFoods/Services/SessionStateStore.swift new file mode 100644 index 0000000..f61d99f --- /dev/null +++ b/PediFoods/Services/SessionStateStore.swift @@ -0,0 +1,456 @@ +import Foundation + +private struct PersistedAddressState: Codable { + let selectedId: String? + let display: String + let latitude: Double? + let longitude: Double? +} + +private struct PersistedCartAddonState: Codable { + let id: String + let name: String + let quantity: Int + let unitPrice: Double +} + +private struct PersistedCartItemState: Codable { + let id: String + let productId: String + let storeId: String + let name: String + let imageURL: String? + let details: String? + let addons: [PersistedCartAddonState] + let quantity: Int + let unitPrice: Double +} + +private struct PersistedCartState: Codable { + let storeId: String? + let storeName: String? + let items: [PersistedCartItemState] + let total: Double +} + +private struct PersistedTrackedOrdersState: Codable { + let orders: [PublicOrderResult] +} + +struct OrderReviewRecord: Codable, Identifiable, Hashable { + var id: String { orderId } + let orderId: String + let storeId: String? + let shortId: String? + let storeName: String? + let storeLogoURL: String? + let createdAt: String? + let submittedAt: String + let rating: Int + let comment: String + let orderPositiveTags: [String]? + let orderImprovementTags: [String]? + let deliverySentiment: String? + let deliveryPositiveTags: [String]? + let deliveryNegativeTags: [String]? + let appNps: Int? + let platform: String? + let editableUntil: String? + let storeReplyUntil: String? + let reviewWindowExpiresAt: String? + let storeReplyMessage: String? + let storeReplyAt: String? +} + +struct OrderReviewDraftState: Codable, Hashable { + var orderId: String + var orderRate: Int + var orderComment: String + var orderPositiveTags: [String] + var orderImprovementTags: [String] + var deliverySentiment: String + var deliveryPositiveTags: [String] + var deliveryNegativeTags: [String] + var appNps: Int + var platform: String +} + +private struct PersistedOrderReviewsState: Codable { + let reviews: [OrderReviewRecord] +} + +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" + private static let cartKeyPrefix = "session.cart.state.v1." + private static let trackedOrdersKeyPrefix = "session.orders.tracking.v1." + private static let pendingCartOrderKeyPrefix = "session.cart.pending-order.v1." + private static let orderReviewsKeyPrefix = "session.orders.reviews.v1." + private static let orderReviewDraftKeyPrefix = "session.orders.review-draft.v1." + private static let pushOptInPromptKey = "session.push.opt-in.last-prompted.v1" + private static let pushOptInCooldown: TimeInterval = 60 * 60 * 24 + + /// See docs/api/push-notifications-integration-guide.md §2b — avoid + /// re-prompting the order-tracking fallback alert on every screen visit. + static func shouldPromptPushOptIn() -> Bool { + guard let last = defaults.object(forKey: pushOptInPromptKey) as? Date else { return true } + return Date().timeIntervalSince(last) > pushOptInCooldown + } + + static func recordPushOptInPrompted() { + defaults.set(Date(), forKey: pushOptInPromptKey) + } + + static func makeUserKey(profileId: String?, email: String?) -> String? { + let id = (profileId ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + if id.isEmpty == false { + return "id:\(id)" + } + + let mail = (email ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + if mail.isEmpty == false { + return "email:\(mail)" + } + + return nil + } + + static func setActiveUserKey(_ userKey: String?) { + let trimmed = (userKey ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + defaults.removeObject(forKey: activeUserKey) + } else { + defaults.set(trimmed, forKey: activeUserKey) + } + } + + static func loadActiveUserKey() -> String? { + let value = defaults.string(forKey: activeUserKey)? + .trimmingCharacters(in: .whitespacesAndNewlines) + if let value, value.isEmpty == false { + return value + } + return nil + } + + private static func addressStorageKey(for userKey: String?) -> String { + let key = (userKey ?? loadActiveUserKey() ?? "anonymous") + .trimmingCharacters(in: .whitespacesAndNewlines) + let safe = key.replacingOccurrences(of: " ", with: "_") + return addressKeyPrefix + safe + } + + static func loadAddress() -> AddressState? { + let activeKey = loadActiveUserKey() + let scopedKey = addressStorageKey(for: activeKey) + + if let data = defaults.data(forKey: scopedKey), + let decoded = try? JSONDecoder().decode(PersistedAddressState.self, from: data) { + return AddressState( + selectedId: decoded.selectedId, + display: decoded.display.isEmpty ? "Defina seu endereco" : decoded.display, + latitude: decoded.latitude, + longitude: decoded.longitude, + onboardingMessage: nil + ) + } + + // Backward-compatible fallback for data persisted before user scoping. + let anonymousKey = addressStorageKey(for: "anonymous") + if let data = defaults.data(forKey: anonymousKey), + let decoded = try? JSONDecoder().decode(PersistedAddressState.self, from: data) { + let recovered = AddressState( + selectedId: decoded.selectedId, + display: decoded.display.isEmpty ? "Defina seu endereco" : decoded.display, + latitude: decoded.latitude, + longitude: decoded.longitude, + onboardingMessage: nil + ) + + // Migrate anonymous cache into the current active user namespace. + if let activeKey, activeKey.isEmpty == false { + let payload = PersistedAddressState( + selectedId: recovered.selectedId, + display: recovered.display, + latitude: recovered.latitude, + longitude: recovered.longitude + ) + if let migratedData = try? JSONEncoder().encode(payload) { + defaults.set(migratedData, forKey: scopedKey) + } + } + + return recovered + } + + guard let data = defaults.data(forKey: legacyAddressKey), + let decoded = try? JSONDecoder().decode(PersistedAddressState.self, from: data) else { + return nil + } + + let migrated = AddressState( + selectedId: decoded.selectedId, + display: decoded.display.isEmpty ? "Defina seu endereco" : decoded.display, + latitude: decoded.latitude, + longitude: decoded.longitude, + onboardingMessage: nil + ) + saveAddress(migrated) + defaults.removeObject(forKey: legacyAddressKey) + return migrated + } + + static func saveAddress(_ state: AddressState) { + let payload = PersistedAddressState( + selectedId: state.selectedId, + display: state.display, + latitude: state.latitude, + longitude: state.longitude + ) + guard let data = try? JSONEncoder().encode(payload) else { return } + defaults.set(data, forKey: addressStorageKey(for: nil)) + } + + static func clearAddress() { + defaults.removeObject(forKey: addressStorageKey(for: nil)) + defaults.removeObject(forKey: legacyAddressKey) + } + + static func clearActiveUser() { + defaults.removeObject(forKey: activeUserKey) + } + + private static func cartStorageKey(for userKey: String?) -> String { + let key = (userKey ?? loadActiveUserKey() ?? "anonymous") + .trimmingCharacters(in: .whitespacesAndNewlines) + let safe = key.replacingOccurrences(of: " ", with: "_") + return cartKeyPrefix + safe + } + + static func loadCart() -> CartState? { + let key = cartStorageKey(for: nil) + + if let data = defaults.data(forKey: key), + let decoded = try? JSONDecoder().decode(PersistedCartState.self, from: data) { + return CartState( + storeId: decoded.storeId, + storeName: decoded.storeName, + items: decoded.items.map { + CartItemState( + id: $0.id, + productId: $0.productId, + storeId: $0.storeId, + name: $0.name, + imageURL: $0.imageURL, + details: $0.details, + addons: $0.addons.map { + CartItemAddonState( + id: $0.id, + name: $0.name, + quantity: $0.quantity, + unitPrice: $0.unitPrice + ) + }, + quantity: $0.quantity, + unitPrice: $0.unitPrice + ) + }, + total: decoded.total + ) + } + + return nil + } + + static func saveCart(_ state: CartState) { + let payload = PersistedCartState( + storeId: state.storeId, + storeName: state.storeName, + items: state.items.map { + PersistedCartItemState( + id: $0.id, + productId: $0.productId, + storeId: $0.storeId, + name: $0.name, + imageURL: $0.imageURL, + details: $0.details, + addons: $0.addons.map { + PersistedCartAddonState( + id: $0.id, + name: $0.name, + quantity: $0.quantity, + unitPrice: $0.unitPrice + ) + }, + quantity: $0.quantity, + unitPrice: $0.unitPrice + ) + }, + total: state.total + ) + guard let data = try? JSONEncoder().encode(payload) else { return } + defaults.set(data, forKey: cartStorageKey(for: nil)) + } + + static func clearCart() { + defaults.removeObject(forKey: cartStorageKey(for: nil)) + } + + private static func trackedOrdersStorageKey(for userKey: String?) -> String { + let key = (userKey ?? loadActiveUserKey() ?? "anonymous") + .trimmingCharacters(in: .whitespacesAndNewlines) + let safe = key.replacingOccurrences(of: " ", with: "_") + return trackedOrdersKeyPrefix + safe + } + + private static func pendingCartOrderStorageKey(for userKey: String?) -> String { + let key = (userKey ?? loadActiveUserKey() ?? "anonymous") + .trimmingCharacters(in: .whitespacesAndNewlines) + let safe = key.replacingOccurrences(of: " ", with: "_") + return pendingCartOrderKeyPrefix + safe + } + + private static func orderReviewsStorageKey(for userKey: String?) -> String { + let key = (userKey ?? loadActiveUserKey() ?? "anonymous") + .trimmingCharacters(in: .whitespacesAndNewlines) + let safe = key.replacingOccurrences(of: " ", with: "_") + return orderReviewsKeyPrefix + safe + } + + private static func orderReviewDraftStorageKey(for orderId: String, userKey: String?) -> String { + let scope = (userKey ?? loadActiveUserKey() ?? "anonymous") + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: " ", with: "_") + let id = orderId + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + return "\(orderReviewDraftKeyPrefix)\(scope).\(id)" + } + + static func loadTrackedOrders() -> [PublicOrderResult] { + let key = trackedOrdersStorageKey(for: nil) + guard let data = defaults.data(forKey: key), + let decoded = try? JSONDecoder().decode(PersistedTrackedOrdersState.self, from: data) else { + return [] + } + return decoded.orders + } + + static func loadTrackedOrder(orderId: String) -> PublicOrderResult? { + loadTrackedOrders().first(where: { $0.id == orderId }) + } + + static func saveTrackedOrder(_ order: PublicOrderResult) { + var orders = loadTrackedOrders() + if let index = orders.firstIndex(where: { $0.id == order.id }) { + orders[index] = order + } else { + orders.insert(order, at: 0) + } + if orders.count > 60 { + orders = Array(orders.prefix(60)) + } + let payload = PersistedTrackedOrdersState(orders: orders) + guard let data = try? JSONEncoder().encode(payload) else { return } + defaults.set(data, forKey: trackedOrdersStorageKey(for: nil)) + } + + static func clearTrackedOrders() { + defaults.removeObject(forKey: trackedOrdersStorageKey(for: nil)) + } + + static func savePendingCartOrderId(_ orderId: String) { + let clean = orderId.trimmingCharacters(in: .whitespacesAndNewlines) + guard clean.isEmpty == false else { + clearPendingCartOrder() + return + } + defaults.set(clean, forKey: pendingCartOrderStorageKey(for: nil)) + } + + static func loadPendingCartOrderId() -> String? { + let value = defaults.string(forKey: pendingCartOrderStorageKey(for: nil))? + .trimmingCharacters(in: .whitespacesAndNewlines) + if let value, value.isEmpty == false { + return value + } + return nil + } + + static func clearPendingCartOrder() { + defaults.removeObject(forKey: pendingCartOrderStorageKey(for: nil)) + } + + static func loadOrderReviews() -> [OrderReviewRecord] { + let key = orderReviewsStorageKey(for: nil) + guard let data = defaults.data(forKey: key), + let decoded = try? JSONDecoder().decode(PersistedOrderReviewsState.self, from: data) else { + return [] + } + + return decoded.reviews.sorted { lhs, rhs in + lhs.submittedAt > rhs.submittedAt + } + } + + static func loadOrderReview(orderId: String) -> OrderReviewRecord? { + let normalized = orderId.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard normalized.isEmpty == false else { return nil } + return loadOrderReviews().first { review in + review.orderId.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == normalized + } + } + + static func hasOrderReview(orderId: String) -> Bool { + loadOrderReview(orderId: orderId) != nil + } + + static func saveOrderReview(_ review: OrderReviewRecord) { + let cleanId = review.orderId.trimmingCharacters(in: .whitespacesAndNewlines) + guard cleanId.isEmpty == false else { return } + + var reviews = loadOrderReviews() + if let index = reviews.firstIndex(where: { + $0.orderId.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == cleanId.lowercased() + }) { + reviews[index] = review + } else { + reviews.insert(review, at: 0) + } + + let payload = PersistedOrderReviewsState(reviews: reviews) + guard let data = try? JSONEncoder().encode(payload) else { return } + defaults.set(data, forKey: orderReviewsStorageKey(for: nil)) + } + + static func loadOrderReviewDraft(orderId: String) -> OrderReviewDraftState? { + let key = orderReviewDraftStorageKey(for: orderId, userKey: nil) + guard let data = defaults.data(forKey: key), + let draft = try? JSONDecoder().decode(OrderReviewDraftState.self, from: data) else { + return nil + } + return draft + } + + static func saveOrderReviewDraft(_ draft: OrderReviewDraftState) { + let key = orderReviewDraftStorageKey(for: draft.orderId, userKey: nil) + guard let data = try? JSONEncoder().encode(draft) else { return } + defaults.set(data, forKey: key) + } + + static func clearOrderReviewDraft(orderId: String) { + let key = orderReviewDraftStorageKey(for: orderId, userKey: nil) + defaults.removeObject(forKey: key) + } +} diff --git a/PediFoods/Services/StoreCatalogNormalizer.swift b/PediFoods/Services/StoreCatalogNormalizer.swift new file mode 100644 index 0000000..563ee6e --- /dev/null +++ b/PediFoods/Services/StoreCatalogNormalizer.swift @@ -0,0 +1,280 @@ +import Foundation + +enum StoreCatalogNormalizer { + static func sanitize(categories: [StoreCatalogCategory], storeId: String) -> [StoreCatalogCategory] { + var seenCategoryIds: Set = [] + + return categories.enumerated().map { categoryIndex, category in + let categoryId = makeUniqueId( + rawValue: category.id, + fallback: "\(storeId)-category-\(categoryIndex)", + seenIds: &seenCategoryIds + ) + + let normalizedPizzaConfig = sanitize( + pizzaConfig: category.pizzaConfig, + categoryId: categoryId + ) + + var seenProductIds: Set = [] + let normalizedProducts = category.products.enumerated().map { productIndex, product in + sanitize( + product: product, + categoryId: categoryId, + productIndex: productIndex, + seenProductIds: &seenProductIds + ) + } + + return StoreCatalogCategory( + id: categoryId, + name: category.name, + isPizzaCategory: category.isPizzaCategory, + pizzaConfig: normalizedPizzaConfig, + products: normalizedProducts + ) + } + } + + static func preferredCategoryId( + from categories: [StoreCatalogCategory], + preferredId: String? + ) -> String? { + guard let preferredId, preferredId.isEmpty == false else { + return categories.first?.id + } + + if categories.contains(where: { $0.id == preferredId }) { + return preferredId + } + + return categories.first?.id + } + + private static func sanitize( + pizzaConfig: StorePizzaConfig?, + categoryId: String + ) -> StorePizzaConfig? { + guard let pizzaConfig else { return nil } + + var seenSizeIds: Set = [] + let normalizedSizes = pizzaConfig.sizes.enumerated().map { index, size in + StorePizzaSize( + id: makeUniqueId( + rawValue: size.id, + fallback: "\(categoryId)-size-\(index)", + seenIds: &seenSizeIds + ), + name: size.name, + slices: size.slices, + maxFlavors: size.maxFlavors + ) + } + + var seenDoughIds: Set = [] + let normalizedDoughs = pizzaConfig.doughs.enumerated().map { index, dough in + StorePizzaDough( + id: makeUniqueId( + rawValue: dough.id, + fallback: "\(categoryId)-dough-\(index)", + seenIds: &seenDoughIds + ), + name: dough.name, + active: dough.active + ) + } + + var seenCrustIds: Set = [] + let normalizedCrusts = pizzaConfig.crusts.enumerated().map { index, crust in + StorePizzaCrust( + id: makeUniqueId( + rawValue: crust.id, + fallback: "\(categoryId)-crust-\(index)", + seenIds: &seenCrustIds + ), + name: crust.name, + active: crust.active, + priceModifier: crust.priceModifier + ) + } + + return StorePizzaConfig( + sizes: normalizedSizes, + doughs: normalizedDoughs, + crusts: normalizedCrusts + ) + } + + private static func sanitize( + product: StoreCatalogProduct, + categoryId: String, + productIndex: Int, + seenProductIds: inout Set + ) -> StoreCatalogProduct { + let productId = makeUniqueId( + rawValue: product.id, + fallback: "\(categoryId)-product-\(productIndex)", + seenIds: &seenProductIds + ) + + var seenGroupIds: Set = [] + var seenAddonItemIds: Set = [] + let normalizedAddonGroups = product.addonGroups.enumerated().map { groupIndex, group in + let groupId = makeUniqueId( + rawValue: group.id, + fallback: "\(productId)-group-\(groupIndex)", + seenIds: &seenGroupIds + ) + + let normalizedItems = group.items.enumerated().map { itemIndex, item in + StoreAddonItem( + id: makeUniqueId( + rawValue: item.id, + fallback: "\(groupId)-item-\(itemIndex)", + seenIds: &seenAddonItemIds + ), + name: item.name, + price: item.price + ) + } + + return StoreAddonGroup( + id: groupId, + name: group.name, + minSelectors: group.minSelectors, + maxSelectors: group.maxSelectors, + items: normalizedItems + ) + } + + return StoreCatalogProduct( + id: productId, + type: product.type, + name: product.name, + description: product.description, + image: product.image, + price: product.price, + originalPrice: product.originalPrice, + pizzaPrices: product.pizzaPrices, + addonGroups: normalizedAddonGroups + ) + } + + private static func makeUniqueId( + rawValue: String, + fallback: String, + seenIds: inout Set + ) -> String { + let trimmedRawValue = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + let baseId = trimmedRawValue.isEmpty ? fallback : trimmedRawValue + + if seenIds.contains(baseId) == false { + seenIds.insert(baseId) + return baseId + } + + var suffix = 1 + while true { + let candidate = "\(baseId)-\(suffix)" + if seenIds.contains(candidate) == false { + seenIds.insert(candidate) + return candidate + } + suffix += 1 + } + } +} + +extension StoreCatalogCategory { + init( + id: String, + name: String, + isPizzaCategory: Bool, + pizzaConfig: StorePizzaConfig?, + products: [StoreCatalogProduct] + ) { + self.id = id + self.name = name + self.isPizzaCategory = isPizzaCategory + self.pizzaConfig = pizzaConfig + self.products = products + } +} + +extension StoreCatalogProduct { + init( + id: String, + type: String?, + name: String, + description: String?, + image: String?, + price: Double?, + originalPrice: Double?, + pizzaPrices: [String: Double], + addonGroups: [StoreAddonGroup] + ) { + self.id = id + self.type = type + self.name = name + self.description = description + self.image = image + self.price = price + self.originalPrice = originalPrice + self.pizzaPrices = pizzaPrices + self.addonGroups = addonGroups + } +} + +extension StoreAddonGroup { + init( + id: String, + name: String, + minSelectors: Int?, + maxSelectors: Int?, + items: [StoreAddonItem] + ) { + self.id = id + self.name = name + self.minSelectors = minSelectors + self.maxSelectors = maxSelectors + self.items = items + } +} + +extension StoreAddonItem { + init( + id: String, + name: String, + price: Double? + ) { + self.id = id + self.name = name + self.price = price + } +} + +extension StorePizzaConfig { + init( + sizes: [StorePizzaSize], + doughs: [StorePizzaDough], + crusts: [StorePizzaCrust] + ) { + self.sizes = sizes + self.doughs = doughs + self.crusts = crusts + } +} + +extension StorePizzaCrust { + init( + id: String, + name: String?, + active: Bool?, + priceModifier: Double? + ) { + self.id = id + self.name = name + self.active = active + self.priceModifier = priceModifier + } +} diff --git a/PediFoods/Services/TokenStore.swift b/PediFoods/Services/TokenStore.swift new file mode 100644 index 0000000..a0155c8 --- /dev/null +++ b/PediFoods/Services/TokenStore.swift @@ -0,0 +1,40 @@ +import Foundation + +protocol TokenStore: AnyObject { + var jwt: String? { get set } + func clear() +} + +final class DefaultTokenStore: TokenStore { + private let key = "auth_jwt" + private let serviceName = "com.br.pedifoods.app.auth" + private let defaults = UserDefaults.standard + + var jwt: String? { + get { +#if os(iOS) + if let keychainValue = KeychainStore.load(service: serviceName, key: key) { + return keychainValue + } +#endif + return defaults.string(forKey: key) + } + set { +#if os(iOS) + if let newValue { + KeychainStore.save(newValue, service: serviceName, key: key) + } else { + KeychainStore.delete(service: serviceName, key: key) + } +#endif + defaults.set(newValue, forKey: key) + } + } + + func clear() { +#if os(iOS) + KeychainStore.delete(service: serviceName, key: key) +#endif + defaults.removeObject(forKey: key) + } +} diff --git a/PediFoods/State/AppState.swift b/PediFoods/State/AppState.swift new file mode 100644 index 0000000..1cd767b --- /dev/null +++ b/PediFoods/State/AppState.swift @@ -0,0 +1,291 @@ +import Foundation + +struct AppState { + var session = SessionState() + var profile = ProfileState() + var cart = CartState() + var address = AddressState() + var favorites = FavoritesState() + var featureFlags = FeatureFlagsState() + var homeFilters = HomeFiltersState() + var activeModal: AppModal? = nil + var shouldNavigateToOrders: Bool = false + /// Set when a push tap (§6 of the push notifications guide) targets a + /// specific order — consumed once by `OrdersView`, which routes to it via + /// `OrderEntryDestinationView` and clears it. + var pendingOrderDeepLink: OrderRouteContext? = nil +} + +enum FeatureFlagValue: Codable, Equatable { + case boolean(Bool) + case text(String) + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let boolValue = try? container.decode(Bool.self) { + self = .boolean(boolValue) + return + } + if let stringValue = try? container.decode(String.self) { + self = .text(stringValue) + return + } + if let intValue = try? container.decode(Int.self) { + self = .text(String(intValue)) + return + } + if let doubleValue = try? container.decode(Double.self) { + self = .text(String(doubleValue)) + return + } + self = .text("off") + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .boolean(let value): + try container.encode(value) + case .text(let value): + try container.encode(value) + } + } + + var boolValue: Bool { + switch self { + case .boolean(let value): + return value + case .text(let value): + return value.lowercased() == "on" || value.lowercased() == "true" + } + } +} + +struct FeatureFlagsState: Codable, Equatable { + var configVersion: Int = 0 + var evaluatedAt: String? = nil + var source: String = "default" + var values: [String: FeatureFlagValue] = [:] + var raw: [String: FeatureControlRawFlag] = [:] + + func isEnabled(_ key: String, default defaultValue: Bool = false) -> Bool { + if let rawValue = raw[key] { + return rawValue.enabled || rawValue.variant.lowercased() == "on" + } + if let mapped = values[key] { + return mapped.boolValue + } + return defaultValue + } +} + +enum AppModal: String, Identifiable { + case addressPicker + case filters + + var id: String { rawValue } +} + +struct SessionState { + var isAuthenticated: Bool = false + var jwt: String? = nil +} + +struct ProfileState { + var id: String? = nil + var name: String = "" + var email: String = "" + var phone: String = "" + var profilePicture: String = "" + var cpf: String = "" + var notificationsEnabled: Bool = false + var faceIdEnabled: Bool = false +} + +struct AddressState { + var selectedId: String? = nil + var display: String = "Defina seu endereco" + var latitude: Double? = nil + var longitude: Double? = nil + var onboardingMessage: String? = nil +} + +struct FavoritesState { + var storeIds: Set = [] +} + +enum HomeSortOption: String, CaseIterable, Identifiable { + case relevance + case rating + case deliveryTime + case price + + var id: String { rawValue } + + var title: String { + switch self { + case .relevance: return "Relevância" + case .rating: return "Avaliação" + case .deliveryTime: return "Tempo de entrega" + case .price: return "Preço" + } + } + + var icon: String { + switch self { + case .relevance: return "checkmark.seal.fill" + case .rating: return "star.fill" + case .deliveryTime: return "clock.fill" + case .price: return "dollarsign" + } + } +} + +enum HomePriceTier: String, CaseIterable, Identifiable { + case low = "$" + case medium = "$$" + case high = "$$$" + case veryHigh = "$$$$" + + var id: String { rawValue } +} + +struct HomeFiltersState { + var sortOption: HomeSortOption = .relevance + var selectedCategories: Set = [] + var selectedPriceTier: HomePriceTier? = nil + var maxDistanceKm: Double = 10 + var availableCategories: [String] = [] + + mutating func reset() { + sortOption = .relevance + selectedCategories = [] + selectedPriceTier = nil + maxDistanceKm = 10 + } +} + +struct CartState { + var storeId: String? = nil + var storeName: String? = nil + var items: [CartItemState] = [] + var total: Double = 0 +} + +struct CartItemState: Identifiable { + let id: String + var productId: String + var storeId: String + var name: String + var imageURL: String? = nil + var details: String? = nil + var choices: [String]? = nil + var addons: [CartItemAddonState] = [] + var quantity: Int + var unitPrice: Double +} + +struct CartItemAddonState: Identifiable, Hashable { + let id: String + var name: String + var quantity: Int + var unitPrice: Double +} + +extension CartState { + var totalItems: Int { + items.reduce(0) { $0 + $1.quantity } + } + + mutating func recalculateTotal() { + total = items.reduce(0) { $0 + (Double($1.quantity) * $1.unitPrice) } + } + + mutating func clear() { + storeId = nil + storeName = nil + items = [] + total = 0 + SessionStateStore.clearCart() + } + + mutating func add(item: CartItemState) { + if let index = items.firstIndex(where: { $0.id == item.id }) { + items[index].quantity += item.quantity + } else { + items.append(item) + } + recalculateTotal() + SessionStateStore.saveCart(self) + } + + mutating func set(item: CartItemState) { + if let index = items.firstIndex(where: { $0.id == item.id }) { + if item.quantity <= 0 { + items.remove(at: index) + } else { + items[index] = item + } + } else if item.quantity > 0 { + items.append(item) + } + + if items.isEmpty { + storeId = nil + storeName = nil + } + recalculateTotal() + if items.isEmpty { + SessionStateStore.clearCart() + } else { + SessionStateStore.saveCart(self) + } + } + + mutating func increment(itemId: String) { + guard let index = items.firstIndex(where: { $0.id == itemId }) else { return } + items[index].quantity += 1 + recalculateTotal() + SessionStateStore.saveCart(self) + } + + mutating func decrement(itemId: String) { + guard let index = items.firstIndex(where: { $0.id == itemId }) else { return } + items[index].quantity -= 1 + if items[index].quantity <= 0 { + items.remove(at: index) + } + if items.isEmpty { + storeId = nil + storeName = nil + } + recalculateTotal() + if items.isEmpty { + SessionStateStore.clearCart() + } else { + SessionStateStore.saveCart(self) + } + } + + func toOrderItemsPayload() -> [CreateOrderItemPayload] { + items.map { item in + CreateOrderItemPayload( + productId: item.productId, + name: item.name, + qty: item.quantity, + price: item.unitPrice, + addons: item.addons + .filter { $0.quantity > 0 } + .map { + CreateOrderAddonPayload( + addonId: $0.id, + name: $0.name, + qty: $0.quantity, + price: $0.unitPrice + ) + }, + choices: item.choices?.isEmpty == false ? item.choices : nil + ) + } + } +} diff --git a/PediFoods/State/SnackbarCenter.swift b/PediFoods/State/SnackbarCenter.swift new file mode 100644 index 0000000..24583ba --- /dev/null +++ b/PediFoods/State/SnackbarCenter.swift @@ -0,0 +1,94 @@ +import Foundation +import SwiftUI + +extension Notification.Name { + static let snackbarDidChange = Notification.Name("snackbarDidChange") +} + +@MainActor +final class SnackbarCenter: ObservableObject { + static let shared = SnackbarCenter() + + @Published var current: SnackbarMessage? + + private var dismissTask: Task? + + func show( + title: String, + style: SnackbarStyle = .info, + icon: String? = nil, + duration: TimeInterval = 3.5, + isPersistent: Bool = false, + action: (() -> Void)? = nil + ) { + dismissTask?.cancel() + dismissTask = nil + current = SnackbarMessage( + title: title, + style: style, + iconSystemName: icon, + duration: duration, + isPersistent: isPersistent, + action: action + ) + + guard isPersistent == false else { return } + + dismissTask = Task { [weak self] in + let nanos = UInt64(max(0.2, duration) * 1_000_000_000) + try? await Task.sleep(nanoseconds: nanos) + guard !Task.isCancelled else { return } + self?.dismiss(animated: true) + } + } + + func handleTap() { + guard current?.isPersistent != true else { return } + let action = current?.action + dismiss(animated: true) + action?() + } + + func dismiss(animated: Bool) { + dismissTask?.cancel() + dismissTask = nil + if animated { + withAnimation(.spring(response: 0.3, dampingFraction: 0.9)) { + current = nil + } + } else { + current = nil + } + } + + func dismissPersistent() { + guard current?.isPersistent == true else { return } + dismiss(animated: true) + } +} + +enum SnackbarStyle: Sendable, Equatable { + case info + case success + case warning + case error + + var backgroundColor: Color { + switch self { + case .info: return Color(hex: "#3B93F7") + case .success: return Color(hex: "#2E7D32") + case .warning: return Color(hex: "#C77700") + case .error: return Color(hex: "#C62828") + } + } +} + +struct SnackbarMessage: Identifiable { + let id = UUID() + let title: String + let style: SnackbarStyle + let iconSystemName: String? + let duration: TimeInterval + let isPersistent: Bool + let action: (() -> Void)? +} diff --git a/PediFoods/Support/PlatformCompat.swift b/PediFoods/Support/PlatformCompat.swift new file mode 100644 index 0000000..beb355f --- /dev/null +++ b/PediFoods/Support/PlatformCompat.swift @@ -0,0 +1,97 @@ +import Foundation +import SwiftUI +import CoreGraphics +import UIKit + +extension UIDevice { + static var appSafeAreaTop: CGFloat { + let scenes = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + let windows = scenes.flatMap { $0.windows } + if let maxTop = windows.map({ $0.safeAreaInsets.top }).max(), maxTop > 0 { + return maxTop + } + if let fallbackMaxTop = UIApplication.shared.windows.map({ $0.safeAreaInsets.top }).max(), fallbackMaxTop > 0 { + return fallbackMaxTop + } + return 0 + } +} + +func appReadClipboardText() -> String? { + UIPasteboard.general.string +} + +func appWriteClipboardText(_ value: String) { + UIPasteboard.general.string = value +} + +extension View { + @ViewBuilder + func appInlineNavigationTitle() -> some View { + self + .navigationBarTitleDisplayMode(.inline) + .modifier(AppRoundedBackButtonModifier()) + } + + @ViewBuilder + func appHiddenNavigationBar() -> some View { + self + .toolbar(.hidden, for: .navigationBar) + .toolbarBackground(.hidden, for: .navigationBar) + } + + @ViewBuilder + func appTopBarTrailingToolbar(@ViewBuilder content: () -> Content) -> some View { + self.toolbar { + ToolbarItem(placement: .topBarTrailing) { + content() + } + } + } + + @ViewBuilder + func appContentShape(_ shape: S) -> some View { + self.contentShape(shape) + } + + @ViewBuilder + func appBottomSafeAreaInset(@ViewBuilder content: () -> Content) -> some View { + self.safeAreaInset(edge: .bottom) { + content() + } + } + + @ViewBuilder + func appLayoutPriority(_ value: Double) -> some View { + self.layoutPriority(value) + } + + @ViewBuilder + func appNamedCoordinateSpace(_ name: String) -> some View { + self.coordinateSpace(name: name) + } +} + +private struct AppRoundedBackButtonModifier: ViewModifier { + @Environment(\.dismiss) private var dismiss + + func body(content: Content) -> some View { + content + .navigationBarBackButtonHidden(true) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button(action: { dismiss() }) { + Image(systemName: "chevron.left") + .font(.system(size: 24, weight: .semibold)) + .foregroundStyle(AppColors.textPrimary) + .frame(width: 52, height: 52) + .background(AppColors.surface) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.06), radius: 8, y: 2) + } + .buttonStyle(.plain) + } + } + } +} diff --git a/PediFoods/Views/Auth/AuthFlowView.swift b/PediFoods/Views/Auth/AuthFlowView.swift new file mode 100644 index 0000000..9edd55f --- /dev/null +++ b/PediFoods/Views/Auth/AuthFlowView.swift @@ -0,0 +1,38 @@ +import SwiftUI + +enum Route: Hashable { + case terms, policy + case registration, loginEmail + case otp(email: String, phoneNumber: String) +} + +struct AuthFlowView: View { + @Binding var root: RootFlow + @Binding var selectedTab: MainTab + let tokenStore: TokenStore + @Binding var appState: AppState + + @State var path: [Route] = [] + + var body: some View { + NavigationStack(path: $path) { + LoginView(root: $root) { route in + path.append(route) + } + .navigationDestination(for: Route.self) { route in + switch route { + case .terms: + TermsOfUseView() + case .policy: + PrivacyPolicyView() + case .registration: + RegistrationView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState, navigate: { route in path.append(route) }) + case .loginEmail: + LoginEmailView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState, navigate: { route in path.append(route) }) + case .otp(let email, let phoneNumber): + OtpView(email: email, phoneNumber: phoneNumber, root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState) + } + } + } + } +} diff --git a/PediFoods/Views/Auth/AuthFormatters.swift b/PediFoods/Views/Auth/AuthFormatters.swift new file mode 100644 index 0000000..728c933 --- /dev/null +++ b/PediFoods/Views/Auth/AuthFormatters.swift @@ -0,0 +1,88 @@ +import Foundation + +func formatPhoneBR(_ input: String) -> String { + let digits = input.filter(\.isNumber) + let limited = String(digits.prefix(11)) + let count = limited.count + guard count > 0 else { return "" } + + if count <= 2 { + return "(\(limited)" + } + + let area = String(limited.prefix(2)) + let remainder = String(limited.dropFirst(2)) + + if count <= 7 { + return "(\(area)) \(remainder)" + } + + let firstPart = String(remainder.prefix(5)) + let secondPart = String(remainder.dropFirst(5)) + return "(\(area)) \(firstPart)-\(secondPart)" +} + +func normalizePhoneNumberForAPI(_ input: String) -> String { + let digitsOnly = input.filter(\.isNumber) + if digitsOnly.count < 10 { + return "" + } + + if digitsOnly.hasPrefix("55") { + return "+\(digitsOnly)" + } + + return "+55\(digitsOnly)" +} + +func userFacingAuthErrorMessage(_ error: Error) -> String { + if let serviceError = error as? ApiServiceError { + switch serviceError { + case .sessionExpired(let message): + if let message, !message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return message + } + return "Sua sessão expirou. Faça login novamente." + } + } + + if let networkError = error as? NetworkError { + switch networkError { + case .unauthorized(let message): + if let message, !message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return message + } + return "Seu acesso expirou. Solicite um novo código para continuar." + case .httpError(let code, let message): + if let message, !message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return message + } + switch code { + case 400: + return "Não foi possível validar seus dados. Revise as informações e tente novamente." + case 401, 403: + return "Seu acesso expirou. Solicite um novo código para continuar." + case 404: + return "Não encontramos seu cadastro com os dados informados." + case 429: + return "Muitas tentativas em sequência. Aguarde um instante e tente novamente." + case 500...599: + return "Nossos servidores estão instáveis no momento. Tente novamente em alguns minutos." + default: + return "Não foi possível concluir a operação agora. Tente novamente." + } + case .rateLimited: + return "Muitas tentativas em sequência. Aguarde um instante e tente novamente." + case .transportError: + return "Não foi possível se conectar ao servidor. Tente novamente." + case .invalidURL, .invalidResponse, .decodeError: + return "Ocorreu uma instabilidade ao processar sua solicitação. Tente novamente." + case .cancelled: + return "Cancelado" + case .timedOut: + return "O servidor demorou demais para responder. Tente novamente." + } + } + + return "Não foi possível concluir a operação. Tente novamente." +} diff --git a/PediFoods/Views/Auth/LegalViews.swift b/PediFoods/Views/Auth/LegalViews.swift new file mode 100644 index 0000000..96ff6f2 --- /dev/null +++ b/PediFoods/Views/Auth/LegalViews.swift @@ -0,0 +1,116 @@ +import SwiftUI +import WebKit +#if canImport(LCEssentials) +import LCEssentials +#endif + +private struct RemotePDFView: View { + let url: URL + @Binding var isLoading: Bool + @Binding var hasError: Bool + + var body: some View { + RemotePDFRepresentable(url: url, isLoading: $isLoading, hasError: $hasError) + } +} + +private struct RemotePDFRepresentable: UIViewRepresentable { + let url: URL + @Binding var isLoading: Bool + @Binding var hasError: Bool + + func makeUIView(context: Context) -> WKWebView { + let webView = WKWebView() + webView.navigationDelegate = context.coordinator + webView.load(URLRequest(url: url)) + return webView + } + + func updateUIView(_ uiView: WKWebView, context: Context) {} + + func makeCoordinator() -> RemotePDFCoordinator { + RemotePDFCoordinator(isLoading: $isLoading, hasError: $hasError) + } +} + +private final class RemotePDFCoordinator: NSObject, WKNavigationDelegate { + @Binding var isLoading: Bool + @Binding var hasError: Bool + + init(isLoading: Binding, hasError: Binding) { + self._isLoading = isLoading + self._hasError = hasError + } + + func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { + isLoading = true + hasError = false + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + isLoading = false + } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + isLoading = false + hasError = true + } + + func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + isLoading = false + hasError = true + } +} + +private struct LegalDocumentScreen: View { + let title: String + let headerTitle: String + let document: LegalDocument + @Environment(\.colorScheme) var colorScheme + @Environment(\.dismiss) var dismiss + @State private var isLoading = true + @State private var hasError = false + + var body: some View { + LCENavigationView { + content + } + .setLeftButton(image: AnyView(AppBackButtonIcon())) { dismiss() } + .setTitle(text: Text(headerTitle).font(AppTypography.heading2).foregroundStyle(AppColors.textPrimary)) + .setNavigationBarBackgroundColor(AppColors.backgroundLight) + .buttonStyle(.plain) + } + + private var content: some View { + ZStack { + RemotePDFView(url: document.url, isLoading: $isLoading, hasError: $hasError) + + if isLoading { + ProgressView() + } + + if hasError { + VStack(spacing: 8) { + Text("Não foi possível carregar \(title.lowercased()).") + .font(AppTypography.body) + .foregroundStyle(AppColors.textPrimary) + .multilineTextAlignment(.center) + } + .padding(24) + } + } + .background((colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea()) + } +} + +struct TermsOfUseView: View { + var body: some View { + LegalDocumentScreen(title: "os Termos de Uso", headerTitle: "Termos de Uso", document: .terms) + } +} + +struct PrivacyPolicyView: View { + var body: some View { + LegalDocumentScreen(title: "a Política de Privacidade", headerTitle: "Privacidade", document: .privacyPolicy) + } +} diff --git a/PediFoods/Views/Auth/LoginEmailView.swift b/PediFoods/Views/Auth/LoginEmailView.swift new file mode 100644 index 0000000..163ef38 --- /dev/null +++ b/PediFoods/Views/Auth/LoginEmailView.swift @@ -0,0 +1,158 @@ +import SwiftUI +#if canImport(LCEssentials) +import LCEssentials +#endif + +struct LoginEmailView: View { + @Binding var root: RootFlow + @Binding var selectedTab: MainTab + let tokenStore: TokenStore + @Binding var appState: AppState + let navigate: (Route) -> Void + @State var email = "" + @State var phone = "" + @State var isLoading = false + @State var errorMessage: String? + @Environment(\.dismiss) var dismiss + + @ViewBuilder private var logoImage: some View { + SwiftUI.Image("pedifoods") + .resizable() + } + + var body: some View { + LCENavigationView { + content + } + .setLeftButton(image: AnyView(AppBackButtonIcon())) { dismiss() } + .setNavigationBarBackgroundColor(AppColors.backgroundLight) + .buttonStyle(.plain) + .preferredColorScheme(.light) + } + + private var content: some View { + ZStack { + AppColors.backgroundLight.ignoresSafeArea() + + VStack(spacing: 0) { + logoImage + .scaledToFit() + .frame(width: 120, height: 120) + + Text("Boas-vindas!") + .font(AppTypography.heading1) + .foregroundStyle(AppColors.textPrimary) + .padding(Edge.Set.top, 8) + .padding(.bottom, 16) + + VStack(spacing: 16) { + VStack(alignment: .leading, spacing: 16) { + LoginField(icon: "envelope", placeholder: "seu@email.com", keyboardType: .emailAddress, text: $email) + + LoginField(icon: "phone", placeholder: "(00) 00000-0000", keyboardType: .numberPad, text: $phone) + .onChange(of: phone) { _, newValue in + let masked = formatPhoneBR(newValue) + if masked != newValue { + phone = masked + } + } + } + } + .padding(.horizontal, 24) + .padding(.bottom, 16) + + PrimaryButton(title: "Receber Código", image: Image(systemName: "arrow.right")) { + requestOtp() + } + .padding(.horizontal, 24) + .tint(AppColors.tertiary) + .disabled(isLoading || email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || normalizePhoneNumberForAPI(phone).isEmpty) + .opacity((isLoading || email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || normalizePhoneNumberForAPI(phone).isEmpty) ? 0.6 : 1.0) + + Text("Enviaremos um código de verificação por SMS \ne E-mail para confirmar seu acesso.") + .font(.caption) + .foregroundStyle(Color.gray) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + .padding([.top, .bottom], 16) + + HStack(spacing: 6) { + Text("Novo por aqui?") + .foregroundStyle(Color.gray) + Text("Crie sua conta") + .foregroundStyle(AppColors.primary) + .onTapGesture { + dismiss() + } + } + .buttonStyle(.plain) + .font(AppTypography.body) + .padding(.top, 8) + + Spacer() + } + } + } + + private func requestOtp() { + let sanitizedEmail = email.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedPhone = normalizePhoneNumberForAPI(phone) + guard !sanitizedEmail.isEmpty, !normalizedPhone.isEmpty else { return } + isLoading = true + errorMessage = nil + Task { + do { + let service = ApiService() + let response = try await service.requestOtp(email: sanitizedEmail, phoneNumber: normalizedPhone) + await MainActor.run { + if response.error { + isLoading = false + let message = response.message ?? "Nao foi possivel enviar o codigo." + errorMessage = message + SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 4.0) + return + } + isLoading = false + SnackbarCenter.shared.show(title: "Codigo enviado com sucesso.", style: .info, icon: "paperplane.fill", duration: 3.0) + appState.profile.email = sanitizedEmail + appState.profile.phone = phone + navigate(.otp(email: sanitizedEmail, phoneNumber: normalizedPhone)) + } + } catch { + await MainActor.run { + isLoading = false + let message = userFacingAuthErrorMessage(error) + errorMessage = message + SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 4.0) + } + } + } + } +} + +struct LoginField: View { + let icon: String + let placeholder: String + let keyboardType: UIKeyboardType + @Binding var text: String + + var body: some View { + HStack(spacing: 12) { + Image(systemName: icon) + .foregroundStyle(Color.gray) + .frame(width: 28) + TextField(text: $text, prompt: Text(placeholder).foregroundColor(Color.gray)) { } + .appNoAutoCap() + .foregroundColor(.black) + .keyboardType(keyboardType) + } + .padding(.horizontal, 16) + .frame(height: 52) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(Color.black.opacity(0.06), lineWidth: 1) + ) + } +} diff --git a/PediFoods/Views/Auth/LoginView.swift b/PediFoods/Views/Auth/LoginView.swift new file mode 100644 index 0000000..6413d9e --- /dev/null +++ b/PediFoods/Views/Auth/LoginView.swift @@ -0,0 +1,81 @@ +import SwiftUI +#if canImport(LCEssentials) +import LCEssentials +#endif + +struct LoginView: View { + @Binding var root: RootFlow + let navigate: (Route) -> Void + + @ViewBuilder private var logoImage: some View { + SwiftUI.Image("pedifoods") + .resizable() + } + + var body: some View { + LCENavigationView { + content + } + .setLeftButton(image: AnyView(AppBackButtonIcon())) { + withAnimation(.easeInOut(duration: 0.3)) { root = .main } + } + .setTitle(text: Text("Acesse sua conta").font(AppTypography.heading2).foregroundStyle(AppColors.textPrimary)) + .setNavigationBarBackgroundColor(AppColors.backgroundLight) + .buttonStyle(.plain) + // The app ships no dark theme; force light so system dark mode + // can't invert the nav bar / title against the fixed DS colors. + .preferredColorScheme(.light) + } + + private var content: some View { + VStack(spacing: 20) { + Spacer() + + logoImage + .scaledToFit() + .frame(height: 160) + + Text("Sua vontade, no seu tempo.\nTudo o que você precisa, em um toque.") + .font(AppTypography.heading25) + .foregroundStyle(AppColors.textPrimary) + .multilineTextAlignment(.center) + .padding(.horizontal, 28) + + Spacer() + + VStack(spacing: 12) { + PrimaryButton(title: "Criar conta", image: Image(systemName: "arrow.right")) { + navigate(.registration) + } + .tint(AppColors.tertiary) + + SecondaryButton(title: "Entrar") { + navigate(.loginEmail) + } + } + .padding(.horizontal, 28) + + legalLinks + .padding(.top, 4) + .padding(.bottom, 24) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(AppColors.backgroundLight.ignoresSafeArea()) + } + + private var legalLinks: some View { + HStack(spacing: 6) { + Button("Termos de Uso") { navigate(.terms) } + .buttonStyle(.plain) + Text("·") + Button("Política de Privacidade") { navigate(.policy) } + .buttonStyle(.plain) + } + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(AppColors.textMuted) + } +} + +#Preview { + LoginView(root: .constant(.auth), navigate: { _ in }) +} diff --git a/PediFoods/Views/Auth/OtpView.swift b/PediFoods/Views/Auth/OtpView.swift new file mode 100644 index 0000000..4816b37 --- /dev/null +++ b/PediFoods/Views/Auth/OtpView.swift @@ -0,0 +1,403 @@ +import SwiftUI +import UIKit +#if canImport(LCEssentials) +import LCEssentials +#endif + +struct OtpView: View { + private let resendDelaySeconds = 45 + + let email: String + let phoneNumber: String + @Binding var root: RootFlow + @Binding var selectedTab: MainTab + let tokenStore: TokenStore + @Binding var appState: AppState + @State var otp = "" + @State var isLoading = false + @State var isResending = false + @State var resendCountdown = 45 + @State var canResend = false + @State var errorMessage: String? + @State var countdownTask: Task? + @FocusState var isOtpFocused: Bool + @Environment(\.dismiss) var dismiss + + var body: some View { + LCENavigationView { + content + } + .setLeftButton(image: AnyView(AppBackButtonIcon())) { dismiss() } + .setNavigationBarBackgroundColor(AppColors.backgroundLight) + .buttonStyle(.plain) + .preferredColorScheme(.light) + } + + private var content: some View { + ScrollView { + VStack(spacing: 0) { + SwiftUI.Image("pedifoods") + .resizable() + .scaledToFit() + .frame(width: 74, height: 74) + .padding(.top, 48) + + Text("Verificação") + .font(AppTypography.heading1) + .foregroundStyle(AppColors.textPrimary) + .padding(.top, 26) + + Text("Insira o código de 8 dígitos enviado") + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + .padding(.top, 14) + + Text(otpDeliveryMessage) + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + .lineLimit(1) + .minimumScaleFactor(0.85) + .padding(.top, 2) + .padding(.horizontal, 24) + + ZStack { + otpGrid + TextField("", text: $otp) + .appOTPKeyboard() + .foregroundStyle(Color.clear) + .tint(Color.clear) + .focused($isOtpFocused) + .frame(maxWidth: CGFloat.greatestFiniteMagnitude, maxHeight: CGFloat.greatestFiniteMagnitude) + .opacity(0.02) + .onChange(of: otp) { _, newValue in + let digits = newValue.filter { $0.isNumber } + let trimmed = String(digits.prefix(8)) + if trimmed != newValue { + otp = trimmed + } + if trimmed.count == 8 && !isLoading { + isOtpFocused = false + validateOtp() + } + } + } + .frame(height: 204) + .onTapGesture { + isOtpFocused = true + autoFillOtpFromClipboardIfAvailable() + } + .onLongPressGesture { + pasteOtpFromClipboard() + } + .padding(.horizontal, 24) + .padding(.top, 26) + + HStack(spacing: 8) { + Text("Não recebeu o código?") + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + Button(resendButtonTitle) { + resendOtp() + } + .buttonStyle(.plain) + .font(AppTypography.heading3) + .foregroundStyle(AppColors.primary) + .disabled(isResending || !canResend) + } + .padding(.top, 22) + + Button("Colar código") { + pasteOtpFromClipboard() + } + .buttonStyle(.plain) + .font(AppTypography.heading3) + .foregroundStyle(AppColors.primary) + .padding(.top, 22) + + PrimaryButton(title: "Verificar e Entrar") { + validateOtp() + } + .padding(.horizontal, 24) + .padding(.top, 28) + .disabled(isLoading || otp.count != 8) + .opacity((isLoading || otp.count != 8) ? 0.6 : 1.0) + + HStack(spacing: 8) { + Image(systemName: "lock.fill") + .font(.caption) + Text("Conexão segura e criptografada") + .font(AppTypography.body) + } + .foregroundStyle(AppColors.textMuted.opacity(0.8)) + .padding(.top, 120) + .padding(.bottom, 18) + } + } + .scrollDismissesKeyboard(.interactively) + .background(AppColors.backgroundLight) + .onAppear { + isOtpFocused = true + startResendCooldown() + } + .onDisappear { + countdownTask?.cancel() + countdownTask = nil + } + } + + private var resendButtonTitle: String { + if !canResend { + return String(format: "Reenviar em 00:%02d", resendCountdown) + } + return "Reenviar código" + } + + private var otpGrid: some View { + VStack(spacing: 16) { + HStack(spacing: 14) { + otpCell(index: 0) + otpCell(index: 1) + otpCell(index: 2) + otpCell(index: 3) + } + HStack(spacing: 14) { + otpCell(index: 4) + otpCell(index: 5) + otpCell(index: 6) + otpCell(index: 7) + } + } + } + + private func otpCell(index: Int) -> some View { + let char = otpCharacter(at: index) + return ZStack { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color.white) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(Color(hex: "#EEF3FA"), lineWidth: 2) + ) + + if let char { + Text(String(char)) + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + } else { + Circle() + .fill(AppColors.textMuted.opacity(0.8)) + .frame(width: 10, height: 10) + } + } + .frame(height: 94) + } + + private func otpCharacter(at index: Int) -> Character? { + guard index < otp.count else { return nil } + return Array(otp)[index] + } + + private func pasteOtpFromClipboard() { + let raw = appReadClipboardText() ?? "" + let digits = raw.filter(\.isNumber) + let trimmed = String(digits.prefix(8)) + if trimmed.isEmpty == false { + otp = trimmed + } + } + + private func autoFillOtpFromClipboardIfAvailable() { + guard otp.isEmpty else { return } + let raw = appReadClipboardText() ?? "" + let digits = raw.filter(\.isNumber) + guard digits.count >= 8 else { return } + otp = String(digits.prefix(8)) + } + + private var otpDeliveryMessage: String { + "para o seu telefone \(maskedPhoneForDisplay) e seu email \(maskedEmailForDisplay)" + } + + private var maskedPhoneForDisplay: String { + let digits = phoneNumber.filter(\.isNumber) + guard digits.isEmpty == false else { return "XXXX" } + + let visibleSuffix = String(digits.suffix(min(4, digits.count))) + return "XXXX\(visibleSuffix)" + } + + private var maskedEmailForDisplay: String { + let trimmed = email.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.isEmpty == false else { return "XXXX" } + + let parts = trimmed.split(separator: "@", omittingEmptySubsequences: false) + guard parts.count == 2 else { return "XXXX" } + + let domain = String(parts[1]) + return "XXXX@\(domain)" + } + + private func validateOtp() { + let code = otp.filter(\.isNumber) + guard code.count == 8 else { return } + isLoading = true + errorMessage = nil + Task { + do { + let service = ApiService() + let response = try await service.validateOtp(email: email, phoneNumber: phoneNumber, otp: code) + await MainActor.run { + if response.error { + isLoading = false + let message = response.message ?? "Codigo invalido." + errorMessage = message + SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 4.0) + return + } + isLoading = false + SnackbarCenter.shared.show(title: "Login realizado com sucesso.", style: .success, icon: "checkmark.seal.fill", duration: 2.5) + appState.session.isAuthenticated = true + appState.session.jwt = response.result?.token + let hasServerAddress = hydrateUserState(from: response.result?.customer) + routeAfterLogin(hasServerAddress: hasServerAddress) + Task { await PushNotificationCoordinator.shared.syncCustomerAttributes() } + } + } catch { + await MainActor.run { + isLoading = false + let message = userFacingAuthErrorMessage(error) + errorMessage = message + SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 4.0) + } + } + } + } + + private func routeAfterLogin(hasServerAddress: Bool) { + if hasServerAddress || hasConfiguredAddress() { + selectedTab = .home + root = .main + return + } + + appState.address.onboardingMessage = "Vamos criar um endereço para poder visualizar os estabelecimentos mais próximos de você?" + selectedTab = .profile + root = .main + } + + private func hydrateUserState(from customer: CustomerProfile?) -> Bool { + if let customer { + appState.profile.id = customer.id + appState.profile.name = customer.name + appState.profile.email = customer.email + appState.profile.phone = customer.phoneNumber ?? "" + appState.profile.profilePicture = customer.profilePicture ?? "" + appState.profile.notificationsEnabled = customer.notificationsEnabled ?? false + appState.profile.faceIdEnabled = customer.faceIdEnabled ?? false + appState.favorites.storeIds = Set(customer.favorites ?? []) + SessionStateStore.setActiveUserKey( + SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email) + ) + if let cachedCart = SessionStateStore.loadCart() { + appState.cart = cachedCart + } else { + appState.cart = CartState() + } + + let addresses = customer.addressBook ?? [] + if let preferred = addresses.first { + appState.address.selectedId = preferred.id + let label = (preferred.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + appState.address.display = label.isEmpty ? "Defina seu endereco" : label + if let lat = preferred.latLong?.first, let lng = preferred.latLong?.dropFirst().first { + appState.address.latitude = lat + appState.address.longitude = lng + } else { + appState.address.latitude = nil + appState.address.longitude = nil + } + SessionStateStore.saveAddress(appState.address) + } else { + appState.address = AddressState() + SessionStateStore.clearAddress() + } + return addresses.isEmpty == false + } + + appState.profile.email = email + appState.favorites = FavoritesState() + SessionStateStore.setActiveUserKey( + SessionStateStore.makeUserKey(profileId: nil, email: email) + ) + if let cachedCart = SessionStateStore.loadCart() { + appState.cart = cachedCart + } else { + appState.cart = CartState() + } + appState.address = AddressState() + return false + } + + private func hasConfiguredAddress() -> Bool { + if appState.address.selectedId != nil { + return true + } + + let normalized = appState.address.display + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + + return normalized.isEmpty == false && normalized != "defina seu endereco" + } + + private func resendOtp() { + guard canResend, !isResending else { return } + isResending = true + startResendCooldown() + errorMessage = nil + Task { + do { + let service = ApiService() + let response = try await service.requestOtp(email: email, phoneNumber: phoneNumber) + await MainActor.run { + isResending = false + if response.error { + canResend = true + resendCountdown = 0 + let message = response.message ?? "Nao foi possivel reenviar o código." + errorMessage = message + SnackbarCenter.shared.show(title: message, style: .warning, icon: "exclamationmark.triangle.fill", duration: 4.0) + } else { + SnackbarCenter.shared.show(title: "Codigo reenviado.", style: .info, icon: "paperplane.fill", duration: 2.5) + } + } + } catch { + await MainActor.run { + isResending = false + canResend = true + resendCountdown = 0 + let message = userFacingAuthErrorMessage(error) + errorMessage = message + SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 4.0) + } + } + } + } + + private func startResendCooldown() { + countdownTask?.cancel() + canResend = false + resendCountdown = resendDelaySeconds + countdownTask = Task { + var remaining = resendDelaySeconds + while !Task.isCancelled && remaining > 0 { + try? await Task.sleep(nanoseconds: 1_000_000_000) + remaining -= 1 + await MainActor.run { + resendCountdown = max(remaining, 0) + canResend = remaining == 0 + } + } + } + } +} diff --git a/PediFoods/Views/Auth/RegistrationView.swift b/PediFoods/Views/Auth/RegistrationView.swift new file mode 100644 index 0000000..cb2441d --- /dev/null +++ b/PediFoods/Views/Auth/RegistrationView.swift @@ -0,0 +1,179 @@ +import SwiftUI +#if canImport(LCEssentials) +import LCEssentials +#endif + +struct RegistrationView: View { + @Binding var root: RootFlow + @Binding var selectedTab: MainTab + let tokenStore: TokenStore + @Binding var appState: AppState + @State var name = "" + @State var email = "" + @State var phone = "" + @State var acceptedTerms = false + @State var isLoading = false + @State var errorMessage: String? + @Environment(\.dismiss) var dismiss + let navigate: (Route) -> Void + + private var isFormValid: Bool { !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !phone.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && acceptedTerms } + + @ViewBuilder private var logoImage: some View { + SwiftUI.Image("pedifoods") + .resizable() + } + + var body: some View { + LCENavigationView { + content + } + .setLeftButton(image: AnyView(AppBackButtonIcon())) { dismiss() } + .setNavigationBarBackgroundColor(AppColors.backgroundLight) + .buttonStyle(.plain) + .preferredColorScheme(.light) + } + + private var content: some View { + ZStack { + AppColors.backgroundLight.ignoresSafeArea() + + ScrollView { + VStack(spacing: 0) { + logoImage + .scaledToFit() + .frame(width: 120, height: 120) + + Text("Crie sua conta") + .font(AppTypography.heading1) + .foregroundStyle(AppColors.textPrimary) + + Text("Preencha os dados abaixo para começar.") + .font(AppTypography.body) + .foregroundStyle(AppColors.textPrimary) + .padding(.top, 8) + .padding(.bottom, 20) + + VStack(spacing: 16) { + VStack(alignment: .leading, spacing: 16) { + LoginField(icon: "person", placeholder: "Ex: Maria Silva", keyboardType: .default, text: $name) + LoginField(icon: "envelope", placeholder: "seu@email.com", keyboardType: .emailAddress, text: $email) + LoginField(icon: "phone", placeholder: "(00) 00000-0000", keyboardType: .namePhonePad, text: $phone) + .onChange(of: phone) { _, newValue in + let masked = formatPhoneBR(newValue) + if masked != newValue { + phone = masked + } + } + } + } + .padding(.horizontal, 24) + + HStack(alignment: .top, spacing: 12) { + Toggle("", isOn: $acceptedTerms) + .labelsHidden() + .tint(AppColors.primary) + + Group { + Text("Ao se cadastrar, você concorda com nossos [Termos de Uso](app://terms) e [Política de Privacidade](app://policy)") + .foregroundStyle(AppColors.textPrimary) + .tint(AppColors.primary) + .environment(\.openURL, OpenURLAction { url in + guard url.scheme == "app" else { return .handled } + + switch url.host { + case "terms": + navigate(.terms) + return .handled + case "policy": + navigate(.policy) + return .handled + default: + return .handled + } + }) + } + .multilineTextAlignment(.leading) + } + .padding(.horizontal, 24) + .padding(.top, 16) + .padding(.bottom, 16) + + PrimaryButton(title: "Cadastrar e Receber Código", image: Image(systemName: "arrow.right")) { + registerAndRequestOtp() + } + .padding(.horizontal, 24) + .padding(.top, 6) + .disabled(!isFormValid || isLoading) + .opacity((!isFormValid || isLoading) ? 0.5 : 1.0) + .tint(AppColors.tertiary) + + HStack(spacing: 6) { + Text("Já tem uma conta?") + .foregroundStyle(AppColors.textPrimary) + NavigationLink("Entrar") { + LoginEmailView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState, navigate: navigate) + } + .foregroundStyle(AppColors.primary) + } + .font(AppTypography.body) + .padding(.top, 16) + + Spacer().frame(height: 12) + } + } + } + } + + private func registerAndRequestOtp() { + let sanitizedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + let sanitizedEmail = email.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedPhone = normalizePhoneNumberForAPI(phone) + guard !sanitizedName.isEmpty, !sanitizedEmail.isEmpty, !normalizedPhone.isEmpty else { return } + + isLoading = true + errorMessage = nil + + Task { + do { + let service = ApiService() + let registration = try await service.registerCustomer( + name: sanitizedName, + email: sanitizedEmail, + phoneNumber: normalizedPhone + ) + if registration.error { + await MainActor.run { + isLoading = false + let message = registration.message ?? "Nao foi possivel concluir o cadastro." + errorMessage = message + SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 4.0) + } + return + } + + let otp = try await service.requestOtp(email: sanitizedEmail, phoneNumber: normalizedPhone) + await MainActor.run { + isLoading = false + if otp.error { + let message = otp.message ?? "Cadastro concluido, mas nao foi possivel enviar o codigo." + errorMessage = message + SnackbarCenter.shared.show(title: message, style: .warning, icon: "exclamationmark.triangle.fill", duration: 4.0) + return + } + SnackbarCenter.shared.show(title: "Cadastro concluido. Codigo enviado.", style: .success, icon: "checkmark.seal.fill", duration: 3.0) + appState.profile.email = sanitizedEmail + appState.profile.phone = phone + navigate(.otp(email: sanitizedEmail, phoneNumber: normalizedPhone)) + } + } catch { + await MainActor.run { + isLoading = false + let message = userFacingAuthErrorMessage(error) + errorMessage = message + SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 4.0) + } + } + } + } +} diff --git a/PediFoods/Views/Launch/LaunchSplashView.swift b/PediFoods/Views/Launch/LaunchSplashView.swift new file mode 100644 index 0000000..e05c592 --- /dev/null +++ b/PediFoods/Views/Launch/LaunchSplashView.swift @@ -0,0 +1,42 @@ +import SwiftUI + +struct LaunchSplashView: View { + var shouldPulse: Bool = true + @Environment(\.colorScheme) var colorScheme + @State var isAnimating = false + + var body: some View { + GeometryReader { geo in + let logoTopInset = max(0, (geo.size.height - 180) / 2 - 8) + + ZStack(alignment: .top) { + (colorScheme == .dark ? Color.black : AppColors.backgroundLight) + .ignoresSafeArea() + + VStack(spacing: 0) { + Spacer().frame(height: logoTopInset) + + splashLogo + .scaledToFit() + .frame(width: 180, height: 180) + .scaleEffect(isAnimating ? 1.03 : 0.97) + .opacity(isAnimating ? 1.0 : 0.9) + .animation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true), value: isAnimating) + + Spacer() + } + } + } + .onAppear { + isAnimating = shouldPulse + } + .onChange(of: shouldPulse) { _, newValue in + isAnimating = newValue + } + } + + @ViewBuilder var splashLogo: some View { + SwiftUI.Image("pedifoods") + .resizable() + } +} diff --git a/PediFoods/Views/Main/AddAddressFormView.swift b/PediFoods/Views/Main/AddAddressFormView.swift new file mode 100644 index 0000000..86133f7 --- /dev/null +++ b/PediFoods/Views/Main/AddAddressFormView.swift @@ -0,0 +1,239 @@ +import SwiftUI +#if canImport(LCEssentials) +import LCEssentials +#endif + +struct AddAddressFormView: View { + @Environment(\.dismiss) var dismiss + @State var label = "" + @State var zipCode = "" + @State var address = "" + @State var number = "" + @State var complement = "" + @State var neighborhood = "" + @State var city = "" + @State var state = "" + @State var isLoading = false + @State var isLookingUpZipCode = false + @State var zipLookupMessage: String? = nil + @State var lastLookedUpZipCode = "" + @State var lookedUpLatitude: Double? = nil + @State var lookedUpLongitude: Double? = nil + + let existingAddress: CustomerAddress? + let onSave: ([CustomerAddress], Bool) -> Void + + private var isFormValid: Bool { + !label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + normalizeZipCodeForAPI(zipCode).count == 8 && + !address.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + !number.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + !neighborhood.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + !city.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + !state.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + var body: some View { + LCENavigationView { + content + } + .setLeftButton(image: AnyView(AppBackButtonIcon())) { dismiss() } + .setTitle(text: Text(existingAddress == nil ? "Novo endereço" : "Editar endereço").font(AppTypography.heading2).foregroundStyle(AppColors.textPrimary)) + .setNavigationBarBackgroundColor(AppColors.backgroundLight) + .buttonStyle(.plain) + } + + private var content: some View { + ZStack { + AppColors.backgroundLight.ignoresSafeArea() + + ScrollView(showsIndicators: false) { + VStack(spacing: 0) { + VStack(alignment: .leading, spacing: 16) { + LoginField(icon: "tag", placeholder: "Ex: Casa, Trabalho", keyboardType: .default, text: $label) + LoginField(icon: "mail", placeholder: "CEP", keyboardType: .numberPad, text: $zipCode) + .onChange(of: zipCode) { _, newValue in + let masked = formatZipCodeBR(newValue) + if masked != newValue { + zipCode = masked + } + + let normalized = normalizeZipCodeForAPI(masked) + if normalized.count == 8, normalized != lastLookedUpZipCode, !isLookingUpZipCode { + Task { + await lookupAddressByZipCode(normalized) + } + } + } + if isLookingUpZipCode { + HStack(spacing: 8) { + ProgressView() + Text("Buscando endereço pelo CEP...") + .font(.caption) + .foregroundStyle(AppColors.textMuted) + } + .padding(.horizontal, 6) + } else if let zipLookupMessage { + Text(zipLookupMessage) + .font(.caption) + .foregroundStyle(AppColors.textMuted) + .padding(.horizontal, 6) + } + LoginField(icon: "mappin.and.ellipse", placeholder: "Rua / Avenida", keyboardType: .default, text: $address) + LoginField(icon: "number", placeholder: "Número", keyboardType: .default, text: $number) + LoginField(icon: "plus.app", placeholder: "Complemento (opcional)", keyboardType: .default, text: $complement) + LoginField(icon: "square.grid.2x2", placeholder: "Bairro", keyboardType: .default, text: $neighborhood) + LoginField(icon: "building.2", placeholder: "Cidade", keyboardType: .default, text: $city) + LoginField(icon: "map", placeholder: "Estado (UF)", keyboardType: .default, text: $state) + .onChange(of: state) { _, newValue in + let normalized = String(newValue.uppercased().prefix(2)) + if normalized != newValue { + state = normalized + } + } + } + .padding(.horizontal, 24) + .padding(.bottom, 20) + + PrimaryButton(title: existingAddress == nil ? "Salvar endereço" : "Atualizar endereço", image: Image(systemName: "checkmark")) { + saveAddress() + } + .padding(.horizontal, 24) + .disabled(!isFormValid || isLoading) + .opacity((!isFormValid || isLoading) ? 0.5 : 1.0) + .tint(AppColors.tertiary) + + SecondaryButton(title: "Cancelar") { + dismiss() + } + .padding(.horizontal, 24) + .padding(.top, 12) + + Spacer().frame(height: 120) + } + } + .padding(.top, 18) + } + .navigationBarBackButtonHidden(true) + .appHiddenNavigationBar() + .onAppear { + populateFromExistingAddressIfNeeded() + } + } + + private func saveAddress() { + guard !isLoading else { return } + let latLong: [Double]? = { + if let lat = lookedUpLatitude, let lng = lookedUpLongitude { + return [lat, lng] + } + return nil + }() + + let newAddress = CustomerAddress( + id: existingAddress?.id ?? UUID().uuidString, + label: clean(label), + address: clean(address), + number: clean(number), + complement: optional(clean(complement)), + neighborhood: clean(neighborhood), + city: clean(city), + state: clean(state), + zipCode: optional(normalizeZipCodeForAPI(zipCode)), + latLong: latLong, + isDefault: existingAddress?.isDefault + ) + isLoading = true + zipLookupMessage = nil + + Task { + do { + let response = try await ApiService().saveCustomerAddress(newAddress, replacingAddressId: existingAddress?.id) + await MainActor.run { + isLoading = false + if response.error { + zipLookupMessage = response.message ?? "Não foi possível salvar o endereço." + SnackbarCenter.shared.show(title: zipLookupMessage ?? "Não foi possível salvar o endereço.", style: .error, icon: "xmark.octagon.fill", duration: 4.0) + return + } + + let updatedAddresses = response.result?.addressBook ?? [newAddress] + onSave(updatedAddresses, existingAddress != nil) + dismiss() + } + } catch { + await MainActor.run { + isLoading = false + let message = error.localizedDescription + zipLookupMessage = message + SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 4.0) + } + } + } + } + + private func populateFromExistingAddressIfNeeded() { + guard let existingAddress else { return } + label = existingAddress.label ?? "" + zipCode = formatZipCodeBR(existingAddress.zipCode ?? "") + lastLookedUpZipCode = normalizeZipCodeForAPI(zipCode) + address = existingAddress.address ?? "" + number = existingAddress.number ?? "" + complement = existingAddress.complement ?? "" + neighborhood = existingAddress.neighborhood ?? "" + city = existingAddress.city ?? "" + state = String((existingAddress.state ?? "").uppercased().prefix(2)) + lookedUpLatitude = existingAddress.latLong?.first + lookedUpLongitude = existingAddress.latLong?.dropFirst().first + } + + private func clean(_ value: String) -> String { + value.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func optional(_ value: String) -> String? { + value.isEmpty ? nil : value + } + + @MainActor + private func lookupAddressByZipCode(_ zip: String) async { + isLookingUpZipCode = true + zipLookupMessage = nil + defer { isLookingUpZipCode = false } + + do { + let response = try await ApiService().lookupZipCode(zip) + lastLookedUpZipCode = zip + + guard response.error == false, let result = response.result else { + zipLookupMessage = response.message ?? "Não foi possível consultar este CEP." + return + } + + fillAddressFields(with: result) + zipLookupMessage = "Endereço preenchido automaticamente." + } catch { + zipLookupMessage = "Não foi possível consultar o CEP agora." + } + } + + private func fillAddressFields(with result: CepLookupResult) { + if address.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + address = result.street?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + } + if neighborhood.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + neighborhood = result.neighborhood?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + } + if city.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + city = result.city?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + } + if state.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + state = String((result.state ?? "").uppercased().prefix(2)) + } + if complement.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + complement = result.complement?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + } + lookedUpLatitude = result.latitude + lookedUpLongitude = result.longitude + } +} diff --git a/PediFoods/Views/Main/AddCardFormView.swift b/PediFoods/Views/Main/AddCardFormView.swift new file mode 100644 index 0000000..7addc9a --- /dev/null +++ b/PediFoods/Views/Main/AddCardFormView.swift @@ -0,0 +1,452 @@ +import SwiftUI +#if canImport(LCEssentials) +import LCEssentials +#endif + +struct AddCardFormView: View { + let appState: AppState + let isFirstCard: Bool + let onCardAdded: (SavedCard) -> Void + + @Environment(\.dismiss) var dismiss + + @State private var cardNumber = "" + @State private var holderName = "" + @State private var expiry = "" + @State private var cvv = "" + @State private var cpf = "" + @State private var nickname = "" + @State private var isDefault = false + @State private var isSaving = false + + @State private var addresses: [CustomerAddress] = [] + @State private var selectedAddress: CustomerAddress? = nil + @State private var isLoadingAddresses = false + @State private var showAddressPicker = false + + private var detectedBrandLogo: String? { + let clean = cardNumber.filter(\.isNumber) + guard clean.isEmpty == false else { return nil } + if clean.hasPrefix("506766") || clean.hasPrefix("603389") { return "sodexo_logo" } + if clean.hasPrefix("5067") || clean.hasPrefix("5078") || clean.hasPrefix("6278") || clean.hasPrefix("6367") { return "alelocard_logo" } + if clean.hasPrefix("3841") || clean.hasPrefix("606282") || clean.hasPrefix("637095") || clean.hasPrefix("637568") { return "hipercard_logo" } + if clean.hasPrefix("34") || clean.hasPrefix("37") { return "amexcard_logo" } + if clean.hasPrefix("4") { return "visacard_logo" } + let prefix2 = Int(clean.prefix(2)) ?? 0 + if (51...59).contains(prefix2) { return "mastercard_logo" } + if let p4 = Int(clean.prefix(4)), (2221...2720).contains(p4) { return "mastercard_logo" } + return nil + } + + private var selectedAddressZip: String { + (selectedAddress?.zipCode ?? "").filter(\.isNumber) + } + + private var canSave: Bool { + let digits = cardNumber.filter(\.isNumber) + let cpfDigits = cpf.filter(\.isNumber) + let parts = expiry.split(separator: "/") + return digits.count >= 13 + && holderName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + && parts.count == 2 + && cvv.count >= 3 + && cpfDigits.count == 11 + && selectedAddress != nil + && selectedAddressZip.count >= 7 + } + + var body: some View { + LCENavigationView { + content + } + .setLeftButton(image: AnyView(AppBackButtonIcon())) { dismiss() } + .setTitle(text: Text("Novo Cartão").font(AppTypography.heading2).foregroundStyle(AppColors.textPrimary)) + .setNavigationBarBackgroundColor(AppColors.backgroundLight) + .buttonStyle(.plain) + } + + private var content: some View { + ScrollView(showsIndicators: false) { + VStack(spacing: 20) { + formSection("Dados do Cartão") { + cardNumberField + labeledField("Nome no cartão", placeholder: "Como impresso no cartão", text: $holderName, autocap: true) + HStack(spacing: 12) { + labeledField("Validade", placeholder: "MM/AA", text: $expiry, keyboard: .numberPad) + .onChange(of: expiry) { _, v in expiry = formatExpiry(v) } + labeledField("CVV", placeholder: "•••", text: $cvv, keyboard: .numberPad) + .onChange(of: cvv) { _, v in cvv = String(v.filter(\.isNumber).prefix(4)) } + } + } + + formSection("Identificação do Titular") { + labeledField("CPF", placeholder: "000.000.000-00", text: $cpf, keyboard: .numberPad) + .onChange(of: cpf) { _, v in cpf = formatCPF(v.filter(\.isNumber)) } + addressPickerRow + } + + formSection("Opções") { + labeledField("Apelido (opcional)", placeholder: "Ex: Cartão do Nubank", text: $nickname) + if isFirstCard == false { + Toggle(isOn: $isDefault) { + Text("Definir como principal") + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + } + .tint(AppColors.primary) + .padding(14) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + } + + Button(action: { Task { await saveCard() } }) { + Group { + if isSaving { + ProgressView().tint(Color(hex: "#0E1A06")) + } else { + Text("Salvar Cartão").font(AppTypography.heading2) + } + } + .foregroundStyle(Color(hex: "#0E1A06")) + .frame(maxWidth: .infinity, minHeight: 56) + .background(canSave && !isSaving ? Color(hex: "#C8F06E") : Color(hex: "#C8F06E").opacity(0.45)) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + .disabled(!canSave || isSaving) + .padding(.top, 4) + } + .padding(.horizontal, 20) + .padding(.top, 18) + .padding(.bottom, 40) + } + .background(AppColors.backgroundLight) + .navigationBarBackButtonHidden(true) + .appHiddenNavigationBar() + .sheet(isPresented: $showAddressPicker) { + addressPickerSheet + } + .task { await loadData() } + } + + // MARK: - Address picker row + + private var addressPickerRow: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Endereço de cobrança") + .font(AppTypography.caption) + .foregroundStyle(AppColors.textMuted) + + Button { + if addresses.isEmpty == false { showAddressPicker = true } + } label: { + HStack(spacing: 10) { + Image(systemName: "mappin.circle.fill") + .font(.system(size: 18)) + .foregroundStyle(selectedAddress != nil ? AppColors.primary : AppColors.textMuted) + + VStack(alignment: .leading, spacing: 2) { + if isLoadingAddresses { + Text("Carregando endereços...") + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + } else if let addr = selectedAddress { + Text(addressDisplayTitle(addr)) + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + .lineLimit(1) + if let sub = addressDisplaySubtitle(addr) { + Text(sub) + .font(AppTypography.caption) + .foregroundStyle(AppColors.textMuted) + .lineLimit(1) + } + } else if addresses.isEmpty { + Text("Nenhum endereço cadastrado") + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + } else { + Text("Selecionar endereço") + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + } + } + + Spacer() + + if addresses.isEmpty == false { + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(AppColors.textMuted) + } + } + .padding(14) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + .buttonStyle(.plain) + .disabled(isLoadingAddresses || addresses.isEmpty) + } + } + + // MARK: - Address picker sheet + + private var addressPickerSheet: some View { + NavigationStack { + ScrollView(showsIndicators: false) { + VStack(spacing: 10) { + ForEach(Array(addresses.enumerated()), id: \.offset) { _, addr in + Button { + selectedAddress = addr + showAddressPicker = false + } label: { + HStack(spacing: 12) { + Image(systemName: "mappin.circle.fill") + .font(.system(size: 22)) + .foregroundStyle(isSelected(addr) ? AppColors.primary : AppColors.textMuted) + + VStack(alignment: .leading, spacing: 3) { + Text(addressDisplayTitle(addr)) + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + .lineLimit(1) + if let sub = addressDisplaySubtitle(addr) { + Text(sub) + .font(AppTypography.caption) + .foregroundStyle(AppColors.textMuted) + .lineLimit(1) + } + } + + Spacer() + + if isSelected(addr) { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(AppColors.primary) + } + } + .padding(14) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 20) + .padding(.top, 14) + .padding(.bottom, 30) + } + .background(AppColors.backgroundLight) + .navigationTitle("Endereço de cobrança") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Fechar") { showAddressPicker = false } + .foregroundStyle(AppColors.textPrimary) + } + } + } + } + + // MARK: - Sub-views + + + private var cardNumberField: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Número do cartão") + .font(AppTypography.caption) + .foregroundStyle(AppColors.textMuted) + HStack(spacing: 8) { + TextField("0000 0000 0000 0000", text: $cardNumber) + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + .keyboardType(.numberPad) + .onChange(of: cardNumber) { _, v in cardNumber = formatCardNumber(v.filter(\.isNumber)) } + let digits = cardNumber.filter(\.isNumber) + if let logo = detectedBrandLogo { + Image(logo) + .resizable() + .scaledToFit() + .frame(width: 40, height: 26) + } else if digits.count >= 4 { + Image(systemName: "creditcard") + .font(.system(size: 22)) + .foregroundStyle(AppColors.textMuted) + .frame(width: 40, height: 26) + } + } + .padding(14) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + } + + private func labeledField( + _ label: String, + placeholder: String, + text: Binding, + keyboard: UIKeyboardType = .default, + autocap: Bool = false + ) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(label) + .font(AppTypography.caption) + .foregroundStyle(AppColors.textMuted) + TextField(placeholder, text: text) + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + .keyboardType(keyboard) + .autocorrectionDisabled() + .textInputAutocapitalization(autocap ? .characters : .never) + .padding(14) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + } + + private func formSection(_ title: String, @ViewBuilder content: () -> some View) -> some View { + VStack(alignment: .leading, spacing: 10) { + Text(title) + .font(AppTypography.caption) + .foregroundStyle(AppColors.textMuted) + .padding(.leading, 2) + content() + } + } + + // MARK: - Helpers + + private func isSelected(_ addr: CustomerAddress) -> Bool { + guard let sel = selectedAddress else { return false } + if let id = addr.id, let selId = sel.id { return id == selId } + return addr.address == sel.address && addr.number == sel.number + } + + private func addressDisplayTitle(_ addr: CustomerAddress) -> String { + let label = addr.label?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if label.isEmpty == false { return label } + let street = addr.address?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let number = addr.number?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let base = [street, number].filter { $0.isEmpty == false }.joined(separator: ", ") + return base.isEmpty ? "Endereço" : base + } + + private func addressDisplaySubtitle(_ addr: CustomerAddress) -> String? { + let parts = [ + addr.neighborhood?.trimmingCharacters(in: .whitespacesAndNewlines), + addr.city?.trimmingCharacters(in: .whitespacesAndNewlines), + addr.state?.trimmingCharacters(in: .whitespacesAndNewlines) + ].compactMap { v -> String? in + guard let v, v.isEmpty == false else { return nil } + return v + } + return parts.isEmpty ? nil : parts.joined(separator: ", ") + } + + // MARK: - Load & Save + + @MainActor + private func loadData() async { + holderName = appState.profile.name + cpf = formatCPF(appState.profile.cpf.filter(\.isNumber)) + isDefault = isFirstCard + + isLoadingAddresses = true + defer { isLoadingAddresses = false } + if let result = try? await ApiService().profile(forceRefresh: false).result { + let book = result.addressBook ?? [] + addresses = book + selectedAddress = book.first + } + } + + @MainActor + private func saveCard() async { + guard canSave, let addr = selectedAddress else { return } + isSaving = true + defer { isSaving = false } + + let parts = expiry.split(separator: "/") + let month = String(parts[0]) + let year: String = { + let y = String(parts[1]) + return y.count == 2 ? "20\(y)" : y + }() + let cleanNumber = cardNumber.filter(\.isNumber) + let cleanCpf = cpf.filter(\.isNumber) + let zip = selectedAddressZip + let addrNumber = addr.number?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "0" + + let creditCard = SaveCardCreditCardPayload( + holderName: holderName.trimmingCharacters(in: .whitespacesAndNewlines).uppercased(), + number: cleanNumber, + expiryMonth: month, + expiryYear: year, + ccv: cvv + ) + let holderInfo = SaveCardHolderInfoPayload( + name: holderName.trimmingCharacters(in: .whitespacesAndNewlines), + email: appState.profile.email, + cpfCnpj: cleanCpf, + postalCode: zip, + addressNumber: addrNumber.isEmpty ? "0" : addrNumber, + phone: appState.profile.phone + ) + let payload = SaveCardPayload( + creditCard: creditCard, + creditCardHolderInfo: holderInfo, + nickname: nickname.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : nickname, + isDefault: isDefault || isFirstCard + ) + + do { + let response = try await ApiService().saveCard(payload: payload) + if response.error == false, let result = response.result { + let newCard = SavedCard( + id: result.id, + nickname: payload.nickname, + holderName: result.holderName, + last4: result.last4, + brand: result.brand, + expiryMonth: result.expiryMonth, + expiryYear: result.expiryYear, + isDefault: result.isDefault + ) + onCardAdded(newCard) + dismiss() + SnackbarCenter.shared.show(title: "Cartão salvo com sucesso.", style: .success, icon: "creditcard.fill", duration: 2.5) + } else { + SnackbarCenter.shared.show(title: response.message ?? "Não foi possível salvar o cartão.", style: .error, icon: "xmark.octagon.fill", duration: 3.5) + } + } catch { + SnackbarCenter.shared.show(title: "Erro ao salvar cartão. Verifique os dados e tente novamente.", style: .error, icon: "xmark.octagon.fill", duration: 3.5) + } + } + + // MARK: - Formatters + + private func formatCardNumber(_ digits: String) -> String { + let d = String(digits.prefix(16)) + var result = "" + for (i, c) in d.enumerated() { + if i > 0 && i % 4 == 0 { result += " " } + result.append(c) + } + return result + } + + private func formatExpiry(_ value: String) -> String { + let digits = String(value.filter(\.isNumber).prefix(4)) + if digits.count > 2 { return "\(digits.prefix(2))/\(digits.dropFirst(2))" } + return digits + } + + private func formatCPF(_ digits: String) -> String { + let d = String(digits.prefix(11)) + if d.count <= 3 { return d } + if d.count <= 6 { return "\(d.prefix(3)).\(d.dropFirst(3))" } + if d.count <= 9 { return "\(d.prefix(3)).\(d.dropFirst(3).prefix(3)).\(d.dropFirst(6))" } + return "\(d.prefix(3)).\(d.dropFirst(3).prefix(3)).\(d.dropFirst(6).prefix(3))-\(d.dropFirst(9))" + } +} diff --git a/PediFoods/Views/Main/AddressComponents.swift b/PediFoods/Views/Main/AddressComponents.swift new file mode 100644 index 0000000..b5f4248 --- /dev/null +++ b/PediFoods/Views/Main/AddressComponents.swift @@ -0,0 +1,197 @@ +import SwiftUI +import UIKit + +func formatZipCodeBR(_ input: String) -> String { + let digits = input.filter(\.isNumber) + let limited = String(digits.prefix(8)) + if limited.count <= 5 { + return limited + } + let prefix = String(limited.prefix(5)) + let suffix = String(limited.dropFirst(5)) + return "\(prefix)-\(suffix)" +} + +func normalizeZipCodeForAPI(_ input: String) -> String { + String(input.filter(\.isNumber).prefix(8)) +} + +func triggerLightHaptic() { + UIImpactFeedbackGenerator(style: .light).impactOccurred() +} + +func triggerSelectionHaptic() { + UISelectionFeedbackGenerator().selectionChanged() +} + +struct SwipeToDeleteAddressRow: View { + let rowId: String + @Binding var openRowId: String? + let isDeleting: Bool + let onDelete: () -> Void + @ViewBuilder var content: () -> Content + + @State var contentOffset: CGFloat = 0 + + private let deleteWidth: CGFloat = 92 + private let openThreshold: CGFloat = 32 + private var showsDeleteAction: Bool { contentOffset < -2 || openRowId == rowId } + + var body: some View { + ZStack(alignment: .trailing) { + HStack(spacing: 0) { + Spacer(minLength: 0) + Button(action: { + triggerLightHaptic() + onDelete() + }) { + VStack(spacing: 8) { + Image(systemName: "trash.fill") + .font(.system(size: 20, weight: .semibold)) + Text(isDeleting ? "..." : "Excluir") + .font(AppTypography.overline) + } + .foregroundStyle(AppColors.textInverse) + .frame(width: deleteWidth) + .frame(maxHeight: .infinity) + .background(Color.red) + } + .buttonStyle(.plain) + .disabled(isDeleting) + .opacity(showsDeleteAction ? 1 : 0) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous)) + } + + content() + .offset(x: contentOffset) + .gesture( + DragGesture(minimumDistance: 8) + .onChanged { value in + guard isDeleting == false else { return } + if value.translation.width < 0 { + contentOffset = max(-deleteWidth, value.translation.width) + } else if openRowId == rowId { + contentOffset = min(0, -deleteWidth + value.translation.width) + } + } + .onEnded { _ in + guard isDeleting == false else { return } + if contentOffset <= -openThreshold { + let wasClosed = openRowId != rowId + contentOffset = -deleteWidth + openRowId = rowId + if wasClosed { + triggerSelectionHaptic() + } + } else { + contentOffset = 0 + if openRowId == rowId { + openRowId = nil + } + } + } + ) + .animation(.easeOut(duration: 0.18), value: contentOffset) + } + .clipped() + .animation(.easeOut(duration: 0.18), value: showsDeleteAction) + .onChange(of: openRowId) { _, newValue in + if newValue != rowId { + contentOffset = 0 + } + } + .onChange(of: isDeleting) { _, newValue in + if newValue { + contentOffset = 0 + } + } + } +} + +struct AddressCard: View { + let item: AddressListItem + var onEdit: (() -> Void)? = nil + var onDelete: (() -> Void)? = nil + var onSetDefault: (() -> Void)? = nil + + private var hasActions: Bool { onEdit != nil || onSetDefault != nil } + + var body: some View { + HStack(spacing: 14) { + icon + + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 10) { + Text(item.title) + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + .lineLimit(1) + .minimumScaleFactor(0.9) + + if item.isPrimary { + Text("PRINCIPAL") + .font(AppTypography.overline) + .foregroundStyle(AppColors.textPrimary) + .lineLimit(1) + .padding(EdgeInsets(top: 5, leading: 9, bottom: 5, trailing: 9)) + .background(AppColors.tertiary) + .clipShape(Capsule()) + } + } + + Text(item.detail) + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + .lineLimit(2) + } + + Spacer(minLength: 8) + + if hasActions { + Rectangle() + .fill(AppColors.backgroundLight) + .frame(width: 1, height: 96) + } + + VStack(spacing: 20) { + if let onEdit { + Button(action: onEdit) { + Image(systemName: "pencil") + .font(.system(size: 20)) + .foregroundStyle(AppColors.textMuted) + } + } + + if let onSetDefault, item.isPrimary == false { + Button(action: onSetDefault) { + Image(systemName: "star") + .font(.system(size: 20)) + .foregroundStyle(AppColors.textMuted) + } + } + } + .frame(width: hasActions ? 40 : 0) + } + .padding(.horizontal, 16) + .padding(.vertical, 20) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous)) + } + + var icon: some View { + Image(systemName: item.icon) + .font(.system(size: 28)) + .foregroundStyle(item.isPrimary ? AppColors.primary : AppColors.textPrimary) + .frame(width: 84, height: 84) + .background(item.isPrimary ? AppColors.brandSoft : AppColors.backgroundLight) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } +} + +struct AddressListItem: Identifiable { + let id = UUID() + let title: String + let detail: String + let icon: String + let isPrimary: Bool +} diff --git a/PediFoods/Views/Main/AddressesView.swift b/PediFoods/Views/Main/AddressesView.swift new file mode 100644 index 0000000..9321043 --- /dev/null +++ b/PediFoods/Views/Main/AddressesView.swift @@ -0,0 +1,437 @@ +import SwiftUI +#if canImport(LCEssentials) +import LCEssentials +#endif + +struct AddressesView: View { + let message: String? + @Binding var appState: AppState + var selectionMode: Bool = false + @Environment(\.dismiss) var dismiss + @State var isLoading = false + @State var errorMessage: String? = nil + @State var addresses: [CustomerAddress] = [] + @State var openAddAddressForm = false + @State var editingAddress: CustomerAddress? = nil + @State var openSwipeRowId: String? = nil + @State var deletingRowId: String? = nil + @State var settingDefaultRowId: String? = nil + + let tabBarClearance: CGFloat = 96 + + var body: some View { + LCENavigationView { + content + } + .setLeftButton(image: AnyView(AppBackButtonIcon())) { dismiss() } + .setTitle(text: Text("Meus Endereços").font(AppTypography.heading2).foregroundStyle(AppColors.textPrimary)) + .setNavigationBarBackgroundColor(AppColors.backgroundLight) + .buttonStyle(.plain) + } + + private var content: some View { + ZStack { + AppColors.backgroundLight + .ignoresSafeArea() + + ScrollView(showsIndicators: false) { + VStack(spacing: 20) { + if let message { + Text(message) + .font(AppTypography.body) + .foregroundStyle(AppColors.textPrimary) + .multilineTextAlignment(.center) + .padding(.horizontal, 20) + .padding(.vertical, 14) + .frame(maxWidth: .infinity, alignment: .center) + .background(AppColors.brandSoft) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + + VStack(spacing: 16) { + if isLoading { + ProgressView() + .padding(.top, 24) + } else if let errorMessage { + Text(errorMessage) + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + .multilineTextAlignment(.center) + .padding(.top, 24) + } else if addresses.isEmpty { + Text("Nenhum endereço cadastrado") + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + .padding(.top, 24) + } else { + ForEach(Array(addresses.enumerated()), id: \.offset) { index, address in + let rowId = addressRowId(for: address, index: index) + let isPrimary: Bool = { + if address.isDefault == true { return true } + if addresses.contains(where: { $0.isDefault == true }) { return false } + if let selectedId = appState.address.selectedId { + return address.id == selectedId + } + return index == 0 + }() + + if selectionMode { + Button { + selectAddress(address) + } label: { + AddressCard(item: addressToListItem(address, isPrimary: isPrimary)) + } + .buttonStyle(.plain) + } else if addresses.count > 1 { + SwipeToDeleteAddressRow( + rowId: rowId, + openRowId: $openSwipeRowId, + isDeleting: deletingRowId == rowId, + onDelete: { deleteAddress(address, rowId: rowId) } + ) { + AddressCard( + item: addressToListItem(address, isPrimary: isPrimary), + onEdit: { beginEditing(address) }, + onSetDefault: { setDefaultAddress(address, rowId: rowId) } + ) + .appContentShape(Rectangle()) + .simultaneousGesture(TapGesture().onEnded { + if openSwipeRowId == rowId { openSwipeRowId = nil } + }) + .opacity(settingDefaultRowId == rowId ? 0.6 : 1.0) + } + .id(rowId) + .opacity(deletingRowId == rowId ? 0.6 : 1.0) + .disabled(deletingRowId != nil || settingDefaultRowId != nil) + } else { + AddressCard( + item: addressToListItem(address, isPrimary: isPrimary), + onEdit: { beginEditing(address) } + ) + .overlay(alignment: .bottom) { + Text("Ao menos um endereço deve permanecer") + .font(AppTypography.caption) + .foregroundStyle(AppColors.textMuted.opacity(0.7)) + .padding(.bottom, 8) + } + } + } + } + } + } + .padding(.horizontal, 20) + .padding(.top, 18) + } + + VStack { + Spacer() + bottomOverlay + .padding(.bottom, tabBarClearance) + } + + } + .navigationBarBackButtonHidden(true) + .appHiddenNavigationBar() + .sheet(isPresented: $openAddAddressForm) { + NavigationStack { + AddAddressFormView(existingAddress: editingAddress) { updatedAddresses, wasEditing in + addresses = updatedAddresses + applyPreferredAddress(from: updatedAddresses) + let title = wasEditing ? "Endereço atualizado com sucesso." : "Endereço adicionado com sucesso." + SnackbarCenter.shared.show(title: title, style: .success, icon: "checkmark.seal.fill", duration: 3.0) + } + } + } + .onChange(of: openAddAddressForm) { _, isOpen in + if isOpen == false { + editingAddress = nil + } + } + .onAppear { + if isLoading == false, addresses.isEmpty { + Task { + await loadAddresses() + } + } + } + } + + var bottomOverlay: some View { + ZStack(alignment: .bottom) { + Rectangle() + .fill(AppColors.backgroundLight) + .frame(height: 136) + + Button(action: { + editingAddress = nil + openAddAddressForm = true + }) { + HStack(spacing: 12) { + Image(systemName: "mappin.circle.fill") + .font(.system(size: 24)) + Text("Adicionar novo endereço") + .font(AppTypography.heading3) + } + .foregroundStyle(AppColors.textInverse) + .frame(maxWidth: .infinity) + .padding(.vertical, 18) + .background(AppColors.primary) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous)) + } + .buttonStyle(.plain) + .padding(.horizontal, 20) + .padding(.bottom, 14) + } + } + + private func selectAddress(_ address: CustomerAddress) { + appState.address.selectedId = address.id + let label = (address.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + appState.address.display = label.isEmpty ? "Defina seu endereco" : label + + if let lat = address.latLong?.first, let lng = address.latLong?.dropFirst().first { + appState.address.latitude = lat + appState.address.longitude = lng + } else { + appState.address.latitude = nil + appState.address.longitude = nil + } + + SessionStateStore.saveAddress(appState.address) + + if selectionMode { + dismiss() + } + } + + private func beginEditing(_ address: CustomerAddress) { + openSwipeRowId = nil + editingAddress = address + openAddAddressForm = true + } + + private func applyPreferredAddress(from updatedAddresses: [CustomerAddress]) { + let selected = resolvePreferredAddress(from: updatedAddresses) + appState.address.selectedId = selected?.id + appState.address.display = selected?.label?.isEmpty == false ? (selected?.label ?? "Defina seu endereco") : "Defina seu endereco" + + if let lat = selected?.latLong?.first, let lng = selected?.latLong?.dropFirst().first { + appState.address.latitude = lat + appState.address.longitude = lng + } else { + appState.address.latitude = nil + appState.address.longitude = nil + } + + SessionStateStore.saveAddress(appState.address) + } + + private func addressRowId(for address: CustomerAddress, index: Int) -> String { + if let id = address.id, id.isEmpty == false { + return "addr:\(id)" + } + return "idx:\(index):\(address.label ?? ""):\(address.address ?? ""):\(address.number ?? "")" + } + + private func setDefaultAddress(_ address: CustomerAddress, rowId: String) { + guard settingDefaultRowId == nil else { return } + settingDefaultRowId = rowId + openSwipeRowId = nil + + Task { + var resolvedId = address.id?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + + if resolvedId.isEmpty { + if let book = try? await ApiService().profile(forceRefresh: true).result?.addressBook { + await MainActor.run { addresses = book } + resolvedId = book.first { + $0.address == address.address && + $0.number == address.number && + $0.zipCode == address.zipCode + }?.id?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + } + } + + guard resolvedId.isEmpty == false else { + await MainActor.run { + settingDefaultRowId = nil + SnackbarCenter.shared.show(title: "Não foi possível identificar o endereço.", style: .error, icon: "xmark.octagon.fill", duration: 3.5) + } + return + } + + do { + let response = try await ApiService().setDefaultAddress(addressId: resolvedId) + await MainActor.run { + settingDefaultRowId = nil + if response.error == false { + addresses = addresses.map { addr in + let isTarget = (addr.id ?? "") == resolvedId + return CustomerAddress( + id: addr.id, label: addr.label, address: addr.address, + number: addr.number, complement: addr.complement, + neighborhood: addr.neighborhood, city: addr.city, + state: addr.state, zipCode: addr.zipCode, + latLong: addr.latLong, isDefault: isTarget + ) + } + selectAddress(address) + SnackbarCenter.shared.show(title: "Endereço principal atualizado.", style: .success, icon: "star.fill", duration: 2.5) + } else { + SnackbarCenter.shared.show(title: response.message ?? "Não foi possível definir endereço principal.", style: .error, icon: "xmark.octagon.fill", duration: 3.0) + } + } + } catch { + await MainActor.run { + settingDefaultRowId = nil + SnackbarCenter.shared.show(title: "Erro ao atualizar endereço principal.", style: .error, icon: "xmark.octagon.fill", duration: 3.0) + } + } + } + } + + private func deleteAddress(_ address: CustomerAddress, rowId: String) { + guard addresses.count > 1, deletingRowId == nil else { return } + deletingRowId = rowId + openSwipeRowId = nil + + Task { + do { + let response = try await ApiService().deleteCustomerAddress(address) + await MainActor.run { + deletingRowId = nil + + guard response.error == false else { + let message = response.message ?? "Não foi possível excluir o endereço." + SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 4.0) + return + } + + let updatedAddresses = response.result?.addressBook ?? [] + addresses = updatedAddresses + applyPreferredAddress(from: updatedAddresses) + SnackbarCenter.shared.show(title: "Endereço removido com sucesso.", style: .success, icon: "checkmark.seal.fill", duration: 3.0) + } + } catch { + await MainActor.run { + deletingRowId = nil + let message = error.localizedDescription + SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 4.0) + } + } + } + } + + func addressToListItem(_ address: CustomerAddress, isPrimary: Bool) -> AddressListItem { + let title = address.label?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? (address.label ?? "Endereço") : "Endereço" + let line1 = [address.address, address.number] + .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { $0.isEmpty == false } + .joined(separator: ", ") + let line2 = [address.neighborhood, address.city, address.state] + .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { $0.isEmpty == false } + .joined(separator: ", ") + let detail = [line1, line2] + .filter { $0.isEmpty == false } + .joined(separator: " - ") + + return AddressListItem( + title: title, + detail: detail.isEmpty ? "Endereço sem detalhes" : detail, + icon: iconName(for: title), + isPrimary: isPrimary + ) + } + + func iconName(for label: String) -> String { + let normalized = label.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) + if normalized.contains("casa") { + return "house.fill" + } + if normalized.contains("trabalho") { + return "briefcase.fill" + } + return "mappin.and.ellipse" + } + + @MainActor + func loadAddresses() async { + isLoading = true + errorMessage = nil + + do { + let service = ApiService() + let response = try await service.profile(forceRefresh: true) + guard response.error == false else { + errorMessage = response.message ?? "Não foi possível carregar os endereços." + isLoading = false + return + } + + if let customer = response.result { + appState.profile.id = customer.id + appState.profile.name = customer.name + appState.profile.email = customer.email + appState.profile.phone = customer.phoneNumber ?? appState.profile.phone + appState.profile.profilePicture = customer.profilePicture ?? "" + appState.profile.notificationsEnabled = customer.notificationsEnabled ?? false + appState.profile.faceIdEnabled = customer.faceIdEnabled ?? false + SessionStateStore.setActiveUserKey( + SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email) + ) + addresses = customer.addressBook ?? [] + } else { + addresses = [] + } + if let selected = resolvePreferredAddress(from: addresses) { + appState.address.selectedId = selected.id + appState.address.display = selected.label ?? "Defina seu endereco" + if let lat = selected.latLong?.first, let lng = selected.latLong?.dropFirst().first { + appState.address.latitude = lat + appState.address.longitude = lng + } + SessionStateStore.saveAddress(appState.address) + } + } catch { + errorMessage = error.localizedDescription + } + + isLoading = false + } + + private func resolvePreferredAddress(from list: [CustomerAddress]) -> CustomerAddress? { + guard list.isEmpty == false else { return nil } + + let selectedId = appState.address.selectedId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if selectedId.isEmpty == false, + let byId = list.first(where: { ($0.id ?? "").trimmingCharacters(in: .whitespacesAndNewlines) == selectedId }) { + return byId + } + + let normalizedDisplay = appState.address.display + .trimmingCharacters(in: .whitespacesAndNewlines) + .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) + .lowercased() + if normalizedDisplay.isEmpty == false && normalizedDisplay != "defina seu endereco", + let byLabel = list.first(where: { + (($0.label ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) + .lowercased()) == normalizedDisplay + }) { + return byLabel + } + + if let lat = appState.address.latitude, let lng = appState.address.longitude, + let byCoordinate = list.first(where: { address in + guard let addrLat = address.latLong?.first, + let addrLng = address.latLong?.dropFirst().first else { return false } + return abs(addrLat - lat) < 0.00001 && abs(addrLng - lng) < 0.00001 + }) { + return byCoordinate + } + + return list.first + } +} diff --git a/PediFoods/Views/Main/CartView.swift b/PediFoods/Views/Main/CartView.swift new file mode 100644 index 0000000..a437616 --- /dev/null +++ b/PediFoods/Views/Main/CartView.swift @@ -0,0 +1,413 @@ +import Foundation +import SwiftUI +#if canImport(LCEssentials) +import LCEssentials +#endif + +struct CartView: View { + @Binding var appState: AppState + @Binding var selectedTab: MainTab + let enterAuth: () -> Void + @State var openCheckout = false + @State var couponCode = "" + @State var appliedCouponCode: String? = nil + @State var deliveryFee: Double? = nil + @State var selectedCustomerAddress: CustomerAddress? = nil + @State var isLoadingDeliveryFee = false + + var body: some View { + LCENavigationView { + content + } + .setTitle(text: Text("Meu Carrinho").font(AppTypography.heading1).foregroundStyle(AppColors.textPrimary)) + .setNavigationBarBackgroundColor(AppColors.backgroundLight) + .buttonStyle(.plain) + } + + private var content: some View { + ScrollView(showsIndicators: false) { + VStack(spacing: 16) { + if appState.cart.items.isEmpty { + VStack(spacing: 10) { + Text("Seu carrinho está vazio") + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + Text("Adicione produtos para continuar.") + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + } + .frame(maxWidth: .infinity, minHeight: 280, alignment: .center) + } else { + VStack(spacing: 12) { + ForEach(appState.cart.items) { item in + cartItemRow(item) + } + } + .padding(.horizontal, 20) + +// couponSection +// .padding(.horizontal, 20) + + summarySection + .padding(.horizontal, 20) + } + } + .padding(.bottom, 120) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(AppColors.backgroundLight) + .navigationDestination(isPresented: $openCheckout) { + CheckoutView(appState: $appState, selectedTab: $selectedTab) + } + .task(id: deliveryFeeWatchKey) { + await refreshDeliveryFee() + } + } + + private var subtotalValue: Double { + appState.cart.items.reduce(0) { $0 + (Double($1.quantity) * $1.unitPrice) } + } + + private var totalValue: Double { + max(0, subtotalValue + (deliveryFee ?? 0) - effectiveDiscountValue) + } + + private var effectiveDiscountValue: Double { + let normalizedCoupon = (appliedCouponCode ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + .uppercased() + + if normalizedCoupon == "DESCONTO10" { + return min(subtotalValue, subtotalValue * 0.1) + } + return 0 + } + + private var discountLabelValue: String { + if effectiveDiscountValue <= 0.0001 { + return formatCurrency(0) + } + return "-\(formatCurrency(effectiveDiscountValue))" + } + + private var deliveryFeeWatchKey: String { + let storeId = appState.cart.storeId ?? "nil" + let selectedId = appState.address.selectedId ?? "nil" + let display = appState.address.display.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let lat = appState.address.latitude.map { String(format: "%.5f", $0) } ?? "nil" + let lng = appState.address.longitude.map { String(format: "%.5f", $0) } ?? "nil" + return "\(storeId)|\(selectedId)|\(display)|\(lat)|\(lng)" + } + + private var couponSection: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Cupom de Desconto") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + + HStack(spacing: 10) { + HStack(spacing: 8) { + Image(systemName: "ticket") + .foregroundStyle(AppColors.textMuted) + TextField("Inserir cupom", text: $couponCode) + .appNoAutoCap() + } + .padding(.horizontal, 12) + .frame(height: 50) + .background(AppColors.surface) + .overlay( + RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous) + .stroke(AppColors.brandSoft, lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + + Button("Aplicar") { + applyCoupon() + } + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textInverse) + .frame(width: 120, height: 50) + .background(AppColors.brandDark) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + .buttonStyle(.plain) + } + + if let appliedCouponCode { + Text("Cupom aplicado: \(appliedCouponCode)") + .font(.caption) + .foregroundStyle(AppColors.primary) + } + } + } + + private var summarySection: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Resumo de Valores") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + + summaryRow(title: "Subtotal", value: formatCurrency(subtotalValue)) + summaryRow(title: "Taxa de Entrega", value: deliveryFeeLabel) + summaryRow(title: "Desconto", value: discountLabelValue, valueColor: effectiveDiscountValue > 0 ? Color.red : AppColors.textMuted) + + Divider() + + summaryRow(title: "Total", value: formatCurrency(totalValue), highlighted: true) + + Button { + guard appState.session.isAuthenticated else { + enterAuth() + return + } + openCheckout = true + } label: { + HStack(spacing: 10) { + Text("Ir para o Pagamento") + .font(AppTypography.heading2) + Image(systemName: "arrow.right") + .font(.system(size: 18, weight: .bold)) + } + .foregroundStyle(AppColors.textInverse) + .frame(maxWidth: .infinity, minHeight: 54) + .background(AppColors.primary) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + .buttonStyle(.plain) + } + .padding(16) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous)) + } + + private var deliveryFeeLabel: String { + if isLoadingDeliveryFee { + return "Calculando..." + } + if let deliveryFee { + return formatCurrency(deliveryFee) + } + return "Indisponível" + } + + private func summaryRow(title: String, value: String, valueColor: Color? = nil, highlighted: Bool = false) -> some View { + HStack { + Text(title) + .font(highlighted ? AppTypography.heading2 : AppTypography.body) + .foregroundStyle(AppColors.textMuted) + Spacer() + Text(value) + .font(highlighted ? AppTypography.heading1 : AppTypography.heading3) + .foregroundStyle(valueColor ?? AppColors.textPrimary) + } + } + + private func cartItemRow(_ item: CartItemState) -> some View { + HStack(spacing: 14) { + AsyncStoreImage(imageURL: item.imageURL) + .frame(width: 78, height: 78) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + + VStack(alignment: .leading, spacing: 6) { + Text(item.name) + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + .lineLimit(1) + if let details = item.details, details.isEmpty == false { + Text(details) + .font(.caption) + .foregroundStyle(AppColors.textMuted) + .lineLimit(2) + } + Text(formatCurrency(item.unitPrice)) + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + } + + Spacer() + + HStack(spacing: 10) { + Button(action: { appState.cart.decrement(itemId: item.id) }) { + Image(systemName: "minus") + .font(.system(size: 13, weight: .bold)) + .foregroundStyle(AppColors.textPrimary) + .frame(width: 32, height: 32) + .background(AppColors.backgroundLight) + .clipShape(Circle()) + } + .buttonStyle(.plain) + + Text("\(item.quantity)") + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + .frame(minWidth: 18) + + Button(action: { appState.cart.increment(itemId: item.id) }) { + Image(systemName: "plus") + .font(.system(size: 13, weight: .bold)) + .foregroundStyle(AppColors.textPrimary) + .frame(width: 32, height: 32) + .background(AppColors.tertiary) + .clipShape(Circle()) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(AppColors.backgroundLight) + .clipShape(Capsule()) + } + .padding(.horizontal, 12) + .padding(.vertical, 12) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + + private func applyCoupon() { + let normalized = couponCode.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + guard normalized.isEmpty == false else { + appliedCouponCode = nil + return + } + + if normalized == "DESCONTO10" { + appliedCouponCode = normalized + return + } + + appliedCouponCode = nil + } + + private func formatCurrency(_ value: Double) -> String { + String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",") + } + + @MainActor + private func refreshDeliveryFee() async { + guard appState.cart.items.isEmpty == false else { + deliveryFee = nil + selectedCustomerAddress = nil + return + } + guard let storeId = appState.cart.storeId, storeId.isEmpty == false else { + deliveryFee = nil + selectedCustomerAddress = nil + return + } + + isLoadingDeliveryFee = true + defer { isLoadingDeliveryFee = false } + + do { + let profileResponse = try await ApiService().profile(forceRefresh: true) + let addresses = profileResponse.result?.addressBook ?? [] + + if let selectedId = appState.address.selectedId, selectedId.isEmpty == false { + selectedCustomerAddress = addresses.first(where: { $0.id == selectedId }) + } else { + selectedCustomerAddress = nil + } + + // id can be nil for some address book entries — lat/lng is set + // immediately and reliably at selection time (AddressesView. + // selectAddress), so it's a stronger signal than the label match + // below, which silently collides whenever two addresses share an + // empty/duplicate label. Without this, an id-less address falls + // through to addresses.first and never actually "changes". + if selectedCustomerAddress == nil, + let lat = appState.address.latitude, let lng = appState.address.longitude { + selectedCustomerAddress = addresses.first { address in + guard let addrLat = address.latLong?.first, + let addrLng = address.latLong?.dropFirst().first else { return false } + return abs(addrLat - lat) < 0.00001 && abs(addrLng - lng) < 0.00001 + } + } + + if selectedCustomerAddress == nil { + let display = appState.address.display + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + if display.isEmpty == false, display != "defina seu endereco" { + selectedCustomerAddress = addresses.first { + ($0.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == display + } + } + } + + if selectedCustomerAddress == nil { + selectedCustomerAddress = addresses.first + } + + if let selected = selectedCustomerAddress { + // appState.address reflects the address the user just picked — + // authoritative. Only fill in gaps from the address book here, + // never overwrite a live selection with a (possibly stale) + // cached record, or the fee/validation payload below can end + // up built against the wrong coordinates. + if appState.address.selectedId == nil || appState.address.selectedId?.isEmpty == true { + appState.address.selectedId = selected.id + } + if appState.address.display.isEmpty || appState.address.display == "Defina seu endereco" { + let label = (selected.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if label.isEmpty == false { + appState.address.display = label + } + } + if appState.address.latitude == nil || appState.address.longitude == nil, + let lat = selected.latLong?.first, let lng = selected.latLong?.dropFirst().first { + appState.address.latitude = lat + appState.address.longitude = lng + } + SessionStateStore.saveAddress(appState.address) + } + + var payloadLat = appState.address.latitude ?? selectedCustomerAddress?.latLong?.first + var payloadLng = appState.address.longitude ?? selectedCustomerAddress?.latLong?.dropFirst().first + + // Some saved addresses have no lat/long (CEP lookup at creation + // time didn't return coordinates). Without coordinates the + // backend can't tell this address apart from any other, so the + // fee silently never changes. Geocode locally as a fallback. + if payloadLat == nil || payloadLng == nil { + if let coordinate = await LocationService.geocodeAddress( + street: selectedCustomerAddress?.address, + number: selectedCustomerAddress?.number, + neighborhood: selectedCustomerAddress?.neighborhood, + city: selectedCustomerAddress?.city, + state: selectedCustomerAddress?.state, + zip: selectedCustomerAddress?.zipCode + ) { + payloadLat = coordinate.0 + payloadLng = coordinate.1 + appState.address.latitude = coordinate.0 + appState.address.longitude = coordinate.1 + SessionStateStore.saveAddress(appState.address) + } + } + + let payload = ValidateDeliveryAddressPayload( + address: ValidateDeliveryAddressDataPayload( + street: selectedCustomerAddress?.address, + number: selectedCustomerAddress?.number, + neighborhood: selectedCustomerAddress?.neighborhood, + city: selectedCustomerAddress?.city, + state: selectedCustomerAddress?.state, + zip: selectedCustomerAddress?.zipCode, + lat: payloadLat, + lng: payloadLng + ) + ) + + let validationResponse = try await ApiService().validateDeliveryAddress(storeId: storeId, payload: payload) + guard validationResponse.error == false, + validationResponse.result?.deliveryAllowed == true, + let fee = validationResponse.result?.deliveryFee else { + deliveryFee = nil + return + } + + deliveryFee = fee + } catch { + deliveryFee = nil + } + } +} diff --git a/PediFoods/Views/Main/CheckoutTypes.swift b/PediFoods/Views/Main/CheckoutTypes.swift new file mode 100644 index 0000000..d86090b --- /dev/null +++ b/PediFoods/Views/Main/CheckoutTypes.swift @@ -0,0 +1,41 @@ +import Foundation + +enum CheckoutDeliveryType: String { + case delivery = "DELIVERY" + case pickup = "PICKUP" +} + +enum CheckoutPaymentMethod: String { + case pix = "PIX" + case creditCard = "CREDIT_CARD" + case debitCard = "DEBIT_CARD" + case money = "MONEY" + case voucher = "VOUCHER" + + var label: String { + switch self { + case .pix: return "PIX" + case .creditCard: return "Cartão de Crédito" + case .debitCard: return "Cartão de Débito" + case .money: return "Dinheiro" + case .voucher: return "Vale Refeição/Alimentação" + } + } + + var subtitle: String? { + switch self { + case .pix: return "Aprovação imediata" + case .creditCard: return "No app: rápido e seguro" + default: return nil + } + } + + var iconName: String { + switch self { + case .pix: return "bolt.fill" + case .creditCard, .debitCard: return "creditcard.fill" + case .money: return "banknote.fill" + case .voucher: return "ticket.fill" + } + } +} diff --git a/PediFoods/Views/Main/CheckoutView+Logic.swift b/PediFoods/Views/Main/CheckoutView+Logic.swift new file mode 100644 index 0000000..9d34469 --- /dev/null +++ b/PediFoods/Views/Main/CheckoutView+Logic.swift @@ -0,0 +1,471 @@ +import SwiftUI + +extension CheckoutView { + enum CheckoutPayloadValidationError: LocalizedError { + case emptyCart + case missingCustomerName + case missingCustomerEmail + case missingCustomerPhone + case missingAddressStreet + case missingAddressNumber + case missingAddressNeighborhood + + var errorDescription: String? { + switch self { + case .emptyCart: return "Carrinho vazio." + case .missingCustomerName: return "Nome do cliente não informado." + case .missingCustomerEmail: return "Email do cliente não informado." + case .missingCustomerPhone: return "Telefone do cliente não informado." + case .missingAddressStreet: return "Rua do endereço não informada." + case .missingAddressNumber: return "Número do endereço não informado." + case .missingAddressNeighborhood: return "Bairro do endereço não informado." + } + } + } + var checkoutAddressWatchKey: String { + let selectedId = appState.address.selectedId ?? "nil" + let display = appState.address.display.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let lat = appState.address.latitude.map { String(format: "%.5f", $0) } ?? "nil" + let lng = appState.address.longitude.map { String(format: "%.5f", $0) } ?? "nil" + return "\(selectedId)|\(display)|\(lat)|\(lng)" + } + + func formatCurrency(_ value: Double) -> String { + String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",") + } + + @MainActor + func updateMinOrderSnackbar() { + guard isBelowMinOrder else { + SnackbarCenter.shared.dismissPersistent() + return + } + let missing = formatCurrency(minOrderValue - totalValue) + SnackbarCenter.shared.show( + title: "Pedido mínimo de \(formatCurrency(minOrderValue)). Faltam \(missing) para finalizar.", + style: .warning, + icon: "exclamationmark.circle.fill", + isPersistent: true + ) + } + + @MainActor + func loadStoreInfoIfNeeded() async { + guard let storeId = appState.cart.storeId, storeId.isEmpty == false else { return } + do { + let response = try await ApiService().storeInfo(storeId: storeId) + if response.error { + errorMessage = response.message ?? "Não foi possível carregar opções de checkout." + return + } + storeInfo = response.result + if showPaymentModeToggle == false { + useInAppPayment = true + } + errorMessage = nil + } catch { + errorMessage = "Não foi possível carregar opções de checkout." + } + } + + @MainActor + func refreshSelectedCustomerAddress() async { + do { + let response = try await ApiService().profile(forceRefresh: true) + if let customer = response.result { + appState.profile.id = customer.id + appState.profile.name = customer.name + appState.profile.email = customer.email + if let phoneNumber = customer.phoneNumber, phoneNumber.isEmpty == false { + appState.profile.phone = phoneNumber + } + appState.profile.profilePicture = customer.profilePicture ?? "" + appState.profile.notificationsEnabled = customer.notificationsEnabled ?? false + appState.profile.faceIdEnabled = customer.faceIdEnabled ?? false + SessionStateStore.setActiveUserKey( + SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email) + ) + } + + let addresses = response.result?.addressBook ?? [] + if let selectedId = appState.address.selectedId, selectedId.isEmpty == false { + selectedCustomerAddress = addresses.first(where: { $0.id == selectedId }) + } else { + selectedCustomerAddress = nil + } + + // id can be nil for some address book entries — lat/lng is set + // immediately and reliably at selection time (AddressesView. + // selectAddress), so it's a stronger signal than the label match + // below, which silently collides whenever two addresses share an + // empty/duplicate label. Without this ordered first, an id-less + // address falls through to addresses.first and never actually + // "changes" even though delivery to it is allowed. + if selectedCustomerAddress == nil, + let lat = appState.address.latitude, let lng = appState.address.longitude { + selectedCustomerAddress = addresses.first { address in + guard let addrLat = address.latLong?.first, + let addrLng = address.latLong?.dropFirst().first else { return false } + return abs(addrLat - lat) < 0.00001 && abs(addrLng - lng) < 0.00001 + } + } + + if selectedCustomerAddress == nil { + let display = appState.address.display + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + if display.isEmpty == false, display != "defina seu endereco" { + selectedCustomerAddress = addresses.first { + ($0.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == display + } + } + } + + if selectedCustomerAddress == nil { + selectedCustomerAddress = addresses.first + } + + if let selected = selectedCustomerAddress { + // appState.address reflects the address the user just picked — + // authoritative. Only fill in gaps from the address book here, + // never overwrite a live selection with a (possibly stale) + // cached record, or delivery validation below runs against + // the wrong coordinates and can wrongly report the address + // as not served, reverting the user's pick. + if appState.address.selectedId == nil || appState.address.selectedId?.isEmpty == true { + appState.address.selectedId = selected.id + } + if appState.address.display.isEmpty || appState.address.display == "Defina seu endereco" { + let label = (selected.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if label.isEmpty == false { + appState.address.display = label + } + } + if appState.address.latitude == nil || appState.address.longitude == nil, + let lat = selected.latLong?.first, let lng = selected.latLong?.dropFirst().first { + appState.address.latitude = lat + appState.address.longitude = lng + } + SessionStateStore.saveAddress(appState.address) + } + } catch { + selectedCustomerAddress = nil + } + } + + @MainActor + func validateDeliveryAddressIfNeeded() async { + guard isDeliveryMode else { + addressValidationBlocked = false + addressValidationMessage = nil + baseDeliveryFee = nil + return + } + + guard let storeId = appState.cart.storeId, storeId.isEmpty == false else { return } + + baseDeliveryFee = nil + + // Some saved addresses have no lat/long (CEP lookup at creation time + // didn't return coordinates). Without coordinates the backend can't + // tell this address apart from any other, so the fee silently never + // changes. Geocode locally as a fallback before validating. + if appState.address.latitude == nil || appState.address.longitude == nil { + if let coordinate = await LocationService.geocodeAddress( + street: selectedCustomerAddress?.address, + number: selectedCustomerAddress?.number, + neighborhood: selectedCustomerAddress?.neighborhood, + city: selectedCustomerAddress?.city, + state: selectedCustomerAddress?.state, + zip: selectedCustomerAddress?.zipCode + ) { + appState.address.latitude = coordinate.0 + appState.address.longitude = coordinate.1 + SessionStateStore.saveAddress(appState.address) + } + } + + let payload = ValidateDeliveryAddressPayload( + address: ValidateDeliveryAddressDataPayload( + street: selectedCustomerAddress?.address, + number: selectedCustomerAddress?.number, + neighborhood: selectedCustomerAddress?.neighborhood, + city: selectedCustomerAddress?.city, + state: selectedCustomerAddress?.state, + zip: selectedCustomerAddress?.zipCode, + lat: appState.address.latitude, + lng: appState.address.longitude + ) + ) + + isValidatingAddress = true + defer { isValidatingAddress = false } + + do { + let response = try await ApiService().validateDeliveryAddress(storeId: storeId, payload: payload) + if response.error { + addressValidationBlocked = true + addressValidationMessage = response.message ?? "Não foi possível validar o endereço de entrega." + baseDeliveryFee = nil + return + } + + let result = response.result + let allowed = result?.deliveryAllowed ?? false + addressValidationBlocked = allowed == false + addressValidationMessage = result?.reasonMessage + + if allowed { + lastAcceptedAddressState = appState.address + } else { + showAddressNotServedAlert = true + baseDeliveryFee = nil + } + + if allowed { + if let fee = result?.deliveryFee { + baseDeliveryFee = fee + } else { + addressValidationBlocked = true + addressValidationMessage = "Não foi possível calcular a taxa de entrega para este endereço." + baseDeliveryFee = nil + } + } + } catch { + addressValidationBlocked = true + addressValidationMessage = "Não foi possível validar o endereço de entrega." + baseDeliveryFee = nil + } + } + + func normalizeSelectedOptions() { + if availableDeliveryTypes.contains(deliveryType) == false, + let first = availableDeliveryTypes.first { + deliveryType = first + } + + if useInAppPayment { + if availableInAppPaymentMethods.contains(paymentMethod) == false, + let first = availableInAppPaymentMethods.first { + paymentMethod = first + } + } else { + if availableStoreMachineMethods.contains(paymentMethod) == false, + let first = availableStoreMachineMethods.first { + paymentMethod = first + } + } + + if lastAcceptedAddressState == nil { + lastAcceptedAddressState = appState.address + } + } + + func restoreLastAcceptedAddress() { + guard let snapshot = lastAcceptedAddressState else { return } + isRestoringAddress = true + appState.address = snapshot + SessionStateStore.saveAddress(snapshot) + Task { @MainActor in + await refreshSelectedCustomerAddress() + addressValidationBlocked = false + addressValidationMessage = nil + isRestoringAddress = false + } + } + + @MainActor + func handleConfirmPaymentTap() async { + guard canConfirmPayment else { return } + + guard let storeId = appState.cart.storeId, storeId.isEmpty == false else { + SnackbarCenter.shared.show(title: "Loja do pedido não encontrada.", style: .error, icon: "xmark.octagon.fill", duration: 2.0) + return + } + + await refreshSelectedCustomerAddress() + + let effectivePaymentMethod = paymentMethod + if useInAppPayment && availableInAppPaymentMethods.contains(effectivePaymentMethod) == false { + SnackbarCenter.shared.show(title: "Método de pagamento não suportado no app.", style: .warning, icon: "exclamationmark.triangle.fill", duration: 2.0) + return + } + + // Crédito pelo app → abre seleção de cartão antes de criar pedido + if useInAppPayment && effectivePaymentMethod == .creditCard { + showCardSelectionSheet = true + return + } + + let payloadBuildResult = buildCreateOrderPayload(paymentMethod: effectivePaymentMethod, savedCardId: nil) + guard case .success(let payload) = payloadBuildResult else { + let message: String + if case .failure(let reason) = payloadBuildResult { + message = reason.localizedDescription + } else { + message = "Dados do pedido incompletos." + } + SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 2.0) + return + } + + isSubmittingOrder = true + defer { isSubmittingOrder = false } + + do { + let response = try await ApiService().createOrder(storeId: storeId, payload: payload) + let isInAppMethod = availableInAppPaymentMethods.contains(effectivePaymentMethod) + if useInAppPayment == false || isInAppMethod == false { + if response.error == false, let result = response.result { + let orderId = result.id ?? UUID().uuidString + appState.cart.clear() + SessionStateStore.clearPendingCartOrder() + orderTrackingContext = OrderTrackingContext(orderId: orderId, shortId: result.shortId) + } else { + SnackbarCenter.shared.show(title: response.message ?? "Não foi possível criar o pedido.", style: .error, icon: "xmark.octagon.fill", duration: 3.0) + } + return + } + handleOrderResponse(response, effectivePaymentMethod: effectivePaymentMethod) + } catch { + SnackbarCenter.shared.show(title: "Não foi possível criar o pedido.", style: .error, icon: "xmark.octagon.fill", duration: 3.0) + } + } + + func buildCreateOrderPayload( + paymentMethod: CheckoutPaymentMethod, + savedCardId: String? = nil, + creditCard: CreditCardOrderPayload? = nil, + creditCardHolderInfo: SaveCardHolderInfoPayload? = nil, + clientCpfCnpj: String? = nil + ) -> Result { + guard appState.cart.items.isEmpty == false else { return .failure(.emptyCart) } + + let profileName = appState.profile.name.trimmingCharacters(in: .whitespacesAndNewlines) + let profileEmail = appState.profile.email.trimmingCharacters(in: .whitespacesAndNewlines) + let profilePhone = appState.profile.phone.trimmingCharacters(in: .whitespacesAndNewlines) + guard profileName.isEmpty == false else { return .failure(.missingCustomerName) } + guard profileEmail.isEmpty == false else { return .failure(.missingCustomerEmail) } + guard profilePhone.isEmpty == false else { return .failure(.missingCustomerPhone) } + + let addressPayload: CreateOrderAddressPayload? + if isDeliveryMode { + let street = selectedCustomerAddress?.address?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let number = selectedCustomerAddress?.number?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let neighborhood = selectedCustomerAddress?.neighborhood?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard street.isEmpty == false else { return .failure(.missingAddressStreet) } + guard number.isEmpty == false else { return .failure(.missingAddressNumber) } + guard neighborhood.isEmpty == false else { return .failure(.missingAddressNeighborhood) } + addressPayload = CreateOrderAddressPayload( + street: street, + number: number, + neighborhood: neighborhood, + city: selectedCustomerAddress?.city, + state: selectedCustomerAddress?.state, + zip: selectedCustomerAddress?.zipCode, + complement: selectedCustomerAddress?.complement + ) + } else { + addressPayload = nil + } + + return .success( + CreateOrderPayload( + customer: CreateOrderCustomerPayload( + name: profileName, + phone: profilePhone, + email: profileEmail, + asaasId: nil + ), + items: appState.cart.toOrderItemsPayload(), + total: totalValue, + paymentMethod: paymentMethod.rawValue, + deliveryType: deliveryType.rawValue, + address: addressPayload, + savedCardId: savedCardId, + clientCpfCnpj: clientCpfCnpj, + creditCard: creditCard, + creditCardHolderInfo: creditCardHolderInfo + ) + ) + } + + @MainActor + func confirmOrderWithSavedCard(cardId: String, storeId: String) async { + let payloadResult = buildCreateOrderPayload(paymentMethod: .creditCard, savedCardId: cardId) + guard case .success(let payload) = payloadResult else { return } + + isSubmittingOrder = true + defer { isSubmittingOrder = false } + + do { + let response = try await ApiService().createOrder(storeId: storeId, payload: payload) + handleOrderResponse(response, effectivePaymentMethod: .creditCard) + } catch { + SnackbarCenter.shared.show(title: "Não foi possível criar o pedido.", style: .error, icon: "xmark.octagon.fill", duration: 3.0) + } + } + + func handleOrderResponse(_ response: ApiEnvelope, effectivePaymentMethod: CheckoutPaymentMethod) { + if response.error { + SnackbarCenter.shared.show( + title: response.message ?? "Não foi possível criar o pedido.", + style: .error, + icon: "xmark.octagon.fill", + duration: 3.0 + ) + return + } + guard let result = response.result else { + SnackbarCenter.shared.show(title: "Resposta de pagamento inválida.", style: .error, icon: "xmark.octagon.fill", duration: 3.0) + return + } + + let orderSnapshot = result.asPublicOrderResult() + SessionStateStore.saveTrackedOrder(orderSnapshot) + let orderId = result.id ?? UUID().uuidString + + if orderSnapshot.isPaymentConfirmed { + appState.cart.clear() + SessionStateStore.clearPendingCartOrder() + orderTrackingContext = OrderTrackingContext(orderId: orderId, shortId: result.shortId) + return + } + + SessionStateStore.savePendingCartOrderId(orderId) + + let pixFromPayment = result.payment?.pix + let pixFromPayload = result.paymentPayload + let copyPaste = (pixFromPayment?.copyPaste?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) + ? pixFromPayment?.copyPaste : pixFromPayload?.copyPaste + let qrCodeImage = (pixFromPayment?.qrCodeImage?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) + ? pixFromPayment?.qrCodeImage : pixFromPayload?.qrCodeImage + let expirationDate = (pixFromPayment?.expirationDate?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) + ? pixFromPayment?.expirationDate : pixFromPayload?.expirationDate + + if let copyPaste, copyPaste.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false { + let itemsData = (try? JSONEncoder().encode(appState.cart.toOrderItemsPayload())).flatMap { String(data: $0, encoding: .utf8) } ?? "[]" + let storeId = appState.cart.storeId ?? "" + pixPaymentContext = PixPaymentContext( + id: orderId, + orderId: orderId, + shortId: result.shortId, + storeId: storeId, + copyPaste: copyPaste, + qrCodeImageBase64: qrCodeImage, + expirationDate: expirationDate, + total: totalValue, + profileName: appState.profile.name, + profileEmail: appState.profile.email, + profilePhone: appState.profile.phone, + addressZip: selectedCustomerAddress?.zipCode, + addressNumber: selectedCustomerAddress?.number, + deliveryType: deliveryType.rawValue, + itemsJSON: itemsData + ) + return + } + + orderTrackingContext = OrderTrackingContext(orderId: orderId, shortId: result.shortId) + } +} diff --git a/PediFoods/Views/Main/CheckoutView.swift b/PediFoods/Views/Main/CheckoutView.swift new file mode 100644 index 0000000..96aa728 --- /dev/null +++ b/PediFoods/Views/Main/CheckoutView.swift @@ -0,0 +1,1877 @@ +import SwiftUI +import UIKit +#if canImport(LCEssentials) +import LCEssentials +#endif + +struct CheckoutView: View { + @Binding var appState: AppState + @Binding var selectedTab: MainTab + @Environment(\.dismiss) var dismiss + + @State var storeInfo: StoreInfoResult? = nil + @State var errorMessage: String? = nil + @State var deliveryType: CheckoutDeliveryType = .delivery + @State var paymentMethod: CheckoutPaymentMethod = .pix + @State var useInAppPayment = true + @State var discountValue: Double = 0 + @State var baseDeliveryFee: Double? = nil + @State var selectedCustomerAddress: CustomerAddress? = nil + @State var addressValidationMessage: String? = nil + @State var addressValidationBlocked = false + @State var isValidatingAddress = false + @State var showAddressNotServedAlert = false + @State var isRestoringAddress = false + @State var lastAcceptedAddressState: AddressState? = nil + @State var isSubmittingOrder = false + @State var pixPaymentContext: PixPaymentContext? = nil + @State var cardPaymentContext: CardPaymentContext? = nil + @State var orderTrackingContext: OrderTrackingContext? = nil + @State var showCardSelectionSheet = false + + var paymentConfig: StorePaymentMethodsInfo? { storeInfo?.paymentMethods } + + var availableDeliveryTypes: [CheckoutDeliveryType] { + let deliveryEnabled = paymentConfig?.paymentOnDelivery ?? true + let pickupEnabled = paymentConfig?.paymentOnPickup ?? true + var values: [CheckoutDeliveryType] = [] + if deliveryEnabled { values.append(.delivery) } + if pickupEnabled { values.append(.pickup) } + return values.isEmpty ? [.delivery, .pickup] : values + } + + var availableInAppPaymentMethods: [CheckoutPaymentMethod] { + [.pix, .creditCard] + } + + var availableStoreMachineMethods: [CheckoutPaymentMethod] { + var methods: [CheckoutPaymentMethod] = [] + if paymentConfig?.acceptCash == true { methods.append(.money) } + if paymentConfig?.hasAnyCreditCard == true { methods.append(.creditCard) } + if paymentConfig?.hasAnyDebitCard == true { methods.append(.debitCard) } + if paymentConfig?.hasAnyVoucher == true { methods.append(.voucher) } + return methods + } + + var isDeliveryMode: Bool { + deliveryType == .delivery + } + + var showPaymentModeToggle: Bool { + availableStoreMachineMethods.isEmpty == false + } + + private var deliveryToggle: Binding { + Binding( + get: { isDeliveryMode }, + set: { isOn in + let next: CheckoutDeliveryType = isOn ? .delivery : .pickup + if availableDeliveryTypes.contains(next) { + deliveryType = next + } + } + ) + } + + private var subtotalValue: Double { + appState.cart.items.reduce(0) { $0 + (Double($1.quantity) * $1.unitPrice) } + } + + private var deliveryFeeValue: Double { + isDeliveryMode ? (baseDeliveryFee ?? 0) : 0 + } + + var totalValue: Double { + max(0, subtotalValue + deliveryFeeValue - discountValue) + } + + private var sectionTitleColor: Color { + AppColors.textMuted + } + + var minOrderValue: Double { + storeInfo?.minOrder ?? 0 + } + + var isBelowMinOrder: Bool { + minOrderValue > 0 && totalValue < minOrderValue + } + + var canConfirmPayment: Bool { + if isDeliveryMode { + if addressValidationBlocked { return false } + if baseDeliveryFee == nil { return false } + if isValidatingAddress { return false } + } + if useInAppPayment == false && availableStoreMachineMethods.isEmpty { return false } + if isBelowMinOrder { return false } + return true + } + + var body: some View { + LCENavigationView { + content + } + .setLeftButton(image: AnyView(AppBackButtonIcon())) { dismiss() } + .setTitle(text: Text("Finalizar Pedido").font(AppTypography.heading2).foregroundStyle(AppColors.textPrimary)) + .setNavigationBarBackgroundColor(AppColors.backgroundLight) + .buttonStyle(.plain) + .appBottomSafeAreaInset { + bottomBar + } + .task { + await loadStoreInfoIfNeeded() + await refreshSelectedCustomerAddress() + normalizeSelectedOptions() + await validateDeliveryAddressIfNeeded() + updateMinOrderSnackbar() + } + .onChange(of: checkoutAddressWatchKey) { _, _ in + if isRestoringAddress { return } + Task { + await refreshSelectedCustomerAddress() + await validateDeliveryAddressIfNeeded() + } + } + .onChange(of: deliveryType) { _, _ in + Task { + await validateDeliveryAddressIfNeeded() + } + updateMinOrderSnackbar() + } + .onChange(of: totalValue) { _, _ in + updateMinOrderSnackbar() + } + .onDisappear { + SnackbarCenter.shared.dismissPersistent() + } + .alert("Seu endereço não é atendido por esta loja.\nDeseja trocar mesmo assim?", isPresented: $showAddressNotServedAlert) { + Button("Não", role: .cancel) { + restoreLastAcceptedAddress() + } + Button("Sim", role: .destructive) { + appState.cart.clear() + addressValidationBlocked = false + addressValidationMessage = nil + } + } + .navigationDestination(item: $pixPaymentContext) { context in + PaymentPixView( + context: context, + appState: $appState, + onPaymentConfirmed: { + appState.cart.clear() + SessionStateStore.clearPendingCartOrder() + } + ) { + appState.cart.clear() + SessionStateStore.clearPendingCartOrder() + orderTrackingContext = OrderTrackingContext(orderId: context.orderId, shortId: context.shortId) + } + } + .navigationDestination(item: $cardPaymentContext) { context in + PaymentCardView( + context: context, + appState: $appState, + onOrderCreated: { orderId, shortId in + appState.cart.clear() + SessionStateStore.clearPendingCartOrder() + orderTrackingContext = OrderTrackingContext(orderId: orderId, shortId: shortId) + } + ) + } + .sheet(isPresented: $showCardSelectionSheet) { + let cardContext = CardPaymentContext.build( + storeId: appState.cart.storeId ?? "", + total: totalValue, + deliveryType: deliveryType.rawValue, + profile: appState.profile, + address: selectedCustomerAddress, + items: appState.cart.toOrderItemsPayload() + ) + CardSelectionSheet( + cardContext: cardContext, + appState: $appState, + onSavedCardConfirmed: { cardId in + showCardSelectionSheet = false + Task { + guard let storeId = appState.cart.storeId else { return } + await confirmOrderWithSavedCard(cardId: cardId, storeId: storeId) + } + }, + onOrderCreated: { orderId, shortId in + showCardSelectionSheet = false + appState.cart.clear() + SessionStateStore.clearPendingCartOrder() + orderTrackingContext = OrderTrackingContext(orderId: orderId, shortId: shortId) + } + ) + } + .navigationDestination(item: $orderTrackingContext) { context in + OrderTrackingView(orderId: context.orderId, initialShortId: context.shortId, postOrderBack: { + appState.shouldNavigateToOrders = true + selectedTab = .profile + }) + } + } + + private var content: some View { + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 18) { + deliveryTypeSection + addressSection + orderSummarySection + paymentSection + + if let errorMessage, errorMessage.isEmpty == false { + Text(errorMessage) + .font(.caption) + .foregroundStyle(Color.red) + } + + if let addressValidationMessage, addressValidationMessage.isEmpty == false { + Text(addressValidationMessage) + .font(.caption) + .foregroundStyle(addressValidationBlocked ? Color.red : AppColors.primary) + } + } + .padding(.horizontal, 20) + .padding(.top, 16) + .padding(.bottom, 10) + } + .background(AppColors.backgroundLight) + } + + private var deliveryTypeSection: some View { + VStack(alignment: .leading, spacing: 10) { + Text("TIPO DE ENTREGA") + .font(AppTypography.overline) + .foregroundStyle(sectionTitleColor) + + HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 2) { + Text(isDeliveryMode ? "Entrega" : "Retirada") + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + } + Spacer() + Toggle("", isOn: deliveryToggle) + .labelsHidden() + .tint(AppColors.primary) + .disabled(availableDeliveryTypes.count <= 1) + } + .padding(14) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + } + + private var addressSection: some View { + VStack(alignment: .leading, spacing: 10) { + Text(addressSectionTitle) + .font(AppTypography.overline) + .foregroundStyle(sectionTitleColor) + + HStack(spacing: 12) { + Circle() + .fill(AppColors.brandSoft) + .frame(width: 44, height: 44) + .overlay( + Image(systemName: "mappin.and.ellipse") + .foregroundStyle(AppColors.primary) + ) + + VStack(alignment: .leading, spacing: 4) { + Text(isDeliveryMode ? customerAddressName : (appState.cart.storeName ?? "Loja")) + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + Text(isDeliveryMode ? customerAddressLabel : storeAddressLabel) + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + } + + Spacer() + + if isDeliveryMode { + Button("Alterar") { + appState.activeModal = .addressPicker + } + .font(AppTypography.heading3) + .foregroundStyle(AppColors.primary) + .buttonStyle(.plain) + } + } + .padding(14) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + } + + private var orderSummarySection: some View { + VStack(alignment: .leading, spacing: 10) { + Text("RESUMO DO PEDIDO") + .font(AppTypography.overline) + .foregroundStyle(sectionTitleColor) + + VStack(alignment: .leading, spacing: 12) { + ForEach(appState.cart.items) { item in + HStack(alignment: .center, spacing: 10) { + Text("\(item.quantity)x") + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textMuted) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(AppColors.backgroundLight) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + + VStack(alignment: .leading, spacing: 2) { + Text(item.name) + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + if let details = item.details, details.isEmpty == false { + Text(details) + .font(.caption) + .foregroundStyle(AppColors.textMuted) + .lineLimit(1) + } + } + Spacer() + Text(formatCurrency(Double(item.quantity) * item.unitPrice)) + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + } + } + + Divider() + .padding(.vertical, 4) + + summaryRow("Subtotal", formatCurrency(subtotalValue)) + summaryRow("Taxa de entrega", deliveryFeeLabel) + summaryRow("Desconto", "-\(formatCurrency(discountValue))", valueColor: Color.green) + } + .padding(14) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + } + + private var paymentSection: some View { + VStack(alignment: .leading, spacing: 10) { + Text("MÉTODO DE PAGAMENTO") + .font(AppTypography.overline) + .foregroundStyle(sectionTitleColor) + + VStack(alignment: .leading, spacing: 0) { + if showPaymentModeToggle { + // Toggle row + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text(useInAppPayment ? "Pagar Pelo App" : "Pagar Na Maquininha") + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + Text(useInAppPayment ? "Mais rápido e seguro" : "Na entrega/retirada com a loja") + .font(.caption) + .foregroundStyle(AppColors.textMuted) + } + Spacer() + Toggle("", isOn: Binding( + get: { useInAppPayment }, + set: { newValue in + withAnimation(.easeInOut(duration: 0.2)) { + useInAppPayment = newValue + if newValue { + if availableInAppPaymentMethods.contains(paymentMethod) == false { + paymentMethod = .pix + } + } else { + guard availableStoreMachineMethods.isEmpty == false else { + useInAppPayment = true + return + } + if availableStoreMachineMethods.contains(paymentMethod) == false, + let first = availableStoreMachineMethods.first { + paymentMethod = first + } + } + } + } + )) + .labelsHidden() + .tint(AppColors.primary) + } + .padding(14) + + Divider() + .padding(.horizontal, 14) + } + + // Methods list + if useInAppPayment { + ForEach(availableInAppPaymentMethods, id: \.rawValue) { method in + paymentRow(method, isInAppGroup: true) + if method != availableInAppPaymentMethods.last { + Divider().padding(.horizontal, 14) + } + } + } else { + if availableStoreMachineMethods.isEmpty { + Text("Loja não informou métodos presenciais.") + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + .padding(14) + } else { + ForEach(availableStoreMachineMethods, id: \.rawValue) { method in + paymentRow(method, isInAppGroup: false) + if method != availableStoreMachineMethods.last { + Divider().padding(.horizontal, 14) + } + } + } + } + } + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + } + + private func paymentRow(_ method: CheckoutPaymentMethod, isInAppGroup: Bool) -> some View { + let subtitle = paymentSubtitle(for: method, isInAppGroup: isInAppGroup) + return Button { + paymentMethod = method + } label: { + HStack(spacing: 12) { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(AppColors.backgroundLight) + .frame(width: 54, height: 54) + .overlay( + Group { + if method == .pix { + Image("icon_pix") + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + } else { + Image(systemName: method.iconName) + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(AppColors.textPrimary) + } + } + ) + + VStack(alignment: .leading, spacing: 3) { + Text(method.label) + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + if let subtitle { + Text(subtitle) + .font(.caption) + .foregroundStyle(AppColors.textMuted) + } + } + Spacer() + + Circle() + .stroke(paymentMethod == method ? AppColors.tertiary : AppColors.textMuted.opacity(0.45), lineWidth: 2) + .frame(width: 24, height: 24) + .background( + Circle() + .fill(paymentMethod == method ? AppColors.tertiary : Color.clear) + ) + } + .padding(14) + } + .buttonStyle(.plain) + } + + private func paymentSubtitle(for method: CheckoutPaymentMethod, isInAppGroup: Bool) -> String? { + if isInAppGroup == false { + if method == .pix { + return "Pagamento presencial (QR da loja)" + } + return nil + } + return method.subtitle + } + + + private var bottomBar: some View { + VStack(spacing: 12) { + HStack { + Text("Total a pagar") + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textMuted) + Spacer() + Text(formatCurrency(totalValue)) + .font(AppTypography.heading1) + .foregroundStyle(AppColors.textPrimary) + } + + Button { + Task { await handleConfirmPaymentTap() } + } label: { + HStack(spacing: 10) { + Text("Confirmar e Pagar") + .font(AppTypography.heading2) + Image(systemName: "checkmark") + .font(.system(size: 18, weight: .bold)) + } + .foregroundStyle(AppColors.textInverse) + .frame(maxWidth: .infinity, minHeight: 54) + .background(AppColors.primary) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + .buttonStyle(.plain) + .disabled(canConfirmPayment == false || isValidatingAddress || isSubmittingOrder) + .opacity((canConfirmPayment && isValidatingAddress == false && isSubmittingOrder == false) ? 1 : 0.65) + } + .padding(.horizontal, 20) + .padding(.top, 10) + .padding(.bottom, UIDevice.bottomNotch + 40) + .background(AppColors.surface.opacity(0.98)) + } + + private func summaryRow(_ title: String, _ value: String, valueColor: Color? = nil) -> some View { + HStack { + Text(title) + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + Spacer() + Text(value) + .font(AppTypography.heading3) + .foregroundStyle(valueColor ?? AppColors.textMuted) + } + } + + private var customerAddressName: String { + // appState.address.display is set directly, synchronously, the + // moment the user picks an address (AddressesView.selectAddress) — + // it's live. selectedCustomerAddress is resolved later via an async + // matching cascade against the address book and can briefly (or, + // if matching goes wrong, persistently) lag behind or resolve to + // the wrong entry. Prefer the live value; the async one is only a + // fallback for when nothing's been picked yet this session. + let displayLabel = appState.address.display.trimmingCharacters(in: .whitespacesAndNewlines) + if displayLabel.isEmpty == false, displayLabel != "Defina seu endereco" { return displayLabel } + + let label = (selectedCustomerAddress?.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if label.isEmpty == false { return label } + + return "Endereço" + } + + private var customerAddressLabel: String { + if let address = selectedCustomerAddress { + let street = (address.address ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let number = (address.number ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let neighborhood = (address.neighborhood ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let city = (address.city ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let state = (address.state ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let firstLine = [street, number].filter { $0.isEmpty == false }.joined(separator: ", ") + let secondLine = [neighborhood, city, state].filter { $0.isEmpty == false }.joined(separator: " - ") + let joined = [firstLine, secondLine].filter { $0.isEmpty == false }.joined(separator: "\n") + if joined.isEmpty == false { return joined } + } + + let value = appState.address.display.trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? "Defina seu endereço" : value + } + + private var addressSectionTitle: String { + if isDeliveryMode { + return "ENDEREÇO DE ENTREGA" + } + let storeName = (appState.cart.storeName ?? "LOJA") + .trimmingCharacters(in: .whitespacesAndNewlines) + .uppercased() + return "ENDEREÇO DE \(storeName)" + } + + private var storeAddressLabel: String { + guard let address = storeInfo?.address else { return "Endereço da loja indisponível" } + let street = (address.street ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let number = (address.number ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let neighborhood = (address.neighborhood ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let city = (address.city ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let state = (address.state ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let firstLine = [street, number].filter { $0.isEmpty == false }.joined(separator: ", ") + let secondLine = [neighborhood, city, state].filter { $0.isEmpty == false }.joined(separator: " - ") + let joined = [firstLine, secondLine].filter { $0.isEmpty == false }.joined(separator: "\n") + return joined.isEmpty ? "Endereço da loja indisponível" : joined + } + + private var deliveryFeeLabel: String { + if isDeliveryMode == false { + return formatCurrency(0) + } + if let baseDeliveryFee { + return formatCurrency(baseDeliveryFee) + } + return "Calculando..." + } + +} + +struct PixPaymentContext: Identifiable, Hashable { + let id: String + let orderId: String + let shortId: String? + let storeId: String + let copyPaste: String + let qrCodeImageBase64: String? + let expirationDate: String? + // Para troca de pagamento + let total: Double + let profileName: String + let profileEmail: String + let profilePhone: String + let addressZip: String? + let addressNumber: String? + let deliveryType: String + let itemsJSON: String +} + +struct OrderTrackingContext: Identifiable, Hashable { + var id: String { orderId } + let orderId: String + let shortId: String? +} + +struct CardPaymentContext: Identifiable, Hashable { + let id: String + let storeId: String + let total: Double + let deliveryType: String + let profileName: String + let profileEmail: String + let profilePhone: String + let addressStreet: String? + let addressNumber: String? + let addressNeighborhood: String? + let addressCity: String? + let addressState: String? + let addressZip: String? + let addressComplement: String? + let itemsJSON: String + + static func build(storeId: String, total: Double, deliveryType: String, profile: ProfileState, address: CustomerAddress?, items: [CreateOrderItemPayload]) -> CardPaymentContext { + let itemsData = (try? JSONEncoder().encode(items)).flatMap { String(data: $0, encoding: .utf8) } ?? "[]" + return CardPaymentContext( + id: UUID().uuidString, + storeId: storeId, + total: total, + deliveryType: deliveryType, + profileName: profile.name, + profileEmail: profile.email, + profilePhone: profile.phone, + addressStreet: address?.address, + addressNumber: address?.number, + addressNeighborhood: address?.neighborhood, + addressCity: address?.city, + addressState: address?.state, + addressZip: address?.zipCode, + addressComplement: address?.complement, + itemsJSON: itemsData + ) + } + + var addressPayload: CreateOrderAddressPayload? { + guard let street = addressStreet, street.isEmpty == false, + let number = addressNumber, number.isEmpty == false, + let neighborhood = addressNeighborhood, neighborhood.isEmpty == false else { return nil } + return CreateOrderAddressPayload(street: street, number: number, neighborhood: neighborhood, city: addressCity, state: addressState, zip: addressZip, complement: addressComplement) + } + + var orderItems: [CreateOrderItemPayload] { + guard let data = itemsJSON.data(using: .utf8), + let decoded = try? JSONDecoder().decode([CreateOrderItemPayload].self, from: data) else { return [] } + return decoded + } +} + +struct PaymentPixView: View { + let context: PixPaymentContext + @Binding var appState: AppState + var onPaymentConfirmed: (() -> Void)? = nil + var onOpenTracking: (() -> Void)? = nil + @Environment(\.dismiss) var dismiss + @State var tracker = OrderRealtimeTracker() + @State var latestOrder: PublicOrderResult? = nil + @State var hasOpenedTracking = false + @State var hasShownPixExpiredSnackbar = false + @State var currentTime = Date() + @State var showChangePaymentSheet = false + @State var savedCards: [SavedCard] = [] + @State var currentContext: PixPaymentContext + + init(context: PixPaymentContext, appState: Binding, onPaymentConfirmed: (() -> Void)? = nil, onOpenTracking: (() -> Void)? = nil) { + self.context = context + self._appState = appState + self.onPaymentConfirmed = onPaymentConfirmed + self.onOpenTracking = onOpenTracking + _currentContext = State(initialValue: context) + } + + private var qrImageSource: String? { + guard let raw = currentContext.qrCodeImageBase64?.trimmingCharacters(in: .whitespacesAndNewlines), + raw.isEmpty == false else { return nil } + if raw.lowercased().hasPrefix("data:image") { return raw } + return "data:image/png;base64,\(raw)" + } + + var body: some View { + LCENavigationView { + content + } + .setLeftButton(image: AnyView(AppBackButtonIcon())) { dismiss() } + .setTitle(text: Text("Pagamento via PIX").font(AppTypography.heading2).foregroundStyle(AppColors.textPrimary)) + .setNavigationBarBackgroundColor(AppColors.backgroundLight) + .buttonStyle(.plain) + .sheet(isPresented: $showChangePaymentSheet) { + ChangePaymentSheet( + pixContext: currentContext, + savedCards: savedCards, + appState: $appState, + onChanged: { newContext in + showChangePaymentSheet = false + if let newContext { + currentContext = newContext + tracker.stop() + tracker.start(orderId: newContext.orderId, jwt: DefaultTokenStore().jwt) + } + }, + onConfirmed: { + showChangePaymentSheet = false + onPaymentConfirmed?() + openTrackingOnce() + } + ) + } + .task { + tracker.onOrderUpdated = { updated in + latestOrder = updated + if updated.isPaymentConfirmed { + onPaymentConfirmed?() + openTrackingOnce() + } + } + tracker.start(orderId: currentContext.orderId, jwt: DefaultTokenStore().jwt) + } + .task { + while Task.isCancelled == false { + currentTime = Date() + if isPixExpired { + showPixExpiredSnackbar() + return + } + try? await Task.sleep(nanoseconds: 1_000_000_000) + } + } + .onDisappear { + tracker.stop() + } + } + + private var content: some View { + ScrollView(showsIndicators: false) { + VStack(spacing: 14) { + RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous) + .fill(AppColors.surface) + .overlay( + VStack(spacing: 10) { + Text("Escaneie o QR Code abaixo ou copie o código para pagar no seu aplicativo do banco.") + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + .multilineTextAlignment(.center) + .padding(.horizontal, 12) + .padding(.top, 14) + + AsyncStoreImage(imageURL: qrImageSource) + .frame(width: 220, height: 220) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(AppColors.tertiary.opacity(0.35), lineWidth: 2) + ) + + Text("AGUARDANDO PAGAMENTO") + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + .padding(.horizontal, 20) + .padding(.vertical, 8) + .background(AppColors.brandSoft) + .clipShape(Capsule()) + } + ) + .frame(maxWidth: .infinity, minHeight: 380) + + Text("Código PIX") + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + + RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous) + .fill(AppColors.surface) + .frame(height: 20) + .overlay( + VStack(spacing: 8) { + Text(currentContext.copyPaste) + .font(.system(size: 12, weight: .medium, design: .monospaced)) + .foregroundStyle(AppColors.textMuted) + .lineLimit(0) + .multilineTextAlignment(.center) + .padding(.horizontal, 10) + + } + .padding(.vertical, 14) + ) + + if let expirationDate = currentContext.expirationDate, expirationDate.isEmpty == false { + Text(expirationLabel) + .font(.caption) + .foregroundStyle(isPixExpired ? Color.red : AppColors.textMuted) + } + + HStack(spacing: 10) { + PrimaryButton(title: "Copiar Código PIX") { + if isPixExpired { + showPixExpiredSnackbar() + return + } + copyToClipboard(currentContext.copyPaste) + SnackbarCenter.shared.show(title: "Código PIX copiado.", style: .success, icon: "doc.on.doc.fill", duration: 2.0) + } + .disabled(isPixExpired) + .opacity(isPixExpired ? 0.5 : 1.0) + + Button { + Task { await loadSavedCards() } + showChangePaymentSheet = true + } label: { + HStack(spacing: 6) { + Text("Trocar") + .font(AppTypography.heading3) + Image(systemName: "chevron.down") + .font(.system(size: 12, weight: .semibold)) + } + .foregroundStyle(AppColors.textPrimary) + .frame(height: 54) + .padding(.horizontal, 16) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + .buttonStyle(.plain) + } + .padding(.top, 10) + } + .padding(20) + } + .background(AppColors.backgroundLight) + } + + private func openTrackingOnce() { + guard hasOpenedTracking == false else { return } + hasOpenedTracking = true + onOpenTracking?() + } + + private func copyToClipboard(_ value: String) { + appWriteClipboardText(value) + } + + @MainActor + private func loadSavedCards() async { + guard savedCards.isEmpty else { return } + if let cards = try? await ApiService().listCards().result { + savedCards = cards + } + } + + private var parsedExpirationDate: Date? { + let raw = (currentContext.expirationDate ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + guard raw.isEmpty == false else { return nil } + + let iso = ISO8601DateFormatter() + iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = iso.date(from: raw) { return date } + iso.formatOptions = [.withInternetDateTime] + if let date = iso.date(from: raw) { return date } + + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "pt_BR") + let formats = [ + "yyyy-MM-dd HH:mm:ss", + "yyyy-MM-dd'T'HH:mm:ss", + "yyyy-MM-dd'T'HH:mm:ss.SSSZ", + "dd/MM/yyyy HH:mm:ss", + "dd/MM/yyyy HH:mm" + ] + for format in formats { + formatter.dateFormat = format + if let date = formatter.date(from: raw) { + return date + } + } + + return nil + } + + private var isPixExpired: Bool { + guard let parsedExpirationDate else { return false } + return currentTime >= parsedExpirationDate + } + + private var expirationLabel: String { + guard let parsedExpirationDate else { + return "Expira em: --" + } + if isPixExpired { + return "Expirado" + } + + let remaining = max(0, Int(parsedExpirationDate.timeIntervalSince(currentTime))) + let day = 24 * 60 * 60 + let hour = 60 * 60 + + if remaining >= day { + let days = remaining / day + return "Expira em: \(days) dia(s)" + } + if remaining >= hour { + let hours = remaining / hour + return "Expira em: \(hours) hora(s)" + } + if remaining >= 60 { + let minutes = remaining / 60 + return "Expira em: \(minutes) min" + } + return "Vai expirar em \(remaining) segundos" + } + + private func showPixExpiredSnackbar() { + guard hasShownPixExpiredSnackbar == false else { return } + hasShownPixExpiredSnackbar = true + SnackbarCenter.shared.show( + title: "PIX expirou. Gere um novo pedido para continuar.", + style: .warning, + icon: "clock.badge.xmark.fill", + duration: 4.0 + ) + } +} + +// MARK: - CardSelectionSheet + +struct CardSelectionSheet: View { + let cardContext: CardPaymentContext + @Binding var appState: AppState + let onSavedCardConfirmed: (String) -> Void + let onOrderCreated: (String, String?) -> Void + + @Environment(\.dismiss) var dismiss + @State private var cards: [SavedCard] = [] + @State private var isLoading = false + @State var selectedCardId: String? + @State var isSubmitting = false + @State var showNewCardSheet = false + + init(cardContext: CardPaymentContext, appState: Binding, onSavedCardConfirmed: @escaping (String) -> Void, onOrderCreated: @escaping (String, String?) -> Void) { + self.cardContext = cardContext + self._appState = appState + self.onSavedCardConfirmed = onSavedCardConfirmed + self.onOrderCreated = onOrderCreated + } + + var body: some View { + ZStack(alignment: .top) { + VStack(spacing: 0) { + // Header + HStack { + Text("Selecionar Cartão") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + Spacer() + Button { + dismiss() + } label: { + Image(systemName: "xmark") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(AppColors.textMuted) + .frame(width: 30, height: 30) + .background(AppColors.surface) + .clipShape(Circle()) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 20) + .padding(.top, 20) + .padding(.bottom, 16) + + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 16) { + if isLoading { + ProgressView() + .frame(maxWidth: .infinity) + .padding(.top, 32) + } else if cards.isEmpty == false { + VStack(alignment: .leading, spacing: 0) { + ForEach(cards) { card in + savedCardRow(card) + if card.id != cards.last?.id { + Divider().padding(.horizontal, 14) + } + } + } + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + + if let selectedCardId { + PrimaryButton(title: isSubmitting ? "Processando..." : "Pagar \(formatCurrency(cardContext.total))") { + guard isSubmitting == false else { return } + onSavedCardConfirmed(selectedCardId) + } + .disabled(isSubmitting) + } + + Text("ou") + .font(AppTypography.caption) + .foregroundStyle(AppColors.textMuted) + .frame(maxWidth: .infinity, alignment: .center) + } + + Button { + showNewCardSheet = true + } label: { + HStack(spacing: 10) { + Image(systemName: "plus.circle.fill") + .font(.system(size: 18)) + .foregroundStyle(AppColors.primary) + Text("Adicionar novo cartão") + .font(AppTypography.heading3) + .foregroundStyle(AppColors.primary) + Spacer() + } + .padding(14) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 20) + .padding(.bottom, 32) + } + } + .background(AppColors.backgroundLight) + .sheet(isPresented: $showNewCardSheet) { + PaymentCardView( + context: cardContext, + appState: $appState, + onOrderCreated: { orderId, shortId in + showNewCardSheet = false + onOrderCreated(orderId, shortId) + } + ) + } + + SnackbarOverlay(center: SnackbarCenter.shared) + } + .task { await fetchCards() } + } + + @MainActor + private func fetchCards() async { + isLoading = true + defer { isLoading = false } + if let result = try? await ApiService().listCards(), result.error == false, let fetched = result.result { + cards = fetched + selectedCardId = fetched.first(where: { $0.isDefault })?.id ?? fetched.first?.id + } + } + + private func savedCardRow(_ card: SavedCard) -> some View { + Button { + selectedCardId = card.id + } label: { + HStack(spacing: 12) { + Image(systemName: "creditcard.fill") + .font(.system(size: 20)) + .foregroundStyle(AppColors.textPrimary) + .frame(width: 44, height: 44) + .background(AppColors.backgroundLight) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + + VStack(alignment: .leading, spacing: 3) { + Text(card.displayLabel) + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + Text("Vence \(card.expiryLabel)") + .font(.caption) + .foregroundStyle(AppColors.textMuted) + } + Spacer() + Circle() + .stroke(selectedCardId == card.id ? AppColors.tertiary : AppColors.textMuted.opacity(0.45), lineWidth: 2) + .frame(width: 22, height: 22) + .background(Circle().fill(selectedCardId == card.id ? AppColors.tertiary : Color.clear)) + } + .padding(14) + } + .buttonStyle(.plain) + } + + private func formatCurrency(_ value: Double) -> String { + String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",") + } +} + +// MARK: - PaymentCardView + +struct PaymentCardView: View { + let context: CardPaymentContext + @Binding var appState: AppState + let onOrderCreated: (String, String?) -> Void + + @Environment(\.dismiss) var dismiss + @State var holderName: String + @State var cardNumber = "" + @State var expiry = "" + @State var cvv = "" + @State var cpf: String + @State var saveCard = false + @State var nickname = "" + @State var isSubmitting = false + + private var detectedBrandLogo: String? { + let clean = cardNumber.filter(\.isNumber) + guard !clean.isEmpty else { return nil } + + // Sodexo + if clean.hasPrefix("506766") || clean.hasPrefix("603389") { + return "sodexo_logo" + } + + // Alelo + if clean.hasPrefix("5067") || clean.hasPrefix("5078") || clean.hasPrefix("6278") || clean.hasPrefix("6367") { + return "alelocard_logo" + } + + // Hipercard + if clean.hasPrefix("3841") || clean.hasPrefix("606282") || clean.hasPrefix("637095") || clean.hasPrefix("637568") { + return "hipercard_logo" + } + + // Visa + if clean.hasPrefix("4") { + return "visacard_logo" + } + + // Mastercard + if (51...55).contains(Int(clean.prefix(2)) ?? 0) { + return "mastercard_logo" + } + if let prefix4 = Int(clean.prefix(4)), (2221...2720).contains(prefix4) { + return "mastercard_logo" + } + + // Amex + if clean.hasPrefix("34") || clean.hasPrefix("37") { + return "amexcard_logo" + } + + return nil + } + + init(context: CardPaymentContext, appState: Binding, onOrderCreated: @escaping (String, String?) -> Void) { + self.context = context + self._appState = appState + self.onOrderCreated = onOrderCreated + _holderName = State(initialValue: context.profileName) + _cpf = State(initialValue: appState.wrappedValue.profile.cpf) + } + + private func validateExpiryDate() -> Bool { + let clean = expiry.filter(\.isNumber) + guard clean.count == 4 || clean.count == 6 else { return false } + let monthStr = String(clean.prefix(2)) + guard let month = Int(monthStr), (1...12).contains(month) else { return false } + return true + } + + var canSubmit: Bool { + holderName.trimmingCharacters(in: .whitespaces).isEmpty == false && + cardNumber.filter(\.isNumber).count >= 13 && + validateExpiryDate() && + cvv.filter(\.isNumber).count >= 3 && + cpf.filter(\.isNumber).count == 11 + } + + var body: some View { + ZStack(alignment: .top) { + VStack(spacing: 0) { + HStack { + Text("Novo Cartão") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + Spacer() + Button { + dismiss() + } label: { + Image(systemName: "xmark") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(AppColors.textMuted) + .frame(width: 30, height: 30) + .background(AppColors.surface) + .clipShape(Circle()) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 20) + .padding(.top, 20) + .padding(.bottom, 16) + + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 16) { + // Total + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Total do Pedido") + .font(AppTypography.caption) + .foregroundStyle(AppColors.textMuted) + .textCase(nil) + Text(formatCurrency(context.total)) + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + } + Spacer() + } + .padding(14) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + + // Dados do cartão + VStack(alignment: .leading, spacing: 0) { + sectionHeader("DADOS DO CARTÃO") + VStack(spacing: 10) { + labeledField("Número do Cartão", placeholder: "0000 0000 0000 0000", text: $cardNumber, brandLogo: detectedBrandLogo) + .keyboardType(.numberPad) + .onChange(of: cardNumber) { _, v in + let d = String(v.filter(\.isNumber).prefix(16)) + let masked = stride(from: 0, to: d.count, by: 4) + .map { i -> String in + let start = d.index(d.startIndex, offsetBy: i) + let end = d.index(start, offsetBy: min(4, d.count - i)) + return String(d[start.. { + do { _ = try await ApiService().updateProfileCpf(cpf: cleanCpf) } catch {} + } + } + + // Optionally save card + if saveCard { + let savePayload = SaveCardPayload( + creditCard: SaveCardCreditCardPayload(holderName: holderName, number: cleanCardNumber, expiryMonth: expiryMonth, expiryYear: expiryYear, ccv: cvv), + creditCardHolderInfo: holderInfo, + nickname: nickname.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : nickname, + isDefault: false + ) + _ = try await ApiService().saveCard(payload: savePayload) + } + + let response = try await ApiService().createOrder(storeId: context.storeId, payload: payload) + if response.error { + SnackbarCenter.shared.show(title: response.message ?? "Pagamento recusado.", style: .error, icon: "xmark.octagon.fill", duration: 3.5) + return + } + guard let result = response.result else { return } + let orderId = result.id ?? UUID().uuidString + onOrderCreated(orderId, result.shortId) + } catch let error as NetworkError { + let message: String + if case .httpError(_, let serverMessage) = error, let serverMessage, serverMessage.isEmpty == false { + message = serverMessage + } else { + message = error.errorDescription ?? "Não foi possível processar o pagamento." + } + SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 5.0) + } catch { + SnackbarCenter.shared.show(title: "Não foi possível processar o pagamento.", style: .error, icon: "xmark.octagon.fill", duration: 3.0) + } + } + + private func sectionHeader(_ text: String) -> some View { + Text(text) + .font(AppTypography.overline) + .foregroundStyle(AppColors.textMuted) + .padding(.horizontal, 14) + .padding(.top, 12) + .padding(.bottom, 4) + } + + private func labeledField(_ label: String, placeholder: String, text: Binding, brandLogo: String? = nil) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(label) + .font(AppTypography.caption) + .foregroundStyle(AppColors.textMuted) + HStack(spacing: 8) { + TextField(placeholder, text: text) + .appNoAutoCap() + .foregroundStyle(AppColors.brandDark) + if let brandLogo, brandLogo.isEmpty == false { + Image(brandLogo) + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + } + } + .padding(.horizontal, 12) + .frame(height: 46) + .background(AppColors.backgroundLight) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + } + } + + private func formatCurrency(_ value: Double) -> String { + String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",") + } +} + +// MARK: - ChangePaymentSheet + +struct ChangePaymentSheet: View { + let pixContext: PixPaymentContext + let savedCards: [SavedCard] + @Binding var appState: AppState + let onChanged: (PixPaymentContext?) -> Void + let onConfirmed: () -> Void + + @Environment(\.dismiss) var dismiss + @State var isSubmitting = false + @State var showCardSheet = false + + var body: some View { + VStack(spacing: 0) { + HStack { + Text("Trocar Pagamento") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + Spacer() + Button { dismiss() } label: { + Image(systemName: "xmark") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(AppColors.textMuted) + .frame(width: 30, height: 30) + .background(AppColors.surface) + .clipShape(Circle()) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 20) + .padding(.top, 20) + .padding(.bottom, 16) + + ScrollView(showsIndicators: false) { + VStack(spacing: 12) { + optionRow(icon: "qrcode", title: "Novo QR Code PIX", subtitle: "Gera um novo código PIX") { + Task { await changeToMethod("PIX") } + } + optionRow(icon: "creditcard.fill", title: "Cartão de Crédito", subtitle: "Débito imediato no cartão") { + showCardSheet = true + } + optionRow(icon: "banknote.fill", title: "Pagar na Entrega", subtitle: "Pague ao receber o pedido") { + Task { await changeToMethod("CASH") } + } + } + .padding(.horizontal, 20) + .padding(.bottom, 32) + } + } + .background(AppColors.backgroundLight) + .sheet(isPresented: $showCardSheet) { + let items = (try? JSONDecoder().decode([CreateOrderItemPayload].self, from: pixContext.itemsJSON.data(using: .utf8) ?? Data())) ?? [] + let cardCtx = CardPaymentContext.build(storeId: pixContext.storeId, total: pixContext.total, deliveryType: pixContext.deliveryType, profile: appState.profile, address: nil, items: items) + ChangePaymentCardSheet(pixContext: pixContext, cardContext: cardCtx, savedCards: savedCards, appState: $appState) { _, _ in + showCardSheet = false + onConfirmed() + } + } + } + + private func optionRow(icon: String, title: String, subtitle: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + HStack(spacing: 14) { + Image(systemName: icon).font(.system(size: 20)).foregroundStyle(AppColors.primary) + .frame(width: 44, height: 44).background(AppColors.brandSoft) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + VStack(alignment: .leading, spacing: 3) { + Text(title).font(AppTypography.heading3).foregroundStyle(AppColors.textPrimary) + Text(subtitle).font(.caption).foregroundStyle(AppColors.textMuted) + } + Spacer() + if isSubmitting { ProgressView().scaleEffect(0.8) } + else { Image(systemName: "chevron.right").font(.system(size: 12, weight: .semibold)).foregroundStyle(AppColors.textMuted) } + } + .padding(14).background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + .buttonStyle(.plain).disabled(isSubmitting) + } + + @MainActor + private func changeToMethod(_ method: String) async { + isSubmitting = true + defer { isSubmitting = false } + let payload = ChangePaymentMethodPayload(paymentMethod: method, clientCpfCnpj: nil, creditCard: nil, creditCardHolderInfo: nil, savedCardId: nil) + do { + let response = try await ApiService().changePaymentMethod(storeId: pixContext.storeId, orderId: pixContext.orderId, payload: payload) + if response.error { + SnackbarCenter.shared.show(title: response.message ?? "Não foi possível trocar o pagamento.", style: .error, icon: "xmark.octagon.fill", duration: 3.5) + return + } + guard let result = response.result else { return } + if method == "CASH" { + SnackbarCenter.shared.show(title: "Pagamento alterado. Pague ao receber.", style: .success, icon: "checkmark.circle.fill", duration: 3.0) + onConfirmed(); return + } + let pix = result.payment?.pix ?? result.paymentPayload + if let copyPaste = pix?.copyPaste, copyPaste.isEmpty == false { + let newCtx = PixPaymentContext(id: UUID().uuidString, orderId: pixContext.orderId, shortId: pixContext.shortId, storeId: pixContext.storeId, copyPaste: copyPaste, qrCodeImageBase64: pix?.qrCodeImage, expirationDate: pix?.expirationDate, total: pixContext.total, profileName: pixContext.profileName, profileEmail: pixContext.profileEmail, profilePhone: pixContext.profilePhone, addressZip: pixContext.addressZip, addressNumber: pixContext.addressNumber, deliveryType: pixContext.deliveryType, itemsJSON: pixContext.itemsJSON) + SnackbarCenter.shared.show(title: "Novo QR Code gerado.", style: .success, icon: "qrcode", duration: 2.5) + onChanged(newCtx) + } + } catch let error as NetworkError { + let msg: String + if case .httpError(_, let m) = error, let m, m.isEmpty == false { msg = m } else { msg = error.errorDescription ?? "Erro ao trocar pagamento." } + SnackbarCenter.shared.show(title: msg, style: .error, icon: "xmark.octagon.fill", duration: 4.0) + } catch { + SnackbarCenter.shared.show(title: "Erro ao trocar pagamento.", style: .error, icon: "xmark.octagon.fill", duration: 3.0) + } + } +} + +// MARK: - ChangePaymentCardSheet + +struct ChangePaymentCardSheet: View { + let pixContext: PixPaymentContext + let cardContext: CardPaymentContext + let savedCards: [SavedCard] + @Binding var appState: AppState + let onConfirmed: (String, String?) -> Void + + @Environment(\.dismiss) var dismiss + @State var selectedCardId: String? + @State var showNewCardForm = false + @State var isSubmitting = false + + init(pixContext: PixPaymentContext, cardContext: CardPaymentContext, savedCards: [SavedCard], appState: Binding, onConfirmed: @escaping (String, String?) -> Void) { + self.pixContext = pixContext; self.cardContext = cardContext; self.savedCards = savedCards + self._appState = appState; self.onConfirmed = onConfirmed + _selectedCardId = State(initialValue: savedCards.first(where: { $0.isDefault })?.id ?? savedCards.first?.id) + } + + var body: some View { + ZStack(alignment: .top) { + VStack(spacing: 0) { + HStack { + Text("Cartão de Crédito").font(AppTypography.heading2).foregroundStyle(AppColors.textPrimary) + Spacer() + Button { dismiss() } label: { + Image(systemName: "xmark").font(.system(size: 12, weight: .bold)).foregroundStyle(AppColors.textMuted) + .frame(width: 30, height: 30).background(AppColors.surface).clipShape(Circle()) + }.buttonStyle(.plain) + } + .padding(.horizontal, 20).padding(.top, 20).padding(.bottom, 16) + + ScrollView(showsIndicators: false) { + VStack(spacing: 16) { + if savedCards.isEmpty == false { + VStack(spacing: 0) { + ForEach(savedCards) { card in + Button { selectedCardId = card.id } label: { + HStack(spacing: 12) { + Image(systemName: "creditcard.fill").font(.system(size: 18)).foregroundStyle(AppColors.textPrimary) + .frame(width: 44, height: 44).background(AppColors.backgroundLight) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + VStack(alignment: .leading, spacing: 3) { + Text(card.displayLabel).font(AppTypography.heading3).foregroundStyle(AppColors.textPrimary) + Text("Vence \(card.expiryLabel)").font(.caption).foregroundStyle(AppColors.textMuted) + } + Spacer() + Circle().stroke(selectedCardId == card.id ? AppColors.tertiary : AppColors.textMuted.opacity(0.45), lineWidth: 2) + .frame(width: 22, height: 22).background(Circle().fill(selectedCardId == card.id ? AppColors.tertiary : Color.clear)) + }.padding(14) + }.buttonStyle(.plain) + if card.id != savedCards.last?.id { Divider().padding(.horizontal, 14) } + } + } + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + + if let selectedCardId { + PrimaryButton(title: isSubmitting ? "Processando..." : "Pagar com este Cartão") { + guard isSubmitting == false else { return } + Task { await changeToSavedCard(cardId: selectedCardId) } + }.disabled(isSubmitting) + } + Text("ou").font(AppTypography.caption).foregroundStyle(AppColors.textMuted).frame(maxWidth: .infinity, alignment: .center) + } + Button { showNewCardForm = true } label: { + HStack(spacing: 10) { + Image(systemName: "plus.circle.fill").font(.system(size: 18)).foregroundStyle(AppColors.primary) + Text("Novo cartão").font(AppTypography.heading3).foregroundStyle(AppColors.primary) + Spacer() + } + .padding(14).background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + }.buttonStyle(.plain) + } + .padding(.horizontal, 20).padding(.bottom, 32) + } + } + .background(AppColors.backgroundLight) + .sheet(isPresented: $showNewCardForm) { + ChangePaymentNewCardView(pixContext: pixContext, appState: $appState) { orderId, shortId in + showNewCardForm = false; onConfirmed(orderId, shortId) + } + } + + SnackbarOverlay(center: SnackbarCenter.shared) + } + } + + @MainActor + private func changeToSavedCard(cardId: String) async { + isSubmitting = true; defer { isSubmitting = false } + let payload = ChangePaymentMethodPayload(paymentMethod: "CREDIT_CARD", clientCpfCnpj: nil, creditCard: nil, creditCardHolderInfo: nil, savedCardId: cardId) + do { + let response = try await ApiService().changePaymentMethod(storeId: pixContext.storeId, orderId: pixContext.orderId, payload: payload) + if response.error { SnackbarCenter.shared.show(title: response.message ?? "Pagamento recusado.", style: .error, icon: "xmark.octagon.fill", duration: 3.5); return } + onConfirmed(pixContext.orderId, pixContext.shortId) + } catch let error as NetworkError { + let msg: String + if case .httpError(_, let m) = error, let m, m.isEmpty == false { msg = m } else { msg = "Pagamento recusado." } + SnackbarCenter.shared.show(title: msg, style: .error, icon: "xmark.octagon.fill", duration: 4.0) + } catch { SnackbarCenter.shared.show(title: "Erro ao processar.", style: .error, icon: "xmark.octagon.fill", duration: 3.0) } + } +} + +// MARK: - ChangePaymentNewCardView + +struct ChangePaymentNewCardView: View { + let pixContext: PixPaymentContext + @Binding var appState: AppState + let onConfirmed: (String, String?) -> Void + + @Environment(\.dismiss) var dismiss + @State var holderName: String + @State var cardNumber = "" + @State var expiry = "" + @State var cvv = "" + @State var cpf: String + @State var saveCard = false + @State var nickname = "" + @State var isSubmitting = false + + private var detectedBrandLogo: String? { + let clean = cardNumber.filter(\.isNumber) + guard !clean.isEmpty else { return nil } + + // Sodexo + if clean.hasPrefix("506766") || clean.hasPrefix("603389") { + return "sodexo_logo" + } + + // Alelo + if clean.hasPrefix("5067") || clean.hasPrefix("5078") || clean.hasPrefix("6278") || clean.hasPrefix("6367") { + return "alelocard_logo" + } + + // Hipercard + if clean.hasPrefix("3841") || clean.hasPrefix("606282") || clean.hasPrefix("637095") || clean.hasPrefix("637568") { + return "hipercard_logo" + } + + // Visa + if clean.hasPrefix("4") { + return "visacard_logo" + } + + // Mastercard + if (51...55).contains(Int(clean.prefix(2)) ?? 0) { + return "mastercard_logo" + } + if let prefix4 = Int(clean.prefix(4)), (2221...2720).contains(prefix4) { + return "mastercard_logo" + } + + // Amex + if clean.hasPrefix("34") || clean.hasPrefix("37") { + return "amexcard_logo" + } + + return nil + } + + init(pixContext: PixPaymentContext, appState: Binding, onConfirmed: @escaping (String, String?) -> Void) { + self.pixContext = pixContext; self._appState = appState; self.onConfirmed = onConfirmed + _holderName = State(initialValue: appState.wrappedValue.profile.name) + _cpf = State(initialValue: appState.wrappedValue.profile.cpf) + } + + private func validateExpiryDate() -> Bool { + let clean = expiry.filter(\.isNumber) + guard clean.count == 4 || clean.count == 6 else { return false } + let monthStr = String(clean.prefix(2)) + guard let month = Int(monthStr), (1...12).contains(month) else { return false } + return true + } + + var canSubmit: Bool { + holderName.trimmingCharacters(in: .whitespaces).isEmpty == false && + cardNumber.filter(\.isNumber).count >= 13 && + validateExpiryDate() && + cvv.filter(\.isNumber).count >= 3 && + cpf.filter(\.isNumber).count == 11 + } + + var body: some View { + ZStack(alignment: .top) { + VStack(spacing: 0) { + HStack { + Text("Novo Cartão").font(AppTypography.heading2).foregroundStyle(AppColors.textPrimary) + Spacer() + Button { dismiss() } label: { + Image(systemName: "xmark").font(.system(size: 12, weight: .bold)).foregroundStyle(AppColors.textMuted) + .frame(width: 30, height: 30).background(AppColors.surface).clipShape(Circle()) + }.buttonStyle(.plain) + } + .padding(.horizontal, 20).padding(.top, 20).padding(.bottom, 16) + + ScrollView(showsIndicators: false) { + VStack(spacing: 16) { + VStack(spacing: 10) { + cardField("Número do Cartão", placeholder: "0000 0000 0000 0000", text: $cardNumber, brandLogo: detectedBrandLogo).keyboardType(.numberPad) + .onChange(of: cardNumber) { _, v in + let d = String(v.filter(\.isNumber).prefix(16)) + let m = stride(from: 0, to: d.count, by: 4).map { i -> String in let s = d.index(d.startIndex, offsetBy: i); let e = d.index(s, offsetBy: min(4, d.count - i)); return String(d[s.. { do { _ = try await ApiService().updateProfileCpf(cpf: cleanCpf) } catch {} } + } + do { + if saveCard { + let sp = SaveCardPayload(creditCard: SaveCardCreditCardPayload(holderName: holderName, number: cleanNumber, expiryMonth: expiryMonth, expiryYear: expiryYear, ccv: cvv), creditCardHolderInfo: holderInfo, nickname: nickname.trimmingCharacters(in: .whitespaces).isEmpty ? nil : nickname, isDefault: false) + _ = try await ApiService().saveCard(payload: sp) + } + let response = try await ApiService().changePaymentMethod(storeId: pixContext.storeId, orderId: pixContext.orderId, payload: payload) + if response.error { SnackbarCenter.shared.show(title: response.message ?? "Pagamento recusado.", style: .error, icon: "xmark.octagon.fill", duration: 4.0); return } + onConfirmed(pixContext.orderId, pixContext.shortId) + } catch let error as NetworkError { + let msg: String + if case .httpError(_, let m) = error, let m, m.isEmpty == false { msg = m } else { msg = error.errorDescription ?? "Erro ao processar." } + SnackbarCenter.shared.show(title: msg, style: .error, icon: "xmark.octagon.fill", duration: 5.0) + } catch { SnackbarCenter.shared.show(title: "Erro ao processar pagamento.", style: .error, icon: "xmark.octagon.fill", duration: 3.0) } + } + + private func cardField(_ label: String, placeholder: String, text: Binding, brandLogo: String? = nil) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(label).font(AppTypography.caption).foregroundStyle(AppColors.textMuted) + HStack(spacing: 8) { + TextField(placeholder, text: text).appNoAutoCap() + .foregroundStyle(AppColors.brandDark) + if let brandLogo, brandLogo.isEmpty == false { + Image(brandLogo) + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + } + } + .padding(.horizontal, 12).frame(height: 46).background(AppColors.backgroundLight) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + } + } +} diff --git a/PediFoods/Views/Main/FiltersModalView.swift b/PediFoods/Views/Main/FiltersModalView.swift new file mode 100644 index 0000000..ef2613e --- /dev/null +++ b/PediFoods/Views/Main/FiltersModalView.swift @@ -0,0 +1,235 @@ +import SwiftUI + +struct FiltersModalView: View { + @Binding var appState: AppState + @Environment(\.dismiss) var dismiss + @State var draftFilters: HomeFiltersState + + init(appState: Binding) { + _appState = appState + _draftFilters = State(initialValue: appState.wrappedValue.homeFilters) + } + + var body: some View { + VStack(spacing: 0) { + header + + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 28) { + sortSection + categoriesSection + priceSection + distanceSection + } + .padding(.horizontal, 24) + .padding(.top, 22) + .padding(.bottom, 120) + } + + applyButton + .padding(.horizontal, 24) + .padding(.vertical, 18) + .background(AppColors.backgroundLight) + } + .background(AppColors.backgroundLight.ignoresSafeArea()) + } + + var header: some View { + HStack { + Button { + dismiss() + } label: { + Image(systemName: "xmark") + .font(.system(size: 20, weight: .medium)) + .foregroundStyle(AppColors.textPrimary) + .frame(width: 36, height: 36) + } + .buttonStyle(.plain) + + Spacer() + + Text("Filtros") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + + Spacer() + + Button("Limpar") { + draftFilters.reset() + draftFilters.availableCategories = appState.homeFilters.availableCategories + } + .font(AppTypography.heading3) + .foregroundStyle(AppColors.secondary) + .buttonStyle(.plain) + } + .padding(.horizontal, 24) + .padding(.top, 14) + .padding(.bottom, 12) + .overlay(alignment: .bottom) { + Divider().overlay(Color.black.opacity(0.08)) + } + } + + var sortSection: some View { + VStack(alignment: .leading, spacing: 14) { + Text("Ordenar por") + .font(AppTypography.heading1) + .foregroundStyle(AppColors.textPrimary) + + VStack(spacing: 10) { + ForEach(HomeSortOption.allCases) { option in + Button { + draftFilters.sortOption = option + } label: { + HStack(spacing: 14) { + Circle() + .fill(option == draftFilters.sortOption ? AppColors.tertiary : AppColors.surface) + .frame(width: 44, height: 44) + .overlay( + Image(systemName: option.icon) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(option == draftFilters.sortOption ? AppColors.textPrimary : AppColors.textMuted) + ) + + Text(option.title) + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + + Spacer() + + Circle() + .stroke(option == draftFilters.sortOption ? Color.black : Color.black.opacity(0.2), lineWidth: 2) + .frame(width: 28, height: 28) + .overlay( + Circle() + .fill(option == draftFilters.sortOption ? Color.black : Color.clear) + .frame(width: 14, height: 14) + ) + } + .padding(.horizontal, 16) + .frame(height: 88) + .background(Color.black.opacity(0.03)) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + } + .buttonStyle(.plain) + } + } + } + } + + var categoriesSection: some View { + VStack(alignment: .leading, spacing: 14) { + Divider().overlay(Color.black.opacity(0.08)) + + HStack(alignment: .center) { + Text("Categorias") + .font(AppTypography.heading1) + .foregroundStyle(AppColors.textPrimary) + Spacer() + Text("Ver todas") + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textMuted) + } + + LazyVGrid(columns: [GridItem(.adaptive(minimum: 120), spacing: 10)], alignment: .leading, spacing: 10) { + ForEach(draftFilters.availableCategories, id: \.self) { category in + let isSelected = draftFilters.selectedCategories.contains(category) + Button(category) { + if isSelected { + draftFilters.selectedCategories.remove(category) + } else { + draftFilters.selectedCategories.insert(category) + } + } + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + .padding(.horizontal, 20) + .padding(.vertical, 12) + .background(isSelected ? AppColors.tertiary.opacity(0.7) : AppColors.surface) + .overlay( + Capsule().stroke(isSelected ? Color.clear : Color.black.opacity(0.12), lineWidth: 1) + ) + .clipShape(Capsule()) + .buttonStyle(.plain) + } + } + } + } + + var priceSection: some View { + VStack(alignment: .leading, spacing: 14) { + Divider().overlay(Color.black.opacity(0.08)) + + Text("Preço") + .font(AppTypography.heading1) + .foregroundStyle(AppColors.textPrimary) + + HStack(spacing: 12) { + ForEach(HomePriceTier.allCases) { tier in + let isSelected = draftFilters.selectedPriceTier == tier + Button(tier.rawValue) { + draftFilters.selectedPriceTier = isSelected ? nil : tier + } + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + .frame(maxWidth: .infinity) + .frame(height: 68) + .background(isSelected ? AppColors.tertiary.opacity(0.7) : AppColors.surface) + .overlay( + RoundedRectangle(cornerRadius: 24, style: .continuous) + .stroke(isSelected ? Color.black : Color.black.opacity(0.12), lineWidth: isSelected ? 2 : 1) + ) + .clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous)) + .buttonStyle(.plain) + } + } + } + } + + var distanceSection: some View { + VStack(alignment: .leading, spacing: 14) { + Divider().overlay(Color.black.opacity(0.08)) + + HStack { + Text("Distância") + .font(AppTypography.heading1) + .foregroundStyle(AppColors.textPrimary) + Spacer() + Text("Até \(Int(draftFilters.maxDistanceKm))km") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + } + + Slider(value: $draftFilters.maxDistanceKm, in: 1...10, step: 1) + .tint(AppColors.tertiary) + + HStack { + Text("1km") + Spacer() + Text("10km") + } + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textMuted) + } + } + + var applyButton: some View { + Button { + appState.homeFilters.sortOption = draftFilters.sortOption + appState.homeFilters.selectedCategories = draftFilters.selectedCategories + appState.homeFilters.selectedPriceTier = draftFilters.selectedPriceTier + appState.homeFilters.maxDistanceKm = draftFilters.maxDistanceKm + appState.homeFilters.availableCategories = draftFilters.availableCategories + dismiss() + } label: { + Text("Aplicar Filtros") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + .frame(maxWidth: .infinity) + .frame(height: 62) + .background(AppColors.tertiary.opacity(0.7)) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + } + .buttonStyle(.plain) + } +} diff --git a/PediFoods/Views/Main/HomeScrollOffsetObserver.swift b/PediFoods/Views/Main/HomeScrollOffsetObserver.swift new file mode 100644 index 0000000..6dd576d --- /dev/null +++ b/PediFoods/Views/Main/HomeScrollOffsetObserver.swift @@ -0,0 +1,33 @@ +import SwiftUI + +enum HomeScrollCoordinateSpace { + static let name = "home-scroll" +} + +struct HomeScrollOffsetPreferenceKey: PreferenceKey { + static let defaultValue: CGFloat = 0 + + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = nextValue() + } +} + +struct ScrollOffsetObserver: View { + let onOffsetChange: (CGFloat) -> Void + + var body: some View { + Color.clear + .frame(height: 0) + .background( + GeometryReader { geometry in + Color.clear.preference( + key: HomeScrollOffsetPreferenceKey.self, + value: geometry.frame(in: .named(HomeScrollCoordinateSpace.name)).minY + ) + } + ) + .onPreferenceChange(HomeScrollOffsetPreferenceKey.self) { minY in + onOffsetChange(-minY) + } + } +} diff --git a/PediFoods/Views/Main/HomeView+Data.swift b/PediFoods/Views/Main/HomeView+Data.swift new file mode 100644 index 0000000..8405acf --- /dev/null +++ b/PediFoods/Views/Main/HomeView+Data.swift @@ -0,0 +1,199 @@ +import Foundation +import SwiftUI + +extension HomeView { + func buildCategories(from stores: [StoreSummary]) -> [CategoryModel] { + var unique: [CategoryModel] = [ + .init(id: "all", title: "Todas", systemIcon: "line.3.horizontal.decrease.circle", emojiIcon: nil) + ] + var seen = Set() + + for store in stores { + let raw = (store.category ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if raw.isEmpty { continue } + let dedupe = raw.lowercased() + if seen.contains(dedupe) { continue } + seen.insert(dedupe) + unique.append(.init(id: raw, title: raw, systemIcon: categoryIcon(for: raw), emojiIcon: nil)) + } + return unique + } + + @MainActor + func loadHomeCategories(withFallbackStores stores: [StoreSummary], forceRefresh: Bool = false) async { + let cacheKey = "public-categories" + if forceRefresh == false, + let cached: [CategoryModel] = AppContentCache.shared.value(for: cacheKey, as: [CategoryModel].self) { + categories = cached + return + } + + do { + let response = try await ApiService().listPublicCategories(forceRefresh: forceRefresh) + if response.error == false, let remote = response.result, remote.isEmpty == false { + let mapped = mapPublicCategories(remote) + categories = mapped + AppContentCache.shared.set(mapped, for: cacheKey, ttl: AppCacheTTL.twoHours) + return + } + } catch { + // Fallback handled below. + } + + let fallback = buildCategories(from: stores) + categories = fallback + AppContentCache.shared.set(fallback, for: cacheKey, ttl: AppCacheTTL.twoHours) + } + + func mapPublicCategories(_ remote: [PublicCategory]) -> [CategoryModel] { + var mapped: [CategoryModel] = [] + var seen = Set() + + for item in remote { + let id = item.id.trimmingCharacters(in: .whitespacesAndNewlines) + let title = item.name.trimmingCharacters(in: .whitespacesAndNewlines) + if id.isEmpty || title.isEmpty { continue } + if seen.contains(id.lowercased()) { continue } + seen.insert(id.lowercased()) + mapped.append( + .init( + id: id, + title: title, + systemIcon: id.lowercased() == "all" ? "line.3.horizontal.decrease.circle" : nil, + emojiIcon: item.icon + ) + ) + } + + if mapped.contains(where: { $0.id.lowercased() == "all" }) == false { + mapped.insert(.init(id: "all", title: "Todas", systemIcon: "line.3.horizontal.decrease.circle", emojiIcon: nil), at: 0) + } else { + mapped.sort { lhs, rhs in + if lhs.id.lowercased() == "all" { return true } + if rhs.id.lowercased() == "all" { return false } + return lhs.title < rhs.title + } + } + + return mapped + } + + func categoryIcon(for category: String) -> String { + let value = category.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current).lowercased() + if value.contains("pizza") { return "takeoutbag.and.cup.and.straw" } + if value.contains("lanche") || value.contains("hamburg") || value.contains("burger") { return "fork.knife" } + if value.contains("cafe") || value.contains("breakfast") { return "sun.max" } + if value.contains("doce") || value.contains("sobremesa") || value.contains("dessert") { return "cup.and.saucer" } + return "storefront" + } + + @MainActor + func resolveCoordinates(forceRefresh: Bool) async -> (Double, Double)? { + guard hasConfiguredAddress() else { + return nil + } + + // If user selected/saved an address, always trust its coordinates. + // This avoids overriding the chosen city with current device GPS. + if let lat = appState.address.latitude, let lng = appState.address.longitude { + return (lat, lng) + } + + if !forceRefresh, let cached = LocationService.shared.cachedLocation() { + appState.address.latitude = cached.0 + appState.address.longitude = cached.1 + return cached + } + + // Fallback to device location only when no address coordinates are available. + let deviceCoordinate = await LocationService.shared.requestLocationAsync(timeoutSeconds: 3) + if let deviceCoordinate { + appState.address.latitude = deviceCoordinate.0 + appState.address.longitude = deviceCoordinate.1 + } + return deviceCoordinate + } + + func hasConfiguredAddress() -> Bool { + if appState.address.selectedId != nil { + return true + } + + let normalized = appState.address.display + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + + return normalized.isEmpty == false && normalized != "defina seu endereco" + } + + func storesUserMessage(_ error: Error) -> String { + if let service = error as? ApiServiceError { + return service.errorDescription ?? "Não foi possível carregar os estabelecimentos." + } + if let network = error as? NetworkError { + return network.errorDescription ?? "Não foi possível carregar os estabelecimentos." + } + return "Não foi possível carregar os estabelecimentos." + } + + /// Anonymous store loading: no account, no coordinates — just the + /// manually-picked state/city from the public locator. The BFF endpoint + /// has no category filter, so any category chip selection is applied + /// client-side via `filteredStores` (HomeView+Filtering.swift), same as + /// the multi-select filters already do. + @MainActor + func loadGuestStores(hadExistingStores: Bool, category: String?, refreshCategories: Bool) async { + guard let state = GuestLocationStore.shared.selectedState, + let city = GuestLocationStore.shared.selectedCity else { + isLoadingStores = false + stores = [] + storesError = "Escolha um estado e cidade para visualizar os estabelecimentos." + appState.activeModal = .addressPicker + return + } + + do { + let items = try await PublicLocationService.shared.fetchStores(state: state, city: city) + isLoadingStores = false + let mapped = items.map(StoreSummary.init(publicItem:)) + stores = mapped + if refreshCategories || (category == nil && categories.count <= 1) { + await loadHomeCategories(withFallbackStores: mapped, forceRefresh: refreshCategories) + if categories.contains(where: { $0.id == selectedCategory }) == false { + selectedCategory = "all" + } + } + storesError = nil + } catch { + if isCancelledRequest(error) { + isLoadingStores = false + return + } + isLoadingStores = false + reportStoresLoadFailure(storesUserMessage(error), hadExistingStores: hadExistingStores) + } + } +} + +extension StoreSummary { + /// Maps the public-locator DTO onto the same model HomeView already + /// renders — positiveReviews doesn't exist in that response. + init(publicItem: PublicStoreListItem) { + self.id = publicItem.id + self.name = publicItem.name ?? "Loja" + self.logo = publicItem.logo + self.cover = publicItem.cover + self.category = publicItem.category + self.rating = publicItem.rating + self.reviewsCount = publicItem.totalReviews + self.positiveReviews = nil + self.deliveryTime = publicItem.deliveryTime + self.deliveryFee = publicItem.deliveryFee + // The contract uses `0` for "no distance available"; normalize it to + // nil here so every downstream consumer (card label, max-distance + // filter) sees one representation of "unknown". + self.distance = (publicItem.distance ?? 0) > 0 ? publicItem.distance : nil + self.isOpen = publicItem.isOpen + self.statusLabel = publicItem.statusLabel + } +} diff --git a/PediFoods/Views/Main/HomeView+Favorites.swift b/PediFoods/Views/Main/HomeView+Favorites.swift new file mode 100644 index 0000000..b5fcc40 --- /dev/null +++ b/PediFoods/Views/Main/HomeView+Favorites.swift @@ -0,0 +1,42 @@ +import Foundation + +extension HomeView { + @MainActor + func toggleFavoriteStore(storeId: String, storeName: String) async { + guard favoriteRequestStoreIds.contains(storeId) == false else { return } + guard appState.session.isAuthenticated else { + SnackbarCenter.shared.show(title: "Faça login para favoritar lojas.", style: .warning, icon: "person.crop.circle.badge.exclamationmark", duration: 2.5) + return + } + + let isFavorite = appState.favorites.storeIds.contains(storeId) + favoriteRequestStoreIds.insert(storeId) + defer { favoriteRequestStoreIds.remove(storeId) } + + do { + let response = try await ApiService().setStoreFavorite(storeId: storeId, isFavorite: isFavorite == false) + guard response.error == false, let result = response.result else { + let message = response.message ?? "Não foi possível atualizar seus favoritos." + SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 3.5) + return + } + + appState.favorites.storeIds = Set(result.favorites) + let successTitle = isFavorite + ? "\(storeName) removida dos favoritos." + : "\(storeName) adicionada aos favoritos." + let successIcon = isFavorite ? "heart.slash.fill" : "heart.fill" + SnackbarCenter.shared.show(title: successTitle, style: .success, icon: successIcon, duration: 2.2) + } catch { + let message: String + if let networkError = error as? NetworkError { + message = networkError.errorDescription ?? "Não foi possível atualizar seus favoritos." + } else if let serviceError = error as? ApiServiceError { + message = serviceError.errorDescription ?? "Não foi possível atualizar seus favoritos." + } else { + message = "Não foi possível atualizar seus favoritos." + } + SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 3.5) + } + } +} diff --git a/PediFoods/Views/Main/HomeView+Filtering.swift b/PediFoods/Views/Main/HomeView+Filtering.swift new file mode 100644 index 0000000..3cab9f8 --- /dev/null +++ b/PediFoods/Views/Main/HomeView+Filtering.swift @@ -0,0 +1,222 @@ +import Foundation + +extension HomeView { + var filteredStores: [StoreSummary] { + let normalizedQuery = normalizeSearch(searchText) + var list = stores + + if appState.homeFilters.selectedCategories.isEmpty == false { + let allowed = Set(appState.homeFilters.selectedCategories.map(normalizeSearch)) + list = list.filter { store in + let category = normalizeSearch(store.category ?? "") + return allowed.contains(category) + } + } + + if let tier = appState.homeFilters.selectedPriceTier { + list = list.filter { store in + guard let fee = store.deliveryFee else { return false } + return matchesPriceTier(fee: fee, tier: tier) + } + } + + let maxDistance = appState.homeFilters.maxDistanceKm + list = list.filter { store in + guard let distance = store.distance else { return true } + return distance <= maxDistance + } + + if normalizedQuery.isEmpty == false { + list = list.filter { store in + matchesSearch(store: store, query: normalizedQuery) + } + } + + return sortStores(list, query: normalizedQuery) + } + + var featuredStoresCards: [FeaturedStoreCardModel] { + Array(filteredStores.prefix(5)).map(mapStoreToCard) + } + + var filteredStoreCards: [FeaturedStoreCardModel] { + let featuredIds = Set(filteredStores.prefix(5).map(\.id)) + let remaining = filteredStores.filter { featuredIds.contains($0.id) == false } + return Array(remaining.prefix(20)).map(mapStoreToCard) + } + + var emptyResultMessage: String { + if normalizeSearch(searchText).isEmpty == false { + return "Nenhum resultado para \"\(searchText.trimmingCharacters(in: .whitespacesAndNewlines))\"." + } + return "Nenhum estabelecimento encontrado com os filtros selecionados." + } + + func mapStoreToCard(_ store: StoreSummary) -> FeaturedStoreCardModel { + let coverURL = resolveStoreMediaURL(store.cover) + let logoURL = resolveStoreMediaURL(store.logo) + return FeaturedStoreCardModel( + id: store.id, + name: store.name, + rating: store.rating ?? 0, + reviews: String(store.positiveReviews ?? store.reviewsCount ?? 0), + distance: formatDistance(store.distance), + deliveryFee: store.deliveryFee, + category: store.category ?? "Loja", + promoText: nil, + isFavorite: appState.favorites.storeIds.contains(store.id), + iconName: "storefront", + imageURL: logoURL ?? coverURL, + logoURL: logoURL, + coverURL: coverURL, + isOpen: store.isOpen ?? true, + statusLabel: store.statusLabel + ) + } + + func resolveStoreMediaURL(_ raw: String?) -> String? { + ImageSourceResolver.resolve(raw) + } + + var profilePictureURL: String? { + resolveStoreMediaURL(appState.profile.profilePicture) + } + + /// Empty string means "no distance to show" - the caller omits the whole + /// segment rather than labelling it. Per the API contract + /// (docs/plans/public-store-distance-consumer.md) the server never sends + /// null and uses `0` for "unavailable", so both that and a missing value + /// collapse to the same empty result. + func formatDistance(_ distance: Double?) -> String { + guard let distance, distance > 0 else { return "" } + if distance >= 1 { + return String(format: "%.1f km", distance) + } + return "\(Int(distance * 1000)) m" + } + + func scheduleSearchIndexUpdate() { + searchDebounceToken += 1 + let token = searchDebounceToken + Task { + try? await Task.sleep(nanoseconds: 220_000_000) + guard token == searchDebounceToken else { return } + await loadProductIndexForSearchIfNeeded() + } + } + + @MainActor + func loadProductIndexForSearchIfNeeded() async { + let query = normalizeSearch(searchText) + guard query.isEmpty == false else { return } + + let candidates = filteredStores + .filter { productSearchIndexByStoreId[$0.id] == nil } + .prefix(10) + + guard candidates.isEmpty == false else { return } + + await withTaskGroup(of: (String, [String]?).self) { group in + for store in candidates { + group.addTask { + do { + let response = try await ApiService().storeCatalog(storeId: store.id) + let products = response.result?.flatMap(\.products) ?? [] + let names = products.map(\.name) + return (store.id, names) + } catch { + return (store.id, nil) + } + } + } + + for await result in group { + let names = result.1 ?? [] + productSearchIndexByStoreId[result.0] = names + } + } + } + + func normalizeSearch(_ value: String) -> String { + value + .trimmingCharacters(in: .whitespacesAndNewlines) + .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) + .lowercased() + } + + func matchesSearch(store: StoreSummary, query: String) -> Bool { + let storeName = normalizeSearch(store.name) + if storeName.contains(query) { + return true + } + + let category = normalizeSearch(store.category ?? "") + if category.contains(query) { + return true + } + + let products = productSearchIndexByStoreId[store.id] ?? [] + return products.contains { normalizeSearch($0).contains(query) } + } + + func sortStores(_ list: [StoreSummary], query: String) -> [StoreSummary] { + switch appState.homeFilters.sortOption { + case .relevance: + return list.sorted { lhs, rhs in + let lhsScore = relevanceScore(for: lhs, query: query) + let rhsScore = relevanceScore(for: rhs, query: query) + if lhsScore != rhsScore { + return lhsScore > rhsScore + } + return (lhs.distance ?? .greatestFiniteMagnitude) < (rhs.distance ?? .greatestFiniteMagnitude) + } + case .rating: + return list.sorted { ($0.rating ?? 0) > ($1.rating ?? 0) } + case .deliveryTime: + return list.sorted { estimatedDeliveryMinutes($0.deliveryTime) < estimatedDeliveryMinutes($1.deliveryTime) } + case .price: + return list.sorted { ($0.deliveryFee ?? .greatestFiniteMagnitude) < ($1.deliveryFee ?? .greatestFiniteMagnitude) } + } + } + + func relevanceScore(for store: StoreSummary, query: String) -> Double { + guard query.isEmpty == false else { + let positive = Double(store.positiveReviews ?? store.reviewsCount ?? 0) + return positive + (store.rating ?? 0) * 10 + } + + let name = normalizeSearch(store.name) + let category = normalizeSearch(store.category ?? "") + let products = productSearchIndexByStoreId[store.id] ?? [] + + var score = 0.0 + if name.hasPrefix(query) { score += 200 } + if name.contains(query) { score += 120 } + if category.contains(query) { score += 70 } + if products.contains(where: { normalizeSearch($0).contains(query) }) { score += 90 } + score += (store.rating ?? 0) * 10 + score += Double(store.positiveReviews ?? store.reviewsCount ?? 0) * 0.02 + return score + } + + func estimatedDeliveryMinutes(_ value: String?) -> Int { + guard let value else { return Int.max } + let digits = value.compactMap { $0.isNumber ? String($0) : " " }.joined() + let parts = digits + .split(separator: " ") + .compactMap { Int($0) } + if let min = parts.min() { + return min + } + return Int.max + } + + func matchesPriceTier(fee: Double, tier: HomePriceTier) -> Bool { + switch tier { + case .low: return fee <= 5 + case .medium: return fee > 5 && fee <= 10 + case .high: return fee > 10 && fee <= 20 + case .veryHigh: return fee > 20 + } + } +} diff --git a/PediFoods/Views/Main/HomeView.swift b/PediFoods/Views/Main/HomeView.swift new file mode 100644 index 0000000..8ea6ddc --- /dev/null +++ b/PediFoods/Views/Main/HomeView.swift @@ -0,0 +1,573 @@ +import SwiftUI +#if os(iOS) +import LCEssentials +import UIKit +#endif + +struct HomeView: View { + @Binding var appState: AppState + @Binding var selectedTab: MainTab + @State var searchText = "" + @State var selectedCategory = "all" + @State var categories: [CategoryModel] = [ + .init(id: "all", title: "Todas", systemIcon: "line.3.horizontal.decrease.circle", emojiIcon: nil) + ] + @State var scrollOffset: CGFloat = 0 + @State var hasRequestedLocation = false + @State var isLoadingStores = false + @State var storesError: String? = nil + @State var stores: [StoreSummary] = [] + @State var productSearchIndexByStoreId: [String: [String]] = [:] + @State var searchDebounceToken = 0 + @State var favoriteRequestStoreIds: Set = [] + + private let specials: [SpecialOfferCardModel] = [ +// .init(id: "ddddd", title: "Get 50% OFF", subtitle: "For your first order", colors: [Color(hex: "#1E6B43"), Color(hex: "#58A56C")]), +// .init(id: "eeeee", title: "Enjoy spicy day", subtitle: "Delivery in 20 min", colors: [Color(hex: "#F04B3E"), Color(hex: "#FF8C42")]) + ] + + private let headerExpandedHeight: CGFloat = 240 + private let headerCollapsedHeight: CGFloat = 120 + private let contentTopSpacing: CGFloat = -40 + private let contentBottomSpacing: CGFloat = 120 + + var body: some View { + let collapseProgress = clamp(value: scrollOffset / (headerExpandedHeight - headerCollapsedHeight), lower: 0, upper: 1) + let headerHeight = headerExpandedHeight - (headerExpandedHeight - headerCollapsedHeight) * collapseProgress + + return ZStack(alignment: .top) { + ScrollView(showsIndicators: false) { + contentStack + .padding(.top, headerExpandedHeight + contentTopSpacing) + .padding(.bottom, contentBottomSpacing) + } + .refreshable { + // See StoreDetailView's .refreshable for why this runs in + // its own unstructured Task: SwiftUI can cancel + // .refreshable's own wrapping Task independent of whether + // the network call is still legitimately in flight, and + // that cancellation was being silently swallowed by + // isCancelledRequest — awaiting Task.value decouples the + // real work from that premature cancellation. + await Task { + await bootstrapStoresFlow( + forceNetworkRefresh: true, + category: selectedCategoryQueryValue, + refreshCategories: true + ) + }.value + } + .appNamedCoordinateSpace(HomeScrollCoordinateSpace.name) + + // ignoresSafeArea lives here, on the header only — not on the + // .refreshable ScrollView above. Applying it to an ancestor of + // a .refreshable view (or the view itself) breaks the native + // pull-to-refresh spinner's positioning, rendering it invisible + // even though the gesture still fires the refresh closure. + header(collapseProgress: collapseProgress, height: headerHeight) + .frame(maxWidth: .infinity, alignment: .top) + .ignoresSafeArea(edges: .top) + } + .background(AppColors.backgroundLight) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .onAppear { + if hasRequestedLocation == false { + hasRequestedLocation = true + Task { + await bootstrapStoresFlow(refreshCategories: true) + } + } + } + .onChange(of: addressCacheScope) { _, _ in + guard hasRequestedLocation else { return } + Task { + await bootstrapStoresFlow( + forceLocationRefresh: true, + category: selectedCategoryQueryValue, + refreshCategories: true + ) + } + } + .onChange(of: searchText) { _, _ in + scheduleSearchIndexUpdate() + } + .onChange(of: appState.homeFilters.sortOption) { _, _ in scheduleSearchIndexUpdate() } + .onChange(of: appState.homeFilters.selectedCategories) { _, _ in scheduleSearchIndexUpdate() } + .onChange(of: appState.homeFilters.selectedPriceTier) { _, _ in scheduleSearchIndexUpdate() } + .onChange(of: appState.homeFilters.maxDistanceKm) { _, _ in scheduleSearchIndexUpdate() } + } + + private var contentStack: some View { + VStack(spacing: 24) { + scrollOffsetObserver + + // The collapsing header is a separate overlay drawn on top of + // this ScrollView in the ZStack above, which visually covers + // the native pull-to-refresh spinner's position. This gives + // refresh feedback that's actually visible, right below the + // header, instead of relying on a spinner hidden behind it. + if isLoadingStores && stores.isEmpty == false { + HStack(spacing: 8) { + ProgressView() + Text("Atualizando...") + .font(AppTypography.caption) + .foregroundStyle(AppColors.textMuted) + } + .frame(maxWidth: .infinity) + } + + categoriesSection + + section(title: "Featured") { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 16) { + ForEach(featuredStoresCards) { store in + NavigationLink { + storeDestination(for: store) + } label: { + FeaturedStoreCard( + store: store, + onFavoriteToggle: { + Task { + await toggleFavoriteStore(storeId: store.id, storeName: store.name) + } + } + ) + .frame(width: 190) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 20) + } + } + + if appState.featureFlags.isEnabled("at.promo") { + section(title: "#PediPromo") { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 16) { + ForEach(specials) { item in + SpecialOfferCard(model: item) + .frame(width: 260, height: 120) + } + } + .padding(.horizontal, 20) + } + } + } + + section(title: "Pertinho de você") { + // Once we have stores loaded, keep showing them regardless of + // a subsequent refresh's isLoadingStores/storesError state — + // a failed or in-flight pull-to-refresh must never hide + // already-loaded content. + if filteredStoreCards.isEmpty == false { + storeCardsList + } else if isLoadingStores { + HStack { + ProgressView() + Text("Buscando estabelecimentos próximos...") + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + } + .padding(.horizontal, 20) + } else if let storesError { + VStack(alignment: .leading, spacing: 10) { + Text(storesError) + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + Button("Tentar novamente") { + Task { + await bootstrapStoresFlow( + forceLocationRefresh: true, + category: selectedCategoryQueryValue, + refreshCategories: true + ) + } + } + .buttonStyle(.plain) + .font(AppTypography.heading3) + .foregroundStyle(AppColors.primary) + } + .padding(.horizontal, 20) + } else { + Text(emptyResultMessage) + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + .padding(.horizontal, 20) + } + } + } + } + + @ViewBuilder + private func storeDestination(for store: FeaturedStoreCardModel) -> some View { + StoreDetailView( + storeId: store.id, + storeName: store.name, + storeCoverURL: store.coverURL, + storeLogoURL: store.logoURL, + storeCategory: store.category, + storeRating: store.rating, + storeDistance: store.distance, + storeDeliveryFee: store.deliveryFee, + appState: $appState + ) + } + + @ViewBuilder + private var storeCardsList: some View { + VStack(spacing: 16) { + ForEach(filteredStoreCards) { store in + NavigationLink { + storeDestination(for: store) + } label: { + FeaturedStoreCard( + store: store, + onFavoriteToggle: { + Task { + await toggleFavoriteStore(storeId: store.id, storeName: store.name) + } + } + ) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 20) + } + + private func header(collapseProgress: CGFloat, height: CGFloat) -> some View { + let titleOpacity = 1 - clamp(value: collapseProgress * 1.2, lower: 0, upper: 1) + let topRowOpacity = 1 - clamp(value: collapseProgress * 1.4, lower: 0, upper: 1) + + return ZStack(alignment: .top) { + RoundedRectangle(cornerRadius: 32, style: .continuous) + .fill(AppColors.primary) + .frame(height: height) + .overlay(headerRings.opacity(1 - collapseProgress), alignment: .topTrailing) + + VStack(alignment: .leading, spacing: 16) { + Spacer().frame(height: 20) + HStack(alignment: .center, spacing: 12) { + Button { + selectedTab = .profile + } label: { + Circle() + .fill(AppColors.brandSoft) + .frame(width: 40, height: 40) + .overlay { + if let profilePictureURL { + AsyncStoreImage(imageURL: profilePictureURL) + .frame(width: 36, height: 36) + .clipShape(Circle()) + } else { + Image(systemName: "person.fill") + .foregroundStyle(AppColors.brandDark) + } + } + } + .buttonStyle(.plain) + + VStack(alignment: .center, spacing: 4) { + Text("ENTREGAR EM:") + .font(AppTypography.overline) + .tracking(AppTypography.captionLetterSpacing) + .foregroundStyle(AppColors.brandSoft) + .multilineTextAlignment(.center) + + Button { + appState.address.onboardingMessage = nil + appState.activeModal = .addressPicker + } label: { + HStack(spacing: 6) { + Text(appState.address.display) + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textInverse) + Image(systemName: "chevron.down") + .font(.caption) + .foregroundStyle(AppColors.brandSoft) + } + } + .buttonStyle(.plain) + } + .frame(maxWidth: .infinity, alignment: .center) + + Circle() + .fill(Color.white.opacity(0.18)) + .frame(width: 40, height: 40) + .overlay( + Image(systemName: "bell") + .foregroundStyle(AppColors.textInverse) + ) + } + .opacity(topRowOpacity) + .offset(y: collapseProgress * -12) + + if UIDevice().modelName.lowercased().contains("se") || UIDevice().modelName.lowercased().contains("16e") { + Text("O que vai querer \npedir hoje?") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textInverse) + .opacity(titleOpacity) + .offset(y: collapseProgress * -20) + } else { + Text("O que vai querer \npedir hoje?") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textInverse) + .opacity(titleOpacity) + .offset(y: collapseProgress * -20) + } + + SearchBar(placeholder: "Search menu, restaurant or craving", text: $searchText) { + appState.homeFilters.availableCategories = categories + .filter { $0.id.lowercased() != "all" } + .map(\.title) + appState.activeModal = .filters + } + .offset(y: collapseProgress * -120) + } + .padding(.horizontal, 20) + .padding(.top, 18) + } + } + + private func section(title: String, @ViewBuilder content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 16) { + Text(title) + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + .padding(.horizontal, 20) + + content() + } + } + + private var categoriesSection: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Categories") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + .padding(.horizontal, 20) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(categories) { category in + CategoryChip( + title: category.title, + systemIcon: category.systemIcon, + emojiIcon: category.emojiIcon, + isActive: category.id == selectedCategory + ) + .onTapGesture { + guard category.id != selectedCategory else { return } + selectedCategory = category.id + Task { + await bootstrapStoresFlow(category: category.id.lowercased() == "all" ? nil : category.title) + } + } + } + } + .padding(.horizontal, 20) + } + } + } + + private var headerRings: some View { + ZStack { + Circle() + .stroke(Color.white.opacity(0.08), lineWidth: 1) + .frame(width: 180, height: 180) + .offset(x: 40, y: -10) + Circle() + .stroke(Color.white.opacity(0.08), lineWidth: 1) + .frame(width: 130, height: 130) + .offset(x: 70, y: 10) + } + } + + @MainActor + private func bootstrapStoresFlow( + forceLocationRefresh: Bool = false, + forceNetworkRefresh: Bool = false, + category: String? = nil, + refreshCategories: Bool = false + ) async { + if isLoadingStores { return } + // A refresh (pull-to-refresh) that fails must never wipe the list + // the user is already looking at — only a first load with nothing + // yet loaded is allowed to show a blocking error state. + let hadExistingStores = stores.isEmpty == false + isLoadingStores = true + if hadExistingStores == false { + storesError = nil + } + + // Anonymous browsing has no account address/coordinates — the public + // locator uses a manually-picked state/city instead (geolocation is + // out of scope for that flow, see public-store-locator-sdd.md). + guard appState.session.isAuthenticated else { + await loadGuestStores(hadExistingStores: hadExistingStores, category: category, refreshCategories: refreshCategories) + return + } + + let coordinate = await resolveCoordinates(forceRefresh: forceLocationRefresh) + let hasAddress = hasConfiguredAddress() + + if coordinate == nil && hasAddress == false { + isLoadingStores = false + stores = [] + storesError = "Defina um endereço para visualizar os estabelecimentos mais próximos." + appState.address.onboardingMessage = "Vamos criar um endereço para poder visualizar os estabelecimentos mais próximos de você?" + appState.activeModal = .addressPicker + return + } + + do { + let storesCacheKey = homeStoresCacheKey( + lat: coordinate?.0, + lng: coordinate?.1, + category: category + ) + + if forceLocationRefresh == false, + forceNetworkRefresh == false, + let cachedStores: [StoreSummary] = AppContentCache.shared.value(for: storesCacheKey, as: [StoreSummary].self) { + isLoadingStores = false + stores = cachedStores +#if os(iOS) + for store in cachedStores { + printLog( + title: "LOGO HOME", + msg: "storeId=\(store.id) | storeName=\(store.name) | logoRaw=\(store.logo ?? "nil")" + ) + } +#endif + AppContentCache.shared.set(cachedStores, for: AppCacheKey.homeStoresLatestSnapshot, ttl: AppCacheTTL.twoHours) + if refreshCategories || (category == nil && categories.count <= 1) { + await loadHomeCategories( + withFallbackStores: cachedStores, + forceRefresh: forceLocationRefresh || forceNetworkRefresh + ) + if categories.contains(where: { $0.id == selectedCategory }) == false { + selectedCategory = "all" + } + } + storesError = nil + return + } + + let response = try await ApiService().listStores( + lat: coordinate?.0, + lng: coordinate?.1, + category: category + ) + isLoadingStores = false + if response.error { + reportStoresLoadFailure( + response.message ?? "Não foi possível carregar os estabelecimentos.", + hadExistingStores: hadExistingStores + ) + return + } + let results = response.result ?? [] + stores = results +#if os(iOS) + for store in results { + printLog( + title: "LOGO HOME", + msg: "storeId=\(store.id) | storeName=\(store.name) | logoRaw=\(store.logo ?? "nil")" + ) + } +#endif + AppContentCache.shared.set(results, for: storesCacheKey, ttl: AppCacheTTL.homeStores) + AppContentCache.shared.set(results, for: AppCacheKey.homeStoresLatestSnapshot, ttl: AppCacheTTL.twoHours) + if refreshCategories || (category == nil && categories.count <= 1) { + await loadHomeCategories( + withFallbackStores: results, + forceRefresh: forceLocationRefresh || forceNetworkRefresh + ) + if categories.contains(where: { $0.id == selectedCategory }) == false { + selectedCategory = "all" + } + } + storesError = nil + } catch { + if isCancelledRequest(error) { + isLoadingStores = false + return + } + isLoadingStores = false + reportStoresLoadFailure(storesUserMessage(error), hadExistingStores: hadExistingStores) + } + } + + func reportStoresLoadFailure(_ message: String, hadExistingStores: Bool) { + if hadExistingStores { + SnackbarCenter.shared.show(title: message, style: .warning, icon: "exclamationmark.triangle.fill", duration: 3.0) + } else { + stores = [] + storesError = message + } + } + + @ViewBuilder + private var scrollOffsetObserver: some View { + ScrollOffsetObserver { y in + // Use only upward displacement for collapse and ignore top bounce. + let normalized = max(0, y) + scrollOffset = normalized + } + .frame(width: 0, height: 0) + } + + private var addressCacheScope: String { + let selected = appState.address.selectedId ?? "nil" + let display = appState.address.display + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + let lat = appState.address.latitude.map(formatCoordinateScope) ?? "nil" + let lng = appState.address.longitude.map(formatCoordinateScope) ?? "nil" + return "\(selected)|\(display)|\(lat)|\(lng)" + } + + private func homeStoresCacheKey(lat: Double?, lng: Double?, category: String?) -> String { + let normalizedCategory = category?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "all" + let latKey = lat.map(formatCoordinateCache) ?? "nil" + let lngKey = lng.map(formatCoordinateCache) ?? "nil" + return "stores:\(latKey):\(lngKey):\(normalizedCategory)" + } + + private func formatCoordinateScope(_ value: Double) -> String { + String((value * 100_000).rounded() / 100_000) + } + + private func formatCoordinateCache(_ value: Double) -> String { + String((value * 10_000).rounded() / 10_000) + } + + private var selectedCategoryQueryValue: String? { + guard selectedCategory.lowercased() != "all" else { return nil } + guard let selected = categories.first(where: { $0.id == selectedCategory }) else { return nil } + return selected.title + } + + func isCancelledRequest(_ error: Error) -> Bool { + if error is CancellationError { + return true + } + + if let networkError = error as? NetworkError { + switch networkError { + case .cancelled: + return true + case .transportError(let message): + let normalized = message.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return normalized.contains("cancel") + default: + break + } + } + + return error.localizedDescription.lowercased().contains("cancel") + } +} diff --git a/PediFoods/Views/Main/HomeViewComponents.swift b/PediFoods/Views/Main/HomeViewComponents.swift new file mode 100644 index 0000000..b94b3c9 --- /dev/null +++ b/PediFoods/Views/Main/HomeViewComponents.swift @@ -0,0 +1,65 @@ +import SwiftUI + +struct CategoryModel: Identifiable { + let id: String + let title: String + let systemIcon: String? + let emojiIcon: String? +} + +struct CategoryChip: View { + let title: String + let systemIcon: String? + let emojiIcon: String? + let isActive: Bool + + var body: some View { + HStack(spacing: 8) { + if let emojiIcon, emojiIcon.isEmpty == false { + Text(emojiIcon) + .font(.body) + } else if let systemIcon, systemIcon.isEmpty == false { + Image(systemName: systemIcon) + .font(.caption) + } + Text(title) + .font(AppTypography.heading3) + } + .foregroundStyle(isActive ? AppColors.textInverse : AppColors.textPrimary) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(isActive ? AppColors.primary : AppColors.surface) + .clipShape(Capsule()) + } +} + +struct SearchBar: View { + let placeholder: String + @Binding var text: String + var onFilterTap: () -> Void = {} + + var body: some View { + HStack(spacing: 12) { + Image(systemName: "magnifyingglass") + .foregroundStyle(AppColors.textMuted) + TextField(placeholder, text: $text) + .appNoAutoCap() + .foregroundStyle(AppColors.textPrimary) + .tint(AppColors.textPrimary) + Spacer() + Button(action: onFilterTap) { + Image(systemName: "slider.horizontal.3") + .foregroundStyle(AppColors.textMuted) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 16) + .frame(height: 52) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } +} + +func clamp(value: CGFloat, lower: CGFloat, upper: CGFloat) -> CGFloat { + min(max(value, lower), upper) +} diff --git a/PediFoods/Views/Main/MainTabView.swift b/PediFoods/Views/Main/MainTabView.swift new file mode 100644 index 0000000..acb2036 --- /dev/null +++ b/PediFoods/Views/Main/MainTabView.swift @@ -0,0 +1,89 @@ +import SwiftUI + +struct MainTabView: View { + @Binding var selectedTab: MainTab + let tokenStore: TokenStore + @Binding var appState: AppState + let enterAuth: () -> Void + + var body: some View { + ZStack(alignment: .bottom) { + Group { + switch selectedTab { + case .home: + NavigationStack { + HomeView(appState: $appState, selectedTab: $selectedTab) + } + case .cart: + NavigationStack { + CartView(appState: $appState, selectedTab: $selectedTab, enterAuth: enterAuth) + } + case .profile: + NavigationStack { + if appState.session.isAuthenticated { + ProfileView(selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState, enterAuth: enterAuth) + } else { + ProfileLoggedOutView(enterAuth: enterAuth) + } + } + } + } + + customTabBar + } + } + + private var customTabBar: some View { + HStack(spacing: 12) { + tabBarButton(tab: .home, title: "Home", icon: "house.fill") + tabBarButton(tab: .cart, title: "Carrinho", icon: "cart.fill", badgeCount: appState.cart.totalItems) + tabBarButton(tab: .profile, title: "Perfil", icon: "person.fill") + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 30, style: .continuous) + .fill(AppColors.surface.opacity(0.95)) + ) + .padding(.horizontal, 18) + .padding(.bottom, 10) + .shadow(color: AppShadow.soft.color, radius: AppShadow.soft.radius, y: AppShadow.soft.y) + } + + private func tabBarButton(tab: MainTab, title: String, icon: String, badgeCount: Int = 0) -> some View { + let isActive = selectedTab == tab + return Button { + selectedTab = tab + } label: { + HStack(spacing: 8) { + ZStack(alignment: .topTrailing) { + Image(systemName: icon) + .font(.system(size: 18, weight: .semibold)) + + if badgeCount > 0 { + Text("\(min(badgeCount, 99))") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(Color.white) + .padding(.horizontal, 4) + .padding(.vertical, 2) + .background(Color.red) + .clipShape(Capsule()) + .offset(x: 9, y: -8) + } + } + if isActive { + Text(title) + .font(AppTypography.heading3) + } + } + .foregroundStyle(isActive ? AppColors.textInverse : AppColors.textPrimary) + .padding(.horizontal, 18) + .padding(.vertical, 10) + .background( + Capsule() + .fill(isActive ? AppColors.primary : Color.clear) + ) + } + .buttonStyle(.plain) + } +} diff --git a/PediFoods/Views/Main/OrderDetailsView.swift b/PediFoods/Views/Main/OrderDetailsView.swift new file mode 100644 index 0000000..290b74a --- /dev/null +++ b/PediFoods/Views/Main/OrderDetailsView.swift @@ -0,0 +1,613 @@ +import SwiftUI +#if canImport(LCEssentials) +import LCEssentials +#endif + +struct OrderDetailsView: View { + let order: PublicOrderResult + let orderId: String + let initialShortId: String? + @Binding var appState: AppState + + @Environment(\.dismiss) var dismiss + @Environment(\.openURL) var openURL + @State private var storeContactPhone: String? = nil + @State private var resolvedStoreLogoURL: String? = nil + @State private var showCallAlert = false + @State private var navigateToStore = false + @State private var showClearCartAlert = false + + var body: some View { + LCENavigationView { + content + } + .setLeftButton(image: AnyView(AppBackButtonIcon())) { dismiss() } + .setTitle(text: Text("Detalhes do Pedido").font(AppTypography.heading2).foregroundStyle(AppColors.textPrimary)) + .setNavigationBarBackgroundColor(AppColors.backgroundLight) + .buttonStyle(.plain) + } + + private var content: some View { + ScrollView(showsIndicators: false) { + VStack(spacing: 14) { + statusCard + storeCard + itemsCard + totalsCard + if hasAddressInfo { + addressCard + } + helpFooter + } + .padding(.horizontal, 20) + .padding(.top, 14) + .padding(.bottom, UIDevice.bottomNotch) + } + .background(AppColors.backgroundLight) + .appHiddenNavigationBar() + .navigationBarBackButtonHidden(true) + .navigationDestination(isPresented: $navigateToStore) { + if let storeId = order.storeId, storeId.isEmpty == false { + StoreDetailView( + storeId: storeId, + storeName: order.storeName ?? "Loja", + storeCoverURL: nil, + storeLogoURL: order.storeLogoURL, + storeCategory: nil, + storeRating: nil, + storeDistance: nil, + storeDeliveryFee: nil, + appState: $appState + ) + } + } + .alert("Ligar para a loja?", isPresented: $showCallAlert) { + Button("Ligar para \(order.storeName ?? "a loja")") { + if let phone = storeContactPhone { + openTel(phone) + } + } + Button("Cancelar", role: .cancel) {} + } message: { + Text("WhatsApp não encontrado. Deseja ligar para \(order.storeName ?? "a loja")?") + } + .task { + await loadStoreContactPhone() + } + .alert("Substituir carrinho?", isPresented: $showClearCartAlert) { + Button("Limpar e adicionar", role: .destructive) { + applyReorder(clearFirst: true) + } + Button("Cancelar", role: .cancel) {} + } message: { + Text("Seu carrinho tem itens de \(appState.cart.storeName ?? appState.cart.storeId ?? "outra loja"). Deseja limpar e adicionar itens de \(order.storeName ?? "esta loja")?") + } + .appBottomSafeAreaInset { + VStack { + reorderButton + .padding(.horizontal, 20) + .padding(.top, 10) + .padding(.bottom, UIDevice.bottomNotch + 60) + } + .background(AppColors.backgroundLight.opacity(0.94)) + } + } + + private var statusCard: some View { + HStack(spacing: 14) { + Circle() + .fill(AppColors.brandSoft) + .frame(width: 54, height: 54) + .overlay( + Image(systemName: statusIcon) + .font(.system(size: 22, weight: .bold)) + .foregroundStyle(statusColor) + ) + + VStack(alignment: .leading, spacing: 2) { + Text(statusTitle) + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + + Text(statusDateText) + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + + if let reason = cancellationReasonText { + Text(reason) + .font(AppTypography.caption) + .foregroundStyle(AppColors.textMuted) + .padding(.top, 2) + } + + if let addr = deliveryAddressSummary { + HStack(spacing: 4) { + Image(systemName: "mappin.circle.fill") + .font(.system(size: 11)) + .foregroundStyle(AppColors.textMuted) + Text(addr) + .font(AppTypography.caption) + .foregroundStyle(AppColors.textMuted) + .lineLimit(2) + } + .padding(.top, 2) + } + } + + Spacer(minLength: 0) + } + .padding(16) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + + private var storeCard: some View { + Button { + if order.storeId?.isEmpty == false { + navigateToStore = true + } + } label: { + HStack(spacing: 12) { + AsyncStoreImage(imageURL: resolvedMediaURL(resolvedStoreLogoURL ?? order.storeLogoURL)) + .frame(width: 54, height: 54) + .clipShape(Circle()) + .background(AppColors.brandSoft, in: Circle()) + + VStack(alignment: .leading, spacing: 4) { + Text(order.storeName?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? (order.storeName ?? "Loja") : "Loja") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + Text(storeSubtitle) + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + } + + Spacer(minLength: 0) + + if order.storeId?.isEmpty == false { + Text("Ver loja") + .font(AppTypography.body) + .foregroundStyle(Color(hex: "#A5D645")) + } + } + .padding(16) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + .buttonStyle(.plain) + } + + private var itemsCard: some View { + VStack(alignment: .leading, spacing: 14) { + Text("Itens do Pedido") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + + ForEach(order.items) { item in + HStack(alignment: .top, spacing: 12) { + Text("\(max(1, item.qty ?? 1))") + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + .frame(width: 30, height: 30) + .background(AppColors.backgroundLight) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + + VStack(alignment: .leading, spacing: 2) { + Text(item.name?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? (item.name ?? "Item") : "Item") + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + } + + Spacer(minLength: 0) + + if let price = item.price { + Text(formatCurrency(price)) + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(16) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + + private var totalsCard: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Resumo de Valores") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + + HStack { + Text("Subtotal") + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + Spacer() + Text(formatCurrency(subtotalValue)) + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + } + + HStack { + Text("Taxa de entrega") + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + Spacer() + Text(formatCurrency(deliveryFeeValue)) + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + } + + HStack { + Text("Desconto") + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + Spacer() + Text("- \(formatCurrency(discountValue))") + .font(AppTypography.body) + .foregroundStyle(Color(hex: "#18A957")) + } + + Divider() + + HStack { + Text("Total") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + Spacer() + Text(formatCurrency(totalValue)) + .font(AppTypography.heading1) + .foregroundStyle(AppColors.textPrimary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(16) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + + private var addressCard: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 10) { + Circle() + .fill(AppColors.backgroundLight) + .frame(width: 34, height: 34) + .overlay( + Image(systemName: "mappin.circle.fill") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(AppColors.textMuted) + ) + Text("ENDEREÇO DE ENTREGA") + .font(AppTypography.caption) + .foregroundStyle(AppColors.textMuted) + Spacer(minLength: 0) + } + + Text(deliveryAddressLine) + .font(AppTypography.body) + .foregroundStyle(AppColors.textPrimary) + + if deliveryAddressLine2.isEmpty == false { + Text(deliveryAddressLine2) + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(16) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + + private var reorderButton: some View { + Button("Pedir Novamente") { + reorder() + } + .font(AppTypography.heading2) + .foregroundStyle(Color(hex: "#0E1A06")) + .frame(maxWidth: .infinity, minHeight: 56) + .background(order.items.isEmpty ? Color(hex: "#C8F06E").opacity(0.45) : Color(hex: "#C8F06E")) + .clipShape(Capsule()) + .buttonStyle(.plain) + .disabled(order.items.isEmpty) + } + + private var helpFooter: some View { + Button { + handleHelpTap() + } label: { + Text("Precisa de ajuda com esse pedido?") + .font(AppTypography.body) + .foregroundStyle(Color(hex: "#A5D645")) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, 8) + } + .buttonStyle(.plain) + } + + private var subtotal: Double { + order.items.reduce(0) { partial, item in + partial + (Double(max(1, item.qty ?? 1)) * (item.price ?? 0)) + } + } + + private var subtotalValue: Double { + order.subtotal ?? subtotal + } + + private var deliveryFeeValue: Double { + max(0, order.deliveryFee ?? 0) + } + + private var discountValue: Double { + max(0, order.discount ?? 0) + } + + private var totalValue: Double { + if let total = order.total { + return total + } + let calculated = subtotalValue + deliveryFeeValue - discountValue + return max(0, calculated) + } + + private var hasAddressInfo: Bool { + deliveryAddressLine.isEmpty == false || deliveryAddressLine2.isEmpty == false + } + + private var deliveryAddressLine: String { + guard let address = order.deliveryAddress else { return "" } + let street = normalizedText(address.street) + let number = normalizedText(address.number) + let base = [street, number].filter { $0.isEmpty == false }.joined(separator: ", ") + if base.isEmpty == false { return base } + return normalizedText(address.label) + } + + private var deliveryAddressLine2: String { + guard let address = order.deliveryAddress else { return "" } + let neighborhood = normalizedText(address.neighborhood) + let city = normalizedText(address.city) + let state = normalizedText(address.state) + let zip = normalizedText(address.zip) + return [neighborhood, city, state, zip] + .filter { $0.isEmpty == false } + .joined(separator: " • ") + } + + private var storeSubtitle: String { + if deliveryAddressLine2.isEmpty == false { + return deliveryAddressLine2 + } + return "Pedido #\(displayOrderTitle)" + } + + private var deliveryAddressSummary: String? { + if let full = order.fullAddress, full.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false { + return full.trimmingCharacters(in: .whitespacesAndNewlines) + } + let line1 = deliveryAddressLine + let line2 = deliveryAddressLine2 + let combined = [line1, line2].filter { $0.isEmpty == false }.joined(separator: ", ") + return combined.isEmpty ? nil : combined + } + + private var cancellationReasonText: String? { + guard statusTitle.contains("cancelado"), + let reason = order.cancellationReason, + reason.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + else { return nil } + return "Motivo: \(reason.trimmingCharacters(in: .whitespacesAndNewlines))" + } + + private var statusTitle: String { + let status = normalized(order.status) + if status.contains("CANCEL") { return "Pedido cancelado" } + if status.contains("COMPLETED") || status.contains("DELIVERED") { + return normalized(order.deliveryType ?? order.deliveryTypeLabel).contains("PICKUP") ? "Pedido retirado" : "Pedido concluído" + } + if status.contains("DELIVER") || status.contains("ROTA") { return "Pedido em rota" } + if status.contains("READY") { return "Pedido pronto" } + if status.contains("PREPAR") { return "Pedido em produção" } + return "Pedido confirmado" + } + + private var statusDateText: String { + if let event = order.timeline.first, + let date = event.date, date.isEmpty == false { + let time = event.time.flatMap { $0.isEmpty ? nil : $0 } + let combined = time.map { "\(date) às \($0)" } ?? date + return "\(statusDatePrefix) \(combined)" + } + if let formatted = formatDate(order.updatedAt ?? order.createdAt) { + return "\(statusDatePrefix) \(formatted)" + } + return statusDatePrefix + } + + private var statusDatePrefix: String { + if statusTitle.contains("cancelado") { return "Cancelado em" } + if statusTitle.contains("retirado") { return "Retirado em" } + if statusTitle.contains("concluído") { return "Entregue em" } + return "Atualizado em" + } + + private var statusIcon: String { + statusTitle.contains("cancelado") ? "xmark" : "checkmark" + } + + private var statusColor: Color { + statusTitle.contains("cancelado") ? Color.red : AppColors.primary + } + + private var displayOrderTitle: String { + if let short = order.shortId, short.isEmpty == false { return short } + let orderIdValue = order.id.trimmingCharacters(in: .whitespacesAndNewlines) + if orderIdValue.isEmpty == false { return orderIdValue } + if let initialShortId, initialShortId.isEmpty == false { return initialShortId } + return String(orderId.prefix(6)) + } + + private func formatCurrency(_ value: Double) -> String { + String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",") + } + + private func reorder() { + guard order.items.isEmpty == false else { return } + let cartStoreId = appState.cart.storeId ?? appState.cart.items.first?.storeId ?? "" + let orderStoreId = order.storeId ?? "" + let cartHasDifferentStore = cartStoreId.isEmpty == false + && orderStoreId.isEmpty == false + && cartStoreId != orderStoreId + && appState.cart.items.isEmpty == false + if cartHasDifferentStore { + showClearCartAlert = true + } else { + applyReorder(clearFirst: false) + } + } + + private func applyReorder(clearFirst: Bool) { + if clearFirst { + appState.cart.clear() + } + let storeId = order.storeId ?? "" + if appState.cart.storeId == nil || appState.cart.storeId?.isEmpty == true { + appState.cart.storeId = storeId + appState.cart.storeName = order.storeName + } + var addedCount = 0 + for item in order.items { + let name = item.name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard name.isEmpty == false else { continue } + let qty = max(1, item.qty ?? 1) + let price = item.price ?? 0 + let cartItem = CartItemState( + id: UUID().uuidString, + productId: item.productId ?? item.id, + storeId: storeId, + name: name, + imageURL: nil, + details: nil, + addons: [], + quantity: qty, + unitPrice: price + ) + appState.cart.add(item: cartItem) + addedCount += qty + } + let label = addedCount == 1 ? "1 item adicionado ao carrinho." : "\(addedCount) itens adicionados ao carrinho." + SnackbarCenter.shared.show(title: label, style: .success, icon: "cart.badge.plus", duration: 2.5) + } + + private func formatDate(_ isoValue: String?) -> String? { + guard let isoValue, isoValue.isEmpty == false else { return nil } + + let iso = ISO8601DateFormatter() + let optionSets: [ISO8601DateFormatter.Options] = [ + [.withInternetDateTime, .withFractionalSeconds], + [.withInternetDateTime], + [.withFullDate, .withTime, .withColonSeparatorInTime], + [.withFullDate, .withTime, .withColonSeparatorInTime, .withTimeZone], + [.withFullDate] + ] + var date: Date? = nil + for options in optionSets { + iso.formatOptions = options + if let d = iso.date(from: isoValue) { + date = d + break + } + } + if date == nil { + let fallback = DateFormatter() + fallback.locale = Locale(identifier: "en_US_POSIX") + for fmt in ["yyyy-MM-dd'T'HH:mm:ss.SSSZ", "yyyy-MM-dd'T'HH:mm:ssZ", + "yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd"] { + fallback.dateFormat = fmt + if let d = fallback.date(from: isoValue) { date = d; break } + } + } + guard let date else { return nil } + + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "pt_BR") + formatter.dateFormat = "dd MMM, HH:mm" + return formatter.string(from: date) + } + + @MainActor + private func loadStoreContactPhone() async { + if let inline = order.storePhone, inline.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false { + storeContactPhone = inline + } + guard let storeId = order.storeId?.trimmingCharacters(in: .whitespacesAndNewlines), + storeId.isEmpty == false else { return } + do { + let response = try await ApiService().storeInfo(storeId: storeId) + if response.error == false, let result = response.result { + let phone = (result.whatsapp ?? result.phone ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if phone.isEmpty == false { + storeContactPhone = phone + } + if let logo = result.logo, logo.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false { + resolvedStoreLogoURL = logo + } + } + } catch {} + } + + private func handleHelpTap() { + guard let phoneRaw = storeContactPhone, + phoneRaw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else { + SnackbarCenter.shared.show( + title: "Telefone da loja indisponível.", + style: .warning, + icon: "exclamationmark.triangle.fill", + duration: 2.8 + ) + return + } + if let waURL = makeWhatsAppURL(from: phoneRaw) { + openURL(waURL) + } else { + showCallAlert = true + } + } + + private func openTel(_ phoneRaw: String) { + let digits = phoneRaw.filter(\.isNumber) + if digits.isEmpty { return } + if let url = URL(string: "tel://\(digits)") { + openURL(url) + } + } + + private func makeWhatsAppURL(from phoneRaw: String) -> URL? { + var digits = phoneRaw.filter(\.isNumber) + if digits.isEmpty { return nil } + if digits.hasPrefix("0") { digits = String(digits.drop(while: { $0 == "0" })) } + if digits.hasPrefix("55") == false && (digits.count == 10 || digits.count == 11) { + digits = "55" + digits + } + guard digits.count >= 12 else { return nil } + return URL(string: "https://wa.me/\(digits)") + } + + private func normalized(_ value: String?) -> String { + (value ?? "") + .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) + .uppercased() + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func resolvedMediaURL(_ raw: String?) -> String? { + ImageSourceResolver.resolve(raw) + } + + private func normalizedText(_ value: String?) -> String { + (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/PediFoods/Views/Main/OrderTrackingView.swift b/PediFoods/Views/Main/OrderTrackingView.swift new file mode 100644 index 0000000..e7a8610 --- /dev/null +++ b/PediFoods/Views/Main/OrderTrackingView.swift @@ -0,0 +1,910 @@ +import SwiftUI +import UIKit +#if canImport(LCEssentials) +import LCEssentials +#endif + +private struct TrackingStep: Identifiable { + let id: String + let title: String + let subtitle: String + let time: String? + let isCompleted: Bool + let isActive: Bool +} + +struct OrderTrackingView: View { + let orderId: String + let initialShortId: String? + var postOrderBack: (() -> Void)? = nil + @Environment(\.dismiss) var dismiss + @Environment(\.openURL) var openURL + + @State var isLoading = true + @State var errorMessage: String? = nil + @State var order: PublicOrderResult? = nil + @State var storeContactPhone: String? = nil + @State var tracker = OrderRealtimeTracker() + @State var showCancellationReason = false + @State var reviewDraft: ReviewDraft? = nil + @State var didSaveReviewForCurrentOrder = false + @State var reviewSavedObserver: Any? + @State var showPushOptInAlert = false + + var body: some View { + LCENavigationView { + content + } + .setLeftButton(image: AnyView(AppBackButtonIcon())) { + if let postOrderBack { + postOrderBack() + } else { + dismiss() + } + } + .setTitle(text: Text("Pedido \(displayOrderTitle)").font(AppTypography.heading2).foregroundStyle(AppColors.textPrimary)) + .setNavigationBarBackgroundColor(AppColors.backgroundLight) + .buttonStyle(.plain) + .navigationDestination(item: $reviewDraft) { draft in + MyReviewsView(initialOrder: draft) + } + } + + private var content: some View { + ScrollView(showsIndicators: false) { + VStack(spacing: 16) { + topHeader + orderTitleSection + statusBanner + timelineSection + placeholderCard + if shouldShowReviewButton { + reviewButton + } else { + contactButton + } + } + .padding(.horizontal, 20) + .padding(.top, 14) + .padding(.bottom, UIDevice.bottomNotch + 45) + } + .background(AppColors.backgroundLight) + .appHiddenNavigationBar() + .navigationBarBackButtonHidden(true) + .alert("Motivo do cancelamento", isPresented: $showCancellationReason) { + Button("Fechar", role: .cancel) {} + } message: { + Text(cancellationReasonText) + } + .alert("Ative as notificações", isPresented: $showPushOptInAlert) { + Button("Agora não", role: .cancel) {} + Button("Ativar") { Task { await enablePushNotifications() } } + } message: { + Text("Ative as notificações para acompanhar em tempo real as atualizações do seu pedido.") + } + .task { + await loadInitialOrder() + await maybePromptPushOptIn() + tracker.onOrderUpdated = { updated in + order = updated + if let inlinePhone = updated.storePhone, inlinePhone.isEmpty == false { + storeContactPhone = inlinePhone + } + isLoading = false + errorMessage = nil + Task { await maybePromptPushOptIn() } + } + tracker.start(orderId: orderId, jwt: DefaultTokenStore().jwt) + } + .onAppear { + attachReviewSavedObserverIfNeeded() + } + .onDisappear { + tracker.stop() + detachReviewSavedObserver() + } + } + + private func attachReviewSavedObserverIfNeeded() { + guard reviewSavedObserver == nil else { return } + reviewSavedObserver = NotificationCenter.default.addObserver( + forName: .orderReviewDidSave, + object: nil, + queue: nil + ) { payload in + guard let orderIdFromEvent = payload.userInfo?["orderId"] as? String else { return } + let currentOrderId = (order?.id ?? orderId) + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + if orderIdFromEvent.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == currentOrderId { + didSaveReviewForCurrentOrder = true + } + } + } + + private func detachReviewSavedObserver() { + guard let reviewSavedObserver else { return } + NotificationCenter.default.removeObserver(reviewSavedObserver) + self.reviewSavedObserver = nil + } + + private var topHeader: some View { + HStack(spacing: 10) { + Circle() + .fill(Color.white.opacity(0.2)) + .frame(width: 28, height: 28) + .overlay( + Circle() + .fill(Color.white.opacity(0.35)) + .frame(width: 14, height: 14) + ) + Text("Acompanhamento em tempo real") + .font(AppTypography.heading2) + .foregroundStyle(Color.white) + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 18) + .background(AppColors.primary) + .clipShape(RoundedRectangle(cornerRadius: 28, style: .continuous)) + } + + private var orderTitleSection: some View { + VStack(alignment: .leading, spacing: 6) { + Text(order?.storeName?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? (order?.storeName ?? "Seu pedido") : "Seu pedido") + .font(AppTypography.heading1) + .foregroundStyle(AppColors.textPrimary) + + Text("Pedido #\(displayOrderTitle)") + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + @ViewBuilder + private var statusBanner: some View { + if let errorMessage, errorMessage.isEmpty == false { + statusBadge( + title: errorMessage, + fg: Color.red, + bg: Color.red.opacity(0.12), + icon: "xmark.octagon.fill" + ) + } else if isLoading { + HStack(spacing: 10) { + ProgressView() + Text("Atualizando status do pedido...") + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + } + .frame(maxWidth: .infinity, alignment: .leading) + } else if isCanceled { + VStack(alignment: .leading, spacing: 10) { + statusBadge( + title: "Pedido cancelado", + fg: Color.red, + bg: Color.red.opacity(0.12), + icon: "xmark.circle.fill" + ) + if cancellationReasonText.isEmpty == false { + Button("Ver motivo do cancelamento") { + showCancellationReason = true + } + .font(AppTypography.heading3) + .foregroundStyle(Color.red) + .buttonStyle(.plain) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } else if isWaitingPayment { + statusBadge( + title: "Aguardando pagamento", + fg: Color(hex: "#A16207"), + bg: Color(hex: "#FDE68A").opacity(0.35), + icon: "clock.fill" + ) + } else { + statusBadge( + title: successBannerTitle, + fg: AppColors.primary, + bg: AppColors.brandSoft, + icon: "checkmark.circle.fill" + ) + } + } + + private var timelineSection: some View { + VStack(alignment: .leading, spacing: 14) { + Text("Progresso do Pedido") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + + if let order { + VStack(alignment: .leading, spacing: 0) { + ForEach(Array(timelineSteps(for: order).enumerated()), id: \.element.id) { index, step in + timelineRow(step: step, isLast: index == timelineSteps(for: order).count - 1) + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(16) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + + private var placeholderCard: some View { + VStack(spacing: 12) { + HStack(spacing: 10) { + Circle() + .fill(AppColors.brandSoft) + .frame(width: 28, height: 28) + .overlay( + Image(systemName: summaryStatusIcon) + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(AppColors.primary) + ) + Text(summaryStatusTitle) + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + Spacer() + } + + Group { + if hasTrackingImage { + Image(trackingImageName) + .renderingMode(.original) + .resizable() + .scaledToFit() + } else if hasPlaceholderProductImage { + Image("placeholder-product") + .renderingMode(.original) + .resizable() + .scaledToFit() + } else { + ZStack { + Color.black.opacity(0.08) + Image(systemName: "shippingbox.fill") + .font(.system(size: 52, weight: .bold)) + .foregroundStyle(AppColors.primary) + } + } + } + .frame(height: 220) + .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(16) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + + private var contactButton: some View { + Button("CONTATO") { + openStoreWhatsApp() + } + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + .frame(maxWidth: .infinity, minHeight: 56) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + .buttonStyle(.plain) + } + + private var reviewButton: some View { + Button("AVALIAR PEDIDO") { + guard let reviewTargetDraft else { return } + reviewDraft = reviewTargetDraft + } + .font(AppTypography.heading2) + .foregroundStyle(Color(hex: "#0E1A06")) + .frame(maxWidth: .infinity, minHeight: 56) + .background(Color(hex: "#7CF02A")) + .clipShape(Capsule()) + .buttonStyle(.plain) + } + + private func statusBadge(title: String, fg: Color, bg: Color, icon: String) -> some View { + HStack(spacing: 8) { + Image(systemName: icon) + .font(.system(size: 14, weight: .bold)) + Text(title) + .font(AppTypography.heading3) + } + .foregroundStyle(fg) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(bg) + .clipShape(Capsule()) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func timelineRow(step: TrackingStep, isLast: Bool) -> some View { + HStack(alignment: .top, spacing: 12) { + VStack(spacing: 0) { + Circle() + .fill(stepDotColor(step)) + .frame(width: 20, height: 20) + .overlay( + Group { + if step.isCompleted { + Image(systemName: "checkmark") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(.white) + } else if step.isActive { + Circle() + .fill(.white) + .frame(width: 8, height: 8) + } else { + Circle() + .stroke(Color(hex: "#C5CBD4"), lineWidth: 2) + .frame(width: 8, height: 8) + } + } + ) + + if isLast == false { + Rectangle() + .fill(stepLineColor(step)) + .frame(width: 2, height: 36) + } + } + + VStack(alignment: .leading, spacing: 2) { + Text(step.title) + .font(AppTypography.heading2) + .foregroundStyle(stepTitleColor(step)) + + if step.subtitle.isEmpty == false { + Text(step.subtitle) + .font(AppTypography.body) + .foregroundStyle(stepSubtitleColor(step)) + } + + if let time = step.time, time.isEmpty == false { + Text(time) + .font(AppTypography.caption) + .foregroundStyle(AppColors.textMuted) + } + } + Spacer() + } + } + + private func stepDotColor(_ step: TrackingStep) -> Color { + if isCanceled { + return Color(hex: "#C5CBD4") + } + if isWaitingPayment && step.id == "paid" { + return Color(hex: "#F59E0B") + } + if step.isCompleted || step.isActive { + return AppColors.primary + } + return Color(hex: "#E5E7EB") + } + + private func stepLineColor(_ step: TrackingStep) -> Color { + if isCanceled { return Color(hex: "#E5E7EB") } + if step.isCompleted || step.isActive { + return AppColors.primary.opacity(0.85) + } + return Color(hex: "#E5E7EB") + } + + private func stepTitleColor(_ step: TrackingStep) -> Color { + if isCanceled { return Color(hex: "#9CA3AF") } + if step.isCompleted || step.isActive { + return AppColors.textPrimary + } + return Color(hex: "#9CA3AF") + } + + private func stepSubtitleColor(_ step: TrackingStep) -> Color { + if isCanceled { return Color(hex: "#9CA3AF") } + if isWaitingPayment && step.id == "paid" { + return Color(hex: "#A16207") + } + if step.isCompleted || step.isActive { + return AppColors.primary + } + return Color(hex: "#9CA3AF") + } + + private func timelineSteps(for order: PublicOrderResult) -> [TrackingStep] { + let isPickup = normalized(order.deliveryType ?? order.deliveryTypeLabel).contains("PICKUP") + let allSteps: [(id: String, title: String, subtitle: String, statuses: [String])] = [ + ("confirmed", "Pedido confirmado", "Seu pedido foi recebido", ["PENDING", "ACCEPTED", "PAYMENT_PENDING"]), + ("preparing", "Pedido em produção", "Seu pedido está sendo preparado", ["PREPARING"]), + ("ready", isPickup ? "Pronto para retirada" : "Pronto para entrega", isPickup ? "Retire no balcão quando avisarmos" : "Pedido pronto para sair", ["READY"]), + ("delivering", "Em rota de entrega", customerOtpSubtitle, ["DELIVERING", "OUT_FOR_DELIVERY", "EM_ROTA", "ON_ROUTE"]), + ("completed", isPickup ? "Retirado" : "Entregue", "Pedido finalizado", ["COMPLETED", "DELIVERED"]) + ] + + let stepsBase = isPickup ? allSteps.filter { $0.id != "delivering" } : allSteps + let currentIndex = currentStepIndex(isPickup: isPickup) + + return stepsBase.enumerated().map { index, step in + let event = timelineEvent(for: order, statuses: step.statuses) + let isCompleted = event?.completed ?? (isCanceled == false && index < currentIndex) + let isActive = event?.active ?? (isCanceled == false && index == currentIndex) + return TrackingStep( + id: step.id, + title: step.title, + subtitle: timelineSubtitle(stepId: step.id, fallback: step.subtitle, eventLabel: event?.label), + time: formatTime(event?.time), + isCompleted: isCompleted || (index == currentIndex && isCompletedTerminalStep(index: index, isPickup: isPickup)), + isActive: isActive && isCompletedTerminalStep(index: index, isPickup: isPickup) == false + ) + } + } + + private func currentStepIndex(isPickup: Bool) -> Int { + let normalizedStatus = normalized(order?.status) + + if isCanceled { + if normalizedStatus.contains("DELIVER") || normalizedStatus.contains("ROTA") { return isPickup ? 2 : 3 } + if normalizedStatus.contains("READY") { return 2 } + if normalizedStatus.contains("PREPAR") || normalizedStatus.contains("ACCEPT") || normalizedStatus.contains("PENDING") { return 1 } + return 0 + } + + if isWaitingPayment { + return 0 + } + + if let timelineIndex = timelineProgressStepIndex(isPickup: isPickup) { + return timelineIndex + } + + if normalizedStatus.contains("COMPLETED") || normalizedStatus.contains("DELIVERED") { + return isPickup ? 3 : 4 + } + if isPickup { + if normalizedStatus.contains("READY") { return 2 } + if normalizedStatus.contains("PREPAR") { return 1 } + return 0 + } + + if normalizedStatus.contains("DELIVER") || normalizedStatus.contains("ROTA") { + return 3 + } + if normalizedStatus.contains("READY") { + return 2 + } + if normalizedStatus.contains("PREPAR") { + return 1 + } + return 0 + } + + private func timelineProgressStepIndex(isPickup: Bool) -> Int? { + guard let order else { return nil } + + let stepStatuses: [[String]] = isPickup + ? [ + ["PENDING", "ACCEPTED", "PAYMENT_PENDING"], + ["PREPARING"], + ["READY"], + ["COMPLETED", "DELIVERED"] + ] + : [ + ["PENDING", "ACCEPTED", "PAYMENT_PENDING"], + ["PREPARING"], + ["READY"], + ["DELIVERING", "OUT_FOR_DELIVERY", "EM_ROTA", "ON_ROUTE"], + ["COMPLETED", "DELIVERED"] + ] + + var strongestIndex: Int? = nil + var fallbackIndex: Int? = nil + + for (index, statuses) in stepStatuses.enumerated() { + let statusSet = Set(statuses.map(normalized)) + let events = order.timeline.filter { event in + statusSet.contains(normalized(event.status)) + } + guard events.isEmpty == false else { continue } + + fallbackIndex = index + + if events.contains(where: { $0.active == true || $0.completed == true }) { + strongestIndex = index + } + } + + return strongestIndex ?? fallbackIndex + } + + private func isCompletedTerminalStep(index: Int, isPickup: Bool) -> Bool { + let terminalIndex = isPickup ? 3 : 4 + return isCanceled == false && currentStepIndex(isPickup: isPickup) >= terminalIndex && index == terminalIndex + } + + private func timelineEvent(for order: PublicOrderResult, statuses: [String]) -> PublicOrderTimelineEvent? { + let statusSet = Set(statuses.map(normalized)) + return order.timeline.first(where: { statusSet.contains(normalized($0.status)) }) + } + + private func timelineSubtitle(stepId: String, fallback: String, eventLabel: String?) -> String { + if stepId == "delivering", customerOtpCode != nil { + return customerOtpSubtitle + } + let label = (eventLabel ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if shouldUseTimelineEventLabel(label, fallback: fallback) { + return label + } + return fallback + } + + private func shouldUseTimelineEventLabel(_ label: String, fallback: String) -> Bool { + guard label.isEmpty == false else { return false } + + let foldedLabel = label + .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) + .lowercased() + let foldedFallback = fallback + .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) + .lowercased() + + if foldedLabel == foldedFallback { return false } + + let englishHints = [ + "order", + "confirmed", + "in progress", + "progress", + "delivery", + "delivered", + "ready", + "sent", + "out for", + "began" + ] + if englishHints.contains(where: { foldedLabel.contains($0) }) { + return false + } + + return true + } + + private var customerOtpSubtitle: String { + if let otp = customerOtpCode { + return "Código para o entregador: \(otp)" + } + return "Aguardando saída para entrega" + } + + private var customerOtpCode: String? { + let raw = (order?.customerOtp ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if raw.isEmpty { return nil } + let digits = raw.filter(\.isNumber) + if digits.count == 4 { + return digits + } + return nil + } + + private var displayOrderTitle: String { + if let short = order?.shortId, short.isEmpty == false { return short } + if let orderIdValue = order?.id, orderIdValue.isEmpty == false { return orderIdValue } + if let initialShortId, initialShortId.isEmpty == false { return initialShortId } + return String(orderId.prefix(6)) + } + + private var isCanceled: Bool { + normalized(order?.status).contains("CANCEL") + } + + private var isWaitingPayment: Bool { + let paymentStatus = normalized(order?.paymentStatus) + if isOnlinePaymentMethod == false { + return false + } + if paymentStatus == "PENDING" { + return true + } + return order?.isPaymentConfirmed == false + } + + private var isOnlinePaymentMethod: Bool { + let code = normalized(order?.paymentMethodCode) + if code == "PIX" || code == "CREDIT_CARD" { + return true + } + return false + } + + private var successBannerTitle: String { + if isCompletedOrder { + if isPickupOrder { + return "Pedido retirado" + } + return "Pedido entregue" + } + if isOnlinePaymentMethod { + return "Pagamento confirmado" + } + return "Pedido confirmado" + } + + private var summaryStatusTitle: String { + if isCanceled { + return "Seu pedido foi cancelado" + } + if isWaitingPayment { + return "Aguardando confirmação de pagamento" + } + if isCompletedOrder { + return isPickupOrder ? "Seu pedido foi retirado" : "Seu pedido foi entregue" + } + return "Seu pedido está em andamento" + } + + private var summaryStatusIcon: String { + if isCanceled { + return "xmark" + } + if isWaitingPayment { + return "clock.fill" + } + return "checkmark" + } + + private var isPickupOrder: Bool { + normalized(order?.deliveryType ?? order?.deliveryTypeLabel).contains("PICKUP") + } + + private var isCompletedOrder: Bool { + let status = normalized(order?.status) + if status.contains("COMPLETED") || status.contains("DELIVERED") { + return true + } + + let stepIndex = currentStepIndex(isPickup: isPickupOrder) + let terminalIndex = isPickupOrder ? 3 : 4 + return isCanceled == false && isWaitingPayment == false && stepIndex >= terminalIndex + } + + private var shouldShowReviewButton: Bool { + guard isCompletedOrder else { return false } + guard isCanceled == false else { return false } + guard let reviewTargetDraft else { return false } + if didSaveReviewForCurrentOrder { return false } + if hasPersistedReviewForCurrentOrder { return false } + return order?.review == nil + } + + private var hasPersistedReviewForCurrentOrder: Bool { + reviewIdCandidates.contains { candidate in + SessionStateStore.hasOrderReview(orderId: candidate) + } + } + + private var reviewIdCandidates: [String] { + let values = [ + orderId, + order?.id, + order?.realId, + order?.shortId + ] + var unique: [String] = [] + var seen = Set() + for raw in values { + let normalized = (raw ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + guard normalized.isEmpty == false, seen.contains(normalized) == false else { continue } + seen.insert(normalized) + unique.append(normalized) + } + return unique + } + + private var reviewTargetDraft: ReviewDraft? { + let idFromOrder = (order?.id ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let id = idFromOrder.isEmpty ? orderId : idFromOrder + guard id.isEmpty == false else { return nil } + + return ReviewDraft( + orderId: id, + storeId: order?.storeId, + shortId: order?.shortId ?? initialShortId, + storeName: order?.storeName, + storeLogoURL: order?.storeLogoURL, + createdAt: order?.createdAt, + total: order?.total + ) + } + + private var cancellationReasonText: String { + let value = (order?.cancellationReason ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? "Sem detalhe informado." : value + } + + private var hasTrackingImage: Bool { + imageResourceExists(trackingImageName) + } + + private var hasPlaceholderProductImage: Bool { + imageResourceExists("placeholder-product") + } + + private func imageResourceExists(_ name: String) -> Bool { + UIImage(named: name) != nil + } + + private var trackingImageName: String { + let isPickup = normalized(order?.deliveryType ?? order?.deliveryTypeLabel).contains("PICKUP") + let stepIndex = currentStepIndex(isPickup: isPickup) + + if isCanceled { + return "tracking-canceled" + } + if isWaitingPayment { + return "tracking-pending" + } + switch stepIndex { + case 0: + return "tracking-pending" + case 1: + return "tracking-preparing" + case 2: + return "tracking-ready" + case 3: + return "tracking-delivering" + default: + return "tracking-completed" + } + } + + private func normalized(_ value: String?) -> String { + (value ?? "") + .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) + .uppercased() + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func formatTime(_ rawValue: String?) -> String? { + guard let rawValue else { return nil } + let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + if value.isEmpty { return nil } + + if value.contains("T"), let isoTime = formatISOTime(value) { + return isoTime + } + if value.range(of: #"^\d{2}:\d{2}"#, options: .regularExpression) != nil { + return String(value.prefix(5)) + } + return value + } + + private func formatISOTime(_ value: String) -> String? { + let iso = ISO8601DateFormatter() + iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + var date = iso.date(from: value) + if date == nil { + iso.formatOptions = [.withInternetDateTime] + date = iso.date(from: value) + } + guard let date else { return nil } + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "pt_BR") + formatter.dateFormat = "HH:mm" + return formatter.string(from: date) + } + + @MainActor + private func loadInitialOrder() async { + logger.info("OrderTracking initial fetch orderId=\(orderId)") + + do { + let response = try await ApiService().publicOrder(orderId: orderId) + if response.error { + errorMessage = response.message ?? "Não foi possível carregar o pedido." + logger.error("OrderTracking initial fetch API error orderId=\(orderId) message=\(response.message ?? "unknown")") + } else if let result = response.result { + order = result + storeContactPhone = result.storePhone + errorMessage = nil + logger.info("OrderTracking initial fetch success orderId=\(orderId) status=\(result.status ?? "nil") paymentStatus=\(result.paymentStatus ?? "nil")") + await refreshStoreContactPhone(for: result) + } + } catch { + errorMessage = "Não foi possível carregar o pedido." + logger.error("OrderTracking initial fetch failure orderId=\(orderId) error=\(error.localizedDescription)") + } + + isLoading = false + } + + /// Touchpoint 2 of docs/api/push-notifications-integration-guide.md §2b — + /// last practical moment to recover an opted-out user before order-status + /// push (§6, `type: "order_status"`) goes silent for them for this order. + @MainActor + private func maybePromptPushOptIn() async { + guard showPushOptInAlert == false, isWaitingPayment == false, isCanceled == false else { return } + guard SessionStateStore.shouldPromptPushOptIn() else { return } + + let osAuthorized = await PushNotificationCoordinator.shared.currentAuthorizationState() == .authorized + var serverEnabled = false + if let profileResponse = try? await ApiService().profile(), profileResponse.error == false { + serverEnabled = profileResponse.result?.notificationsEnabled ?? false + } + guard osAuthorized == false || serverEnabled == false else { return } + + SessionStateStore.recordPushOptInPrompted() + showPushOptInAlert = true + } + + @MainActor + private func enablePushNotifications() async { + let profile = await PushNotificationCoordinator.shared.enableNotifications() + if profile?.notificationsEnabled != true { + SnackbarCenter.shared.show( + title: "Ative notificações nos Ajustes do iPhone para acompanhar seu pedido.", + style: .warning, + icon: "bell.slash.fill", + duration: 3.5 + ) + } + } + + @MainActor + private func refreshStoreContactPhone(for order: PublicOrderResult) async { + guard let storeId = order.storeId?.trimmingCharacters(in: .whitespacesAndNewlines), storeId.isEmpty == false else { + if let inlinePhone = order.storePhone, inlinePhone.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false { + storeContactPhone = inlinePhone + } + return + } + + do { + let response = try await ApiService().storeInfo(storeId: storeId) + if response.error == false, let result = response.result { + let phone = (result.whatsapp ?? result.phone ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if phone.isEmpty == false { + storeContactPhone = phone + return + } + } + } catch { + // Fallback handled below. + } + + if let inlinePhone = order.storePhone, inlinePhone.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false { + storeContactPhone = inlinePhone + } + } + + private func openStoreWhatsApp() { + guard let phoneRaw = storeContactPhone, + let url = makeWhatsAppURL(from: phoneRaw) else { + SnackbarCenter.shared.show( + title: "Telefone da loja indisponível.", + style: .warning, + icon: "exclamationmark.triangle.fill", + duration: 2.8 + ) + return + } + openURL(url) + } + + private func makeWhatsAppURL(from phoneRaw: String) -> URL? { + var digits = phoneRaw.filter(\.isNumber) + if digits.isEmpty { return nil } + + if digits.hasPrefix("0") { + digits = String(digits.drop(while: { $0 == "0" })) + } + + if digits.hasPrefix("55") == false && (digits.count == 10 || digits.count == 11) { + digits = "55" + digits + } + + guard digits.count >= 12 else { return nil } + return URL(string: "https://wa.me/\(digits)") + } +} diff --git a/PediFoods/Views/Main/OrdersView.swift b/PediFoods/Views/Main/OrdersView.swift new file mode 100644 index 0000000..d2b9956 --- /dev/null +++ b/PediFoods/Views/Main/OrdersView.swift @@ -0,0 +1,884 @@ +import SwiftUI +#if canImport(LCEssentials) +import LCEssentials +#endif + +struct OrdersView: View { + @Binding var appState: AppState + @Environment(\.dismiss) var dismiss + @State var isLoading = false + @State var errorMessage: String? = nil + @State var orders: [AppOrderSummary] = [] + @State var hasLoadedOnce = false + @State var storeRatingByStoreId: [String: Double] = [:] + @State var storeRatingByStoreName: [String: Double] = [:] + @State var storeLogoByStoreId: [String: String] = [:] + @State var storeLogoByStoreName: [String: String] = [:] + @State var selectedOrderRoute: OrderRouteContext? = nil + + var body: some View { + LCENavigationView { + content + } + .setLeftButton(image: AnyView(AppBackButtonIcon())) { dismiss() } + .setTitle(text: Text("Meus Pedidos").font(AppTypography.heading2).foregroundStyle(AppColors.textPrimary)) + .setNavigationBarBackgroundColor(AppColors.backgroundLight) + .buttonStyle(.plain) + } + + private var content: some View { + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 14) { + if isLoading { + ProgressView() + .frame(maxWidth: .infinity) + .padding(.top, 24) + } else if let errorMessage, errorMessage.isEmpty == false { + Text(errorMessage) + .font(AppTypography.body) + .foregroundStyle(Color.red) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .padding(.top, 24) + } else if orders.isEmpty { + Text("Nenhum pedido encontrado.") + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + .frame(maxWidth: .infinity) + .padding(.top, 24) + } else { + ForEach(orders) { order in + orderCard(order) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 20) + .padding(.top, 18) + .padding(.bottom, UIDevice.bottomNotch + 18) + } + .frame(maxWidth: .infinity) + .background(AppColors.backgroundLight) + .navigationBarBackButtonHidden(true) + .appHiddenNavigationBar() + .task { + await loadOrdersIfNeeded() + await refreshStoreRatings() + } + .onAppear { + if let pending = appState.pendingOrderDeepLink { + appState.pendingOrderDeepLink = nil + selectedOrderRoute = pending + } + } + .refreshable { + // Decoupled from .refreshable's own cancellable wrapping Task — + // see StoreDetailView's .refreshable for why. + await Task { + await loadOrders(force: true) + await refreshStoreRatings() + }.value + } + .navigationDestination(item: $selectedOrderRoute) { context in + OrderEntryDestinationView( + orderId: context.orderId, + initialShortId: context.shortId, + fallbackPaymentMethod: context.paymentMethod, + fallbackTotal: context.total, + routeIntent: context.intent, + appState: $appState + ) + } + } + + private func orderCard(_ order: AppOrderSummary) -> some View { + let status = orderVisualStatus(for: order) + let detailsRoute = OrderRouteContext( + orderId: trackingOrderId(for: order), + shortId: order.shortId, + paymentMethod: order.paymentMethod, + total: order.total, + intent: .details + ) + let trackingRoute = OrderRouteContext( + orderId: trackingOrderId(for: order), + shortId: order.shortId, + paymentMethod: order.paymentMethod, + total: order.total, + intent: .tracking + ) + + return VStack(alignment: .leading, spacing: 14) { + HStack(spacing: 12) { + AsyncStoreImage(imageURL: resolvedMediaURL(storeLogoURL(for: order))) + .frame(width: 80, height: 80) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + + VStack(alignment: .leading, spacing: 5) { + + HStack(spacing: 6) { + Text(order.storeName?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? (order.storeName ?? "Pedido") : "Pedido #\(order.shortId ?? order.id)") + .font(AppTypography.heading2) + .minimumScaleFactor(0.01) + .foregroundStyle(AppColors.textPrimary) + .lineLimit(1) + Spacer() + Text(status.badgeTitle) + .font(AppTypography.caption) + .minimumScaleFactor(0.01) + .foregroundStyle(status.badgeForeground) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(status.badgeBackground) + .clipShape(Capsule()) + } + + HStack(spacing: 6) { + Text(orderMetaText(order)) + .font(AppTypography.body) + .minimumScaleFactor(0.01) + .foregroundStyle(AppColors.textMuted) + .lineLimit(1) + if let rating = storeRating(for: order) { + Text("•") + .font(AppTypography.body) + .minimumScaleFactor(0.01) + .foregroundStyle(AppColors.textMuted) + Image(systemName: "star.fill") + .font(.system(size: 11, weight: .bold)) + .minimumScaleFactor(0.01) + .foregroundStyle(Color(hex: "#7CF02A")) + Text(String(format: "%.1f", rating).replacingOccurrences(of: ".", with: ",")) + .font(AppTypography.body) + .minimumScaleFactor(0.01) + .foregroundStyle(AppColors.textMuted) + } + } + } + } + + Divider() + + HStack(spacing: 12) { + Button(status.isCanceled ? "Ajuda" : "Ver Detalhes") { + selectedOrderRoute = detailsRoute + } + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textMuted) + .lineLimit(1) + .minimumScaleFactor(0.5) + .buttonStyle(.plain) + .appLayoutPriority(0) + + Spacer(minLength: 8) + + Button { + if status.isInProgress { + selectedOrderRoute = trackingRoute + return + } + SnackbarCenter.shared.show( + title: "Recompra será integrada com o catálogo em breve.", + style: .info, + icon: "cart.badge.plus", + duration: 2.0 + ) + } label: { + HStack(spacing: 8) { + Image(systemName: status.isInProgress ? "truck.box.fill" : "arrow.clockwise") + Text(status.isInProgress ? "Acompanhar" : "Pedir Novamente") + .font(AppTypography.heading3) + .lineLimit(1) + .minimumScaleFactor(0.5) + } + .lineLimit(1) + //.frame(minWidth: status.isInProgress ? 136 : 184) + .foregroundStyle(status.actionForeground) + .padding(.horizontal, 14) + .padding(.vertical, 11) + .background(status.actionBackground) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + .appLayoutPriority(2) + } + } + .padding(18) + .background(AppColors.surface) + .overlay(alignment: .leading) { + if status.isInProgress { + RoundedRectangle(cornerRadius: 3, style: .continuous) + .fill(Color(hex: "#C8F06E")) + .frame(width: 5) + .padding(.vertical, 20) + } + } + .clipShape(RoundedRectangle(cornerRadius: 28, style: .continuous)) + } + + private func orderVisualStatus(for order: AppOrderSummary) -> OrderRowStatusStyle { + let rawDetailed = (order.statusDetailed ?? "").trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + let rawStatus = (order.status ?? "").trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + + let technical = [rawDetailed, rawStatus].joined(separator: "|") + if technical.contains("CANCEL") || technical.contains("REFUND") { + return .canceled + } + if technical.contains("COMPLETED") || technical.contains("DELIVERED") { + return .delivered + } + if technical.contains("IN_DELIVERY") + || technical.contains("DELIVERING") + || technical.contains("OUT_FOR_DELIVERY") + || technical.contains("PENDING") + || technical.contains("ACCEPTED") + || technical.contains("PREPAR") + || technical.contains("READY") { + return .inProgress + } + + let label = (order.statusLabel ?? "").trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + if label.contains("CANCEL") { + return .canceled + } + if label.contains("CONCLU") || label.contains("ENTREGUE") { + return .delivered + } + return .inProgress + } + + private func formatCurrency(_ value: Double) -> String { + String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",") + } + + private func orderMetaText(_ order: AppOrderSummary) -> String { + let dateText = formatOrderDate(order.createdAt) ?? "Agora" + let totalText = formatCurrency(order.total ?? 0) + return "\(dateText) • \(totalText)" + } + + private func formatOrderDate(_ isoValue: String?) -> String? { + guard let isoValue, isoValue.isEmpty == false else { return nil } + let iso = ISO8601DateFormatter() + iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + var date = iso.date(from: isoValue) + if date == nil { + iso.formatOptions = [.withInternetDateTime] + date = iso.date(from: isoValue) + } + guard let date else { return nil } + + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "pt_BR") + formatter.dateFormat = "dd MMM, HH:mm" + return formatter.string(from: date) + } + + private func trackingOrderId(for order: AppOrderSummary) -> String { + let orderCandidate = (order.orderId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if orderCandidate.isEmpty == false { + return orderCandidate + } + let candidate = (order.realId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if candidate.isEmpty == false { + return candidate + } + return order.id + } + + private func normalizedOrderId(_ value: String?) -> String { + (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } + + private func normalizedStoreName(_ value: String?) -> String { + (value ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) + .lowercased() + } + + @MainActor + private func loadOrdersIfNeeded() async { + guard hasLoadedOnce == false else { return } + await loadOrders(force: false) + } + + @MainActor + private func loadOrders(force: Bool) async { + if isLoading { return } + if force == false, hasLoadedOnce { return } + + isLoading = true + errorMessage = nil + let previousOrders = orders + + var trackedMapped: [AppOrderSummary] = [] + let cachedTracked = SessionStateStore.loadTrackedOrders() + if cachedTracked.isEmpty == false { + trackedMapped = cachedTracked.map { + AppOrderSummary.fromTracked($0) + } + if hasLoadedOnce == false, previousOrders.isEmpty { + orders = mergeOrders(apiOrders: [], trackedOrders: trackedMapped) + } + } + + do { + let response = try await ApiService().listOrders(forceRefresh: force) + if response.error { + if previousOrders.isEmpty == false { + orders = previousOrders + } + errorMessage = response.message ?? "Não foi possível carregar os pedidos." + } else { + let remote = response.result ?? [] + orders = mergeOrders(apiOrders: remote, trackedOrders: trackedMapped) + } + } catch { + if isCancelledRequest(error) { + if previousOrders.isEmpty == false { + orders = previousOrders + } + isLoading = false + return + } + if orders.isEmpty { + errorMessage = "Não foi possível carregar os pedidos." + } + } + + isLoading = false + hasLoadedOnce = true + } + + private func mergeOrders(apiOrders: [AppOrderSummary], trackedOrders: [AppOrderSummary]) -> [AppOrderSummary] { + var map: [String: AppOrderSummary] = [:] + var sourceRank: [String: Int] = [:] + + for (index, item) in trackedOrders.enumerated() { + let key = identityKey(for: item) + map[key] = item + if sourceRank[key] == nil { + sourceRank[key] = 10_000 + index + } + } + + for (index, item) in apiOrders.enumerated() { + let key = identityKey(for: item) + map[key] = item + sourceRank[key] = index + } + + return map.values.sorted { lhs, rhs in + let leftDate = orderDateSortValue(lhs) + let rightDate = orderDateSortValue(rhs) + if leftDate != rightDate { + return leftDate > rightDate + } + + let leftRank = sourceRank[identityKey(for: lhs)] ?? Int.max + let rightRank = sourceRank[identityKey(for: rhs)] ?? Int.max + if leftRank != rightRank { + return leftRank < rightRank + } + + let leftNumericId = Int(lhs.id) + let rightNumericId = Int(rhs.id) + if let leftNumericId, let rightNumericId, leftNumericId != rightNumericId { + return leftNumericId > rightNumericId + } + return lhs.id.localizedCompare(rhs.id) == .orderedDescending + } + } + + private func identityKey(for order: AppOrderSummary) -> String { + let raw = trackingOrderId(for: order) + return raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } + + private func orderDateSortValue(_ order: AppOrderSummary) -> Date { + parseDateForSort(order.updatedAt) + ?? parseDateForSort(order.createdAt) + ?? .distantPast + } + + private func parseDateForSort(_ rawValue: String?) -> Date? { + guard let rawValue else { return nil } + let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + if value.isEmpty { return nil } + + let iso = ISO8601DateFormatter() + iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = iso.date(from: value) { return date } + iso.formatOptions = [.withInternetDateTime] + if let date = iso.date(from: value) { return date } + + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "pt_BR") + + let formats = [ + "yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX", + "yyyy-MM-dd'T'HH:mm:ssXXXXX", + "yyyy-MM-dd HH:mm:ss", + "yyyy-MM-dd'T'HH:mm:ss", + "yyyy-MM-dd'T'HH:mm:ss.SSSZ", + "yyyy-MM-dd HH:mm:ss Z", + "dd/MM/yyyy HH:mm:ss", + "dd/MM/yyyy HH:mm", + "dd/MM/yyyy" + ] + for format in formats { + formatter.dateFormat = format + if let date = formatter.date(from: value) { + return date + } + } + + return nil + } + + private func isCancelledRequest(_ error: Error) -> Bool { + if error is CancellationError { + return true + } + + if let networkError = error as? NetworkError, + case .transportError(let message) = networkError { + let normalized = message.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return normalized.contains("cancel") + } + + if let networkError = error as? NetworkError, + case .cancelled = networkError { + return true + } + + return error.localizedDescription.lowercased().contains("cancel") + } + + @MainActor + private func refreshStoreRatings() async { + var storeList: [StoreSummary] = AppContentCache.shared.value( + for: AppCacheKey.homeStoresLatestSnapshot, + as: [StoreSummary].self + ) ?? [] + + if storeList.isEmpty { + let response = try? await ApiService().listStores() + storeList = response?.result ?? [] + } + + var byId: [String: Double] = [:] + var byName: [String: Double] = [:] + var logoById: [String: String] = [:] + var logoByName: [String: String] = [:] + for store in storeList { + let storeId = normalizedOrderId(store.id) + let nameKey = normalizedStoreName(store.name) + if let rating = store.rating, rating > 0 { + if storeId.isEmpty == false { byId[storeId] = rating } + if nameKey.isEmpty == false { byName[nameKey] = rating } + } + if let logo = store.logo, logo.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false { + if storeId.isEmpty == false { logoById[storeId] = logo } + if nameKey.isEmpty == false { logoByName[nameKey] = logo } + } + } + + storeRatingByStoreId = byId + storeRatingByStoreName = byName + storeLogoByStoreId = logoById + storeLogoByStoreName = logoByName + } + + private func storeRating(for order: AppOrderSummary) -> Double? { + let storeId = normalizedOrderId(order.storeId) + if storeId.isEmpty == false, let fromId = storeRatingByStoreId[storeId] { + return fromId + } + let nameKey = normalizedStoreName(order.storeName) + if nameKey.isEmpty == false, let fromName = storeRatingByStoreName[nameKey] { + return fromName + } + return nil + } + + private func storeLogoURL(for order: AppOrderSummary) -> String? { + if let url = order.storeLogoURL, url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false { + return url + } + let storeId = normalizedOrderId(order.storeId) + if storeId.isEmpty == false, let logo = storeLogoByStoreId[storeId] { return logo } + let nameKey = normalizedStoreName(order.storeName) + if nameKey.isEmpty == false, let logo = storeLogoByStoreName[nameKey] { return logo } + return nil + } + + private func resolvedMediaURL(_ raw: String?) -> String? { + ImageSourceResolver.resolve(raw) + } +} + +struct OrderRouteContext: Identifiable, Hashable { + var id: String { "\(orderId)|\(intent.rawValue)" } + let orderId: String + let shortId: String? + let paymentMethod: String? + let total: Double? + let intent: OrderRouteIntent +} + +enum OrderRouteIntent: String, Hashable { + case details + case tracking + case auto +} + +private enum OrderRowStatusStyle { + case delivered + case inProgress + case canceled + + var badgeTitle: String { + switch self { + case .delivered: return "Entregue" + case .inProgress: return "Em andamento" + case .canceled: return "Cancelado" + } + } + + var badgeForeground: Color { + switch self { + case .delivered: return Color(hex: "#16843B") + case .inProgress: return Color(hex: "#B06A28") + case .canceled: return Color(hex: "#D62828") + } + } + + var badgeBackground: Color { + switch self { + case .delivered: return Color(hex: "#E8F7E9") + case .inProgress: return Color(hex: "#FFF2E5") + case .canceled: return Color(hex: "#FDECEC") + } + } + + var actionForeground: Color { + switch self { + case .inProgress: return .white + case .delivered, .canceled: return Color(hex: "#0E1A06") + } + } + + var actionBackground: Color { + switch self { + case .inProgress: return Color(hex: "#111216") + case .delivered, .canceled: return Color(hex: "#C8F06E") + } + } + + var isInProgress: Bool { self == .inProgress } + var isCanceled: Bool { self == .canceled } +} +extension AppOrderSummary { + static func fromTracked(_ tracked: PublicOrderResult) -> AppOrderSummary { + AppOrderSummary( + id: tracked.id, + orderId: tracked.realId ?? tracked.id, + realId: tracked.realId, + storeId: nil, + shortId: tracked.shortId, + total: tracked.total, + status: tracked.status, + statusDetailed: nil, + statusLabel: nil, + nextAction: nil, + paymentStatus: tracked.paymentStatus, + paymentMethod: tracked.paymentMethod, + deliveryType: tracked.deliveryType, + storeName: tracked.storeName, + storePhone: tracked.storePhone, + storeLogoURL: tracked.storeLogoURL, + createdAt: tracked.createdAt, + updatedAt: tracked.updatedAt + ) + } + + init( + id: String, + orderId: String?, + realId: String?, + storeId: String?, + shortId: String?, + total: Double?, + status: String?, + statusDetailed: String?, + statusLabel: String?, + nextAction: String?, + paymentStatus: String?, + paymentMethod: String?, + deliveryType: String?, + storeName: String?, + storePhone: String?, + storeLogoURL: String?, + createdAt: String?, + updatedAt: String? + ) { + self.id = id + self.orderId = orderId + self.realId = realId + self.storeId = storeId + self.shortId = shortId + self.total = total + self.status = status + self.statusDetailed = statusDetailed + self.statusLabel = statusLabel + self.nextAction = nextAction + self.paymentStatus = paymentStatus + self.paymentMethod = paymentMethod + self.deliveryType = deliveryType + self.storeName = storeName + self.storePhone = storePhone + self.storeLogoURL = storeLogoURL + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} + +struct OrderEntryDestinationView: View { + let orderId: String + let initialShortId: String? + let fallbackPaymentMethod: String? + let fallbackTotal: Double? + let routeIntent: OrderRouteIntent + @Binding var appState: AppState + + @State var isResolvingRoute = true + @State var didResolve = false + @State var pixContext: PixPaymentContext? = nil + @State var orderTrackingContext: OrderTrackingContext? = nil + @State var orderDetails: PublicOrderResult? = nil + + var body: some View { + Group { + if isResolvingRoute { + VStack(spacing: 10) { + ProgressView() + Text("Carregando pedido...") + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(AppColors.backgroundLight) + } else if let pixContext { + PaymentPixView( + context: pixContext, + appState: $appState, + onPaymentConfirmed: { + orderTrackingContext = OrderTrackingContext(orderId: pixContext.orderId, shortId: pixContext.shortId) + }, + onOpenTracking: { + orderTrackingContext = OrderTrackingContext(orderId: pixContext.orderId, shortId: pixContext.shortId) + } + ) + } else if let orderDetails { + OrderDetailsView(order: orderDetails, orderId: orderId, initialShortId: initialShortId, appState: $appState) + } else { + OrderTrackingView(orderId: orderId, initialShortId: initialShortId) + } + } + .navigationDestination(item: $orderTrackingContext) { context in + OrderTrackingView(orderId: context.orderId, initialShortId: context.shortId) + } + .task { + guard didResolve == false else { return } + didResolve = true + await resolveRoute() + } + } + + @MainActor + func resolveRoute() async { + defer { isResolvingRoute = false } + + let order = await fetchOrderForRouting() + guard let order else { return } + + if routeIntent == .details { + orderDetails = order + return + } + + // For both .tracking and .auto: show payment screen if payment is still pending. + // Timeline only shows once payment is confirmed or method is off-app. + if shouldOpenPaymentScreen(for: order) { + let normalizedMethod = normalizePaymentMethod(order) + if normalizedMethod.contains("PIX") { + let pixFromPayment = order.payment?.pix + let pixFromPayload = order.paymentPayload + let copyPaste = (pixFromPayment?.copyPaste?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) + ? pixFromPayment?.copyPaste + : pixFromPayload?.copyPaste + let qrCodeImage = (pixFromPayment?.qrCodeImage?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) + ? pixFromPayment?.qrCodeImage + : pixFromPayload?.qrCodeImage + let expirationDate = (pixFromPayment?.expirationDate?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) + ? pixFromPayment?.expirationDate + : pixFromPayload?.expirationDate + let storeId = order.storeId ?? "" + pixContext = PixPaymentContext( + id: order.id, + orderId: order.id, + shortId: order.shortId ?? initialShortId, + storeId: storeId, + copyPaste: copyPaste?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + ? (copyPaste ?? "") + : "Código PIX indisponível no momento. Aguarde e tente novamente.", + qrCodeImageBase64: qrCodeImage, + expirationDate: expirationDate, + total: order.total ?? 0, + profileName: "", + profileEmail: "", + profilePhone: "", + addressZip: nil, + addressNumber: nil, + deliveryType: order.deliveryType ?? "DELIVERY", + itemsJSON: "[]" + ) + return + } + if normalizedMethod == "CREDIT_CARD" || normalizedMethod == "DEBIT_CARD" { + orderTrackingContext = OrderTrackingContext(orderId: order.id, shortId: order.shortId ?? initialShortId) + return + } + } + + // For .auto only: route terminal/canceled orders to details instead of timeline. + if routeIntent == .auto && shouldOpenOrderDetails(for: order) { + orderDetails = order + } + // .tracking (and .auto fallthrough) → nil states → body renders OrderTrackingView + } + + @MainActor + func fetchOrderForRouting() async -> PublicOrderResult? { + logger.info("OrderEntry fetch route orderId=\(orderId)") + do { + let response = try await ApiService().publicOrder(orderId: orderId) + if response.error == false, let result = response.result { + logger.info("OrderEntry fetch route success orderId=\(orderId) status=\(result.status ?? "nil") paymentStatus=\(result.paymentStatus ?? "nil")") + return result + } + logger.error("OrderEntry fetch route API error orderId=\(orderId) message=\(response.message ?? "unknown")") + } catch { + logger.error("OrderEntry fetch route failure orderId=\(orderId) error=\(error.localizedDescription)") + } + return nil + } + + func isOnlinePaymentMethod(_ order: PublicOrderResult) -> Bool { + let method = normalizePaymentMethod(order) + return method == "PIX" || method == "CREDIT_CARD" || method == "DEBIT_CARD" + } + + func shouldOpenOrderDetails(for order: PublicOrderResult) -> Bool { + let status = normalize(order.status) + if status.contains("CANCEL") { return true } + if status.contains("COMPLETED") || status.contains("DELIVERED") { return true } + return false + } + + func shouldOpenPaymentScreen(for order: PublicOrderResult) -> Bool { + guard order.isPaymentConfirmed == false else { return false } + guard isOnlinePaymentMethod(order) else { return false } + guard isPaymentPending(order) else { return false } + guard isInStorePayment(order) == false else { return false } + + let status = normalize(order.status) + if status.contains("PREPAR") || + status.contains("READY") || + status.contains("DELIVER") || + status.contains("ROTA") || + status.contains("COMPLETED") || + status.contains("CANCEL") || + status.contains("REFUND") { + return false + } + + let paymentStatus = normalize(order.paymentStatus) + if paymentStatus.contains("CONFIRM") || + paymentStatus.contains("PAID") || + paymentStatus.contains("RECEIV") || + paymentStatus.contains("APPROV") { + return false + } + + let method = normalizePaymentMethod(order) + if method == "PIX" { + return hasPixPayload(order) + } + return method == "CREDIT_CARD" || method == "DEBIT_CARD" + } + + func hasPixPayload(_ order: PublicOrderResult) -> Bool { + let fromPayment = (order.payment?.pix?.copyPaste ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + if fromPayment.isEmpty == false { return true } + + let fromPayload = (order.paymentPayload?.copyPaste ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + return fromPayload.isEmpty == false + } + + func isPaymentPending(_ order: PublicOrderResult) -> Bool { + let status = normalize(order.status) + let paymentStatus = normalize(order.paymentStatus) + let nextAction = normalize(order.nextAction) + + if status.contains("PAYMENT_PENDING") { + return true + } + if paymentStatus.contains("PENDING") { + return true + } + if nextAction.contains("PAY") || nextAction.contains("PAYMENT") { + return true + } + return false + } + + func isInStorePayment(_ order: PublicOrderResult) -> Bool { + let nextAction = normalize(order.nextAction) + if nextAction.contains("TRACK") || nextAction.contains("DELIVER") { + return true + } + + let status = normalize(order.status) + if status.contains("PREPAR") || + status.contains("READY") || + status.contains("DELIVER") || + status.contains("ROTA") || + status.contains("OUT_FOR_DELIVERY") { + return true + } + return false + } + + func normalizePaymentMethod(_ order: PublicOrderResult) -> String { + let first = normalize(order.paymentMethodCode) + if first.isEmpty == false { + return first + } + let second = normalize(order.paymentMethod) + if second.isEmpty == false { + return second + } + return normalize(fallbackPaymentMethod) + } + + func normalize(_ value: String?) -> String { + (value ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + .uppercased() + } +} diff --git a/PediFoods/Views/Main/PizzaFlavorAddonsSheet.swift b/PediFoods/Views/Main/PizzaFlavorAddonsSheet.swift new file mode 100644 index 0000000..912e254 --- /dev/null +++ b/PediFoods/Views/Main/PizzaFlavorAddonsSheet.swift @@ -0,0 +1,114 @@ +import Foundation +import SwiftUI +#if canImport(LCEssentials) +import LCEssentials +#endif + +struct PizzaFlavorAddonsSheet: View { + let flavor: StoreCatalogProduct + @Binding var quantities: [String: Int] + + @Environment(\.dismiss) var dismiss + + var body: some View { + LCENavigationView { + content + } + .setLeftButton(image: AnyView(AppBackButtonIcon())) { dismiss() } + .setTitle(text: Text("Adicionais").font(AppTypography.heading2).foregroundStyle(AppColors.textPrimary)) + .setNavigationBarBackgroundColor(AppColors.backgroundLight) + .buttonStyle(.plain) + } + + private var content: some View { + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 12) { + Text(flavor.name) + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + + if flavor.addonGroups.isEmpty { + Text("Este sabor não possui adicionais.") + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + } else { + ForEach(flavor.addonGroups) { group in + VStack(alignment: .leading, spacing: 10) { + Text(group.name) + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + + ForEach(group.items) { item in + HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 4) { + Text(item.name) + .font(AppTypography.body) + .foregroundStyle(AppColors.textPrimary) + Text("+ \(formatCurrency(item.price ?? 0))") + .font(.caption) + .foregroundStyle(AppColors.textMuted) + } + + Spacer() + + HStack(spacing: 8) { + Button(action: { decrement(item.id) }) { + Image(systemName: "minus") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(AppColors.textPrimary) + .frame(width: 26, height: 26) + .background(AppColors.brandSoft) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .disabled((quantities[item.id] ?? 0) <= 0) + + Text("\(quantities[item.id] ?? 0)") + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + .frame(minWidth: 18) + + Button(action: { increment(item.id) }) { + Image(systemName: "plus") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(AppColors.textPrimary) + .frame(width: 26, height: 26) + .background(AppColors.tertiary) + .clipShape(Circle()) + } + .buttonStyle(.plain) + } + } + } + } + .padding(12) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + } + } + .padding(20) + } + .background(AppColors.backgroundLight.ignoresSafeArea()) + .appHiddenNavigationBar() + .navigationBarBackButtonHidden(true) + } + + + private func increment(_ addonId: String) { + quantities[addonId, default: 0] += 1 + } + + private func decrement(_ addonId: String) { + let current = quantities[addonId] ?? 0 + if current <= 1 { + quantities.removeValue(forKey: addonId) + } else { + quantities[addonId] = current - 1 + } + } + + private func formatCurrency(_ value: Double) -> String { + String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",") + } +} diff --git a/PediFoods/Views/Main/PizzaProductDetailSheet+Flow.swift b/PediFoods/Views/Main/PizzaProductDetailSheet+Flow.swift new file mode 100644 index 0000000..30cfd6b --- /dev/null +++ b/PediFoods/Views/Main/PizzaProductDetailSheet+Flow.swift @@ -0,0 +1,293 @@ +import Foundation +import SwiftUI + +extension PizzaProductDetailSheet { + + // MARK: - Steps + + var stepSizes: some View { + accordionSection( + step: 0, + label: "Tamanho", + summary: selectedSize.map { "\($0.name ?? "") • Até \(max(1, $0.maxFlavors ?? 1)) sabor(es)" } + ) { + ForEach(sizes) { size in + radioRow( + title: size.name ?? "Tamanho", + subtitle: "Até \(max(1, size.maxFlavors ?? 1)) sabor(es)", + isSelected: selectedSizeId == size.id + ) { + selectedSizeId = size.id + applyAutoSelections() + withAnimation(.easeInOut(duration: 0.2)) { + expandedStep = nextStep(after: 0) + } + } + } + } + } + + var stepDoughs: some View { + accordionSection( + step: 1, + label: "Massa", + summary: doughs.count <= 1 + ? (doughs.first?.name ?? "Tradicional") + : doughs.first(where: { $0.id == selectedDoughId })?.name + ) { + if doughs.count <= 1 { + Text(doughs.first?.name ?? "Massa tradicional") + .font(AppTypography.body) + .foregroundStyle(AppColors.textPrimary) + } else { + ForEach(doughs) { dough in + radioRow( + title: dough.name ?? "Massa", + subtitle: nil, + isSelected: selectedDoughId == dough.id + ) { + selectedDoughId = dough.id + applyAutoSelections() + withAnimation(.easeInOut(duration: 0.2)) { + expandedStep = nextStep(after: 1) + } + } + } + } + } + } + + var stepCrusts: some View { + accordionSection( + step: 2, + label: "Borda", + summary: crusts.count <= 1 + ? crustDescription(crusts.first) + : crusts.first(where: { $0.id == selectedCrustId }).map { crustDescription($0) } + ) { + if crusts.count <= 1 { + Text(crustDescription(crusts.first)) + .font(AppTypography.body) + .foregroundStyle(AppColors.textPrimary) + } else { + ForEach(crusts) { crust in + radioRow( + title: crust.name ?? "Borda", + subtitle: (crust.priceModifier ?? 0) > 0 ? "+ \(formatCurrency(crust.priceModifier ?? 0))" : nil, + isSelected: selectedCrustId == crust.id + ) { + selectedCrustId = crust.id + withAnimation(.easeInOut(duration: 0.2)) { + expandedStep = 3 + } + } + } + } + } + } + + var stepFlavors: some View { + accordionSection( + step: 3, + label: "Sabores (\(selectedFlavorIds.count)/\(maxFlavorsAllowed))", + summary: selectedFlavorIds.isEmpty ? nil + : selectedFlavorProducts.map(\.name).joined(separator: ", ") + ) { + Text("Toque no sabor para escolher adicionais.") + .font(.caption) + .foregroundStyle(AppColors.textMuted) + + ForEach(flavors) { flavor in + let isSelected = selectedFlavorIds.contains(flavor.id) + let price = selectedSizeId.flatMap { flavor.pizzaPrices[$0] } ?? flavor.price + let maxReached = selectedFlavorIds.count >= maxFlavorsAllowed + + HStack(spacing: 10) { + AsyncStoreImage(imageURL: resolveImageURL(flavor.image)) + .frame(width: 52, height: 52) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + + VStack(alignment: .leading, spacing: 4) { + Text(flavor.name) + .font(AppTypography.body) + .foregroundStyle(AppColors.textPrimary) + if let price { + Text(formatCurrency(price)) + .font(.caption) + .foregroundStyle(AppColors.textMuted) + } + } + Spacer() + Toggle("", isOn: Binding( + get: { isSelected }, + set: { value in + if value { addFlavor(flavor.id) } else { removeFlavor(flavor.id) } + } + )) + .labelsHidden() + .disabled(!isSelected && maxReached) + } + .padding(.vertical, 2) + .appContentShape(Rectangle()) + .onTapGesture { + guard isSelected else { return } + guard flavor.addonGroups.contains(where: { $0.items.isEmpty == false }) else { return } + selectedFlavorForAddons = flavor + } + } + } + } + + // MARK: - Accordion container + + func accordionSection( + step: Int, + label: String, + summary: String?, + @ViewBuilder content: () -> some View + ) -> some View { + let isExpanded = expandedStep == step + let isDone = summary != nil + + return VStack(spacing: 0) { + Button { + withAnimation(.easeInOut(duration: 0.2)) { + expandedStep = isExpanded ? -1 : step + } + } label: { + HStack(spacing: 10) { + ZStack { + Circle() + .fill(isDone || isExpanded ? AppColors.primary : AppColors.textMuted.opacity(0.25)) + .frame(width: 26, height: 26) + if isDone && !isExpanded { + Image(systemName: "checkmark") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(.white) + } else { + Text("\(step + 1)") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(.white) + } + } + + Text(label) + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + + Spacer() + + if let summary, !isExpanded { + Text(summary) + .font(AppTypography.caption) + .foregroundStyle(AppColors.textMuted) + .lineLimit(1) + .truncationMode(.tail) + .frame(maxWidth: 140, alignment: .trailing) + } + + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(AppColors.textMuted) + } + .padding(14) + } + .buttonStyle(.plain) + + if isExpanded { + Divider().padding(.horizontal, 14) + VStack(alignment: .leading, spacing: 10) { + content() + } + .padding(14) + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + + // MARK: - Helpers + + func nextStep(after step: Int) -> Int { + if step == 0 { + if doughs.count > 1 { return 1 } + if crusts.count > 1 { return 2 } + return 3 + } + if step == 1 { + if crusts.count > 1 { return 2 } + return 3 + } + return 3 + } + + func radioRow(title: String, subtitle: String?, isSelected: Bool, action: @escaping () -> Void) -> some View { + Button(action: action) { + HStack(spacing: 10) { + ZStack { + Circle() + .stroke(isSelected ? AppColors.primary : AppColors.textMuted.opacity(0.4), lineWidth: 2) + .frame(width: 20, height: 20) + if isSelected { + Circle() + .fill(AppColors.primary) + .frame(width: 10, height: 10) + } + } + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(AppTypography.body) + .foregroundStyle(AppColors.textPrimary) + if let subtitle, subtitle.isEmpty == false { + Text(subtitle) + .font(.caption) + .foregroundStyle(AppColors.textMuted) + } + } + Spacer() + } + .appContentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + func crustDescription(_ crust: StorePizzaCrust?) -> String { + guard let crust else { return "Sem borda especial" } + let name = crust.name ?? "Borda" + let modifier = crust.priceModifier ?? 0 + return modifier > 0 ? "\(name) (+ \(formatCurrency(modifier)))" : name + } + + func applyAutoSelections() { + if selectedSizeId != nil { + if doughs.count == 1 { selectedDoughId = doughs.first?.id } + else if doughs.isEmpty { selectedDoughId = "__none__" } + } + if isDoughReady { + if crusts.count == 1 { selectedCrustId = crusts.first?.id } + else if crusts.isEmpty { selectedCrustId = "__none__" } + } + } + + func trimFlavorSelectionByLimit() { + let limit = maxFlavorsAllowed + guard selectedFlavorIds.count > limit else { return } + selectedFlavorIds = Set(selectedFlavorIds.sorted().prefix(limit)) + } + + func addFlavor(_ flavorId: String) { + guard !selectedFlavorIds.contains(flavorId), + selectedFlavorIds.count < maxFlavorsAllowed else { return } + selectedFlavorIds.insert(flavorId) + } + + func removeFlavor(_ flavorId: String) { + selectedFlavorIds.remove(flavorId) + flavorAddonQuantities.removeValue(forKey: flavorId) + } + + func formatCurrency(_ value: Double) -> String { + String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",") + } +} diff --git a/PediFoods/Views/Main/PizzaProductDetailSheet.swift b/PediFoods/Views/Main/PizzaProductDetailSheet.swift new file mode 100644 index 0000000..ecf87d1 --- /dev/null +++ b/PediFoods/Views/Main/PizzaProductDetailSheet.swift @@ -0,0 +1,356 @@ +import Foundation +import SwiftUI +#if canImport(LCEssentials) +import LCEssentials +#endif + +struct PizzaProductDetailSheet: View { + let category: StoreCatalogCategory + let storeId: String + let resolveImageURL: (String?) -> String? + let currentQuantityForItemId: (String) -> Int + let onAdd: (CartItemState) -> Void + + @Environment(\.dismiss) var dismiss + + @State var selectedSizeId: String? = nil + @State var selectedDoughId: String? = nil + @State var selectedCrustId: String? = nil + @State var selectedFlavorIds: Set = [] + @State var flavorAddonQuantities: [String: [String: Int]] = [:] + @State var selectedFlavorForAddons: StoreCatalogProduct? = nil + @State var quantity: Int = 1 + @State var expandedStep: Int = 0 + + var flavors: [StoreCatalogProduct] { + category.products + } + + var pizzaConfig: StorePizzaConfig? { + category.pizzaConfig + } + + var sizes: [StorePizzaSize] { + pizzaConfig?.sizes ?? [] + } + + var doughs: [StorePizzaDough] { + (pizzaConfig?.doughs ?? []).filter { $0.active ?? true } + } + + var crusts: [StorePizzaCrust] { + (pizzaConfig?.crusts ?? []).filter { $0.active ?? true } + } + + private var representativeImage: String? { + let firstImage = flavors + .compactMap(\.image) + .first { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false } + return resolveImageURL(firstImage) + } + + var selectedSize: StorePizzaSize? { + guard let selectedSizeId else { return nil } + return sizes.first(where: { $0.id == selectedSizeId }) + } + + private var selectedDoughName: String? { + guard let selectedDoughId else { return nil } + return doughs.first(where: { $0.id == selectedDoughId })?.name + } + + private var selectedCrust: StorePizzaCrust? { + guard let selectedCrustId else { return nil } + return crusts.first(where: { $0.id == selectedCrustId }) + } + + var maxFlavorsAllowed: Int { + max(1, selectedSize?.maxFlavors ?? 1) + } + + var isDoughReady: Bool { + selectedSizeId != nil && (doughs.isEmpty || selectedDoughId != nil) + } + + var isCrustReady: Bool { + isDoughReady && (crusts.isEmpty || selectedCrustId != nil) + } + + var canShowFlavors: Bool { + isCrustReady + } + + var selectedFlavorProducts: [StoreCatalogProduct] { + flavors + .filter { selectedFlavorIds.contains($0.id) } + .sorted { $0.name < $1.name } + } + + private var canConfirm: Bool { + selectedSizeId != nil && isDoughReady && isCrustReady && selectedFlavorIds.isEmpty == false && quantity > 0 + } + + private var crustPriceModifier: Double { + selectedCrust?.priceModifier ?? 0 + } + + private var addonsTotal: Double { + selectedFlavorProducts.reduce(0) { partial, flavor in + let byAddon = flavorAddonQuantities[flavor.id] ?? [:] + let pricesByAddon = Dictionary(uniqueKeysWithValues: flavor.addonGroups.flatMap(\.items).map { ($0.id, $0.price ?? 0) }) + let subtotal = byAddon.reduce(0.0) { line, pair in + line + (Double(max(0, pair.value)) * (pricesByAddon[pair.key] ?? 0)) + } + return partial + subtotal + } + } + + private var basePizzaPrice: Double { + let prices = selectedFlavorProducts.map { flavor in + guard let selectedSizeId else { return flavor.price ?? 0 } + return flavor.pizzaPrices[selectedSizeId] ?? flavor.price ?? 0 + } + guard prices.isEmpty == false else { return 0 } + return prices.reduce(0, +) / Double(prices.count) + } + + private var unitPrice: Double { + basePizzaPrice + crustPriceModifier + addonsTotal + } + + private var totalPrice: Double { + unitPrice * Double(quantity) + } + + private var cartItemId: String { + var tokens: [String] = [] + if let selectedSizeId { tokens.append("size:\(selectedSizeId)") } + if let selectedDoughId { tokens.append("dough:\(selectedDoughId)") } + if let selectedCrustId { tokens.append("crust:\(selectedCrustId)") } + + let flavorsToken = selectedFlavorIds.sorted().joined(separator: ",") + tokens.append("flavors:\(flavorsToken)") + + let addonsToken = flavorAddonQuantities + .flatMap { flavorId, addons in + addons + .filter { $0.value > 0 } + .map { "\(flavorId):\($0.key):\($0.value)" } + } + .sorted() + .joined(separator: ",") + if addonsToken.isEmpty == false { + tokens.append("addons:\(addonsToken)") + } + + return "\(storeId)::pizza::\(category.id)::" + tokens.joined(separator: "|") + } + + private var selectedAddonsPayload: [CartItemAddonState] { + var payload: [CartItemAddonState] = [] + for flavor in selectedFlavorProducts { + let byAddon = flavorAddonQuantities[flavor.id] ?? [:] + let addonMap = Dictionary(uniqueKeysWithValues: flavor.addonGroups.flatMap(\.items).map { ($0.id, $0) }) + for (addonId, qty) in byAddon { + guard qty > 0, let addon = addonMap[addonId] else { continue } + payload.append( + CartItemAddonState( + id: "\(flavor.id)::\(addon.id)", + name: "\(flavor.name) • \(addon.name)", + quantity: qty, + unitPrice: addon.price ?? 0 + ) + ) + } + } + return payload + } + + private var selectedDetailsText: String? { + var chunks: [String] = [] + if let selectedSizeName = selectedSize?.name { + chunks.append("Tamanho: \(selectedSizeName)") + } + if let selectedDoughName, selectedDoughName.isEmpty == false { + chunks.append("Massa: \(selectedDoughName)") + } + if let crustName = selectedCrust?.name, crustName.isEmpty == false { + chunks.append("Borda: \(crustName)") + } + if selectedFlavorProducts.isEmpty == false { + chunks.append("Sabores: " + selectedFlavorProducts.map(\.name).joined(separator: ", ")) + } + return chunks.isEmpty ? nil : chunks.joined(separator: " • ") + } + + private var pizzaChoices: [String] { + var choices: [String] = [] + if let sizeName = selectedSize?.name { + let sizePrice = basePizzaPrice + if sizePrice > 0 { + choices.append("Tamanho: \(sizeName) (+\(formatCurrency(sizePrice)))") + } else { + choices.append("Tamanho: \(sizeName)") + } + } + if let crustName = selectedCrust?.name, crustName.isEmpty == false { + let mod = crustPriceModifier + if mod > 0 { + choices.append("Borda: \(crustName) (+\(formatCurrency(mod)))") + } else { + choices.append("Borda: \(crustName)") + } + } + if let doughName = selectedDoughName, doughName.isEmpty == false { + choices.append("Massa: \(doughName)") + } + let flavorCount = selectedFlavorProducts.count + for flavor in selectedFlavorProducts { + choices.append(flavorCount > 1 ? "Meio \(flavor.name)" : flavor.name) + } + return choices + } + + private var addButtonTitle: String { + if canConfirm == false { + return "Selecione as opções" + } + return "Adicionar • \(formatCurrency(totalPrice))" + } + + var body: some View { + LCENavigationView { + content + } + .setLeftButton(image: AnyView(AppBackButtonIcon())) { dismiss() } + .setTitle(text: Text("Monte sua pizza").font(AppTypography.heading2).foregroundStyle(AppColors.textPrimary)) + .setNavigationBarBackgroundColor(AppColors.backgroundLight) + .buttonStyle(.plain) + } + + private var content: some View { + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 16) { + Rectangle() + .fill(AppColors.brandSoft) + .frame(maxWidth: .infinity) + .frame(height: 220) + .overlay( + Image("placeholder-pizza") + .resizable() + .scaledToFill() + .clipped() + ) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous)) + + Text("Escolha seu sabor") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + + Text("Escolha o tamanho da sua fome") + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + + Text(formatCurrency(unitPrice)) + .font(AppTypography.heading2) + .foregroundStyle(AppColors.primary) + + stepSizes + if selectedSizeId != nil { stepDoughs } + if isDoughReady { stepCrusts } + if canShowFlavors { stepFlavors } + } + .padding(20) + .padding(.bottom, 90) + } + .appBottomSafeAreaInset { + HStack(spacing: 12) { + HStack(spacing: 10) { + Button(action: { if quantity > 1 { quantity -= 1 } }) { + Image(systemName: "minus") + .font(.system(size: 14, weight: .bold)) + .foregroundStyle(AppColors.textPrimary) + .frame(width: 32, height: 32) + .background(AppColors.surface) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .disabled(quantity <= 1) + + Text("\(quantity)") + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + .frame(minWidth: 20) + + Button(action: { quantity += 1 }) { + Image(systemName: "plus") + .font(.system(size: 14, weight: .bold)) + .foregroundStyle(AppColors.textPrimary) + .frame(width: 32, height: 32) + .background(AppColors.surface) + .clipShape(Circle()) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 8) + .frame(height: 48) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + + PrimaryButton(title: addButtonTitle) { + guard canConfirm else { return } + let item = CartItemState( + id: cartItemId, + productId: selectedFlavorProducts.first?.id ?? category.id, + storeId: storeId, + name: "Escolha seu sabor", + imageURL: representativeImage, + details: selectedDetailsText, + choices: pizzaChoices.isEmpty ? nil : pizzaChoices, + addons: selectedAddonsPayload, + quantity: quantity, + unitPrice: unitPrice + ) + onAdd(item) + dismiss() + } + .disabled(canConfirm == false) + } + .padding(.horizontal, 20) + .padding(.top, 8) + .padding(.bottom, 12) + .background(.ultraThinMaterial) + } + .sheet(item: $selectedFlavorForAddons) { flavor in + NavigationStack { + PizzaFlavorAddonsSheet( + flavor: flavor, + quantities: Binding( + get: { flavorAddonQuantities[flavor.id] ?? [:] }, + set: { flavorAddonQuantities[flavor.id] = $0 } + ) + ) + } + } + .background(AppColors.backgroundLight.ignoresSafeArea()) + .appHiddenNavigationBar() + .navigationBarBackButtonHidden(true) + .onAppear { + applyAutoSelections() + let existing = currentQuantityForItemId(cartItemId) + if existing > 0 { + quantity = existing + } + } + .onChange(of: selectedSizeId) { _, _ in + trimFlavorSelectionByLimit() + applyAutoSelections() + } + .onChange(of: selectedFlavorIds) { _, newValue in + let selected = newValue + flavorAddonQuantities = flavorAddonQuantities.filter { selected.contains($0.key) } + } + } + + +} diff --git a/PediFoods/Views/Main/ProductDetailSheet.swift b/PediFoods/Views/Main/ProductDetailSheet.swift new file mode 100644 index 0000000..689d2cb --- /dev/null +++ b/PediFoods/Views/Main/ProductDetailSheet.swift @@ -0,0 +1,287 @@ +import Foundation +import SwiftUI +#if canImport(LCEssentials) +import LCEssentials +#endif + +struct ProductDetailSheet: View { + let product: StoreCatalogProduct + let imageURL: String? + let storeId: String + let currentQuantityForItemId: (String) -> Int + let onAdd: (CartItemState) -> Void + @Environment(\.dismiss) var dismiss + @State var selectedAddonQuantities: [String: Int] = [:] + @State var quantity: Int = 0 + + private var addonItemsById: [String: StoreAddonItem] { + Dictionary(uniqueKeysWithValues: product.addonGroups.flatMap(\.items).map { ($0.id, $0) }) + } + + private var selectedAddonItems: [(item: StoreAddonItem, quantity: Int)] { + selectedAddonQuantities + .compactMap { key, qty in + guard qty > 0, let item = addonItemsById[key] else { return nil } + return (item, qty) + } + .sorted { $0.item.name < $1.item.name } + } + + private var addonsTotal: Double { + selectedAddonItems.reduce(0) { partial, pair in + partial + (Double(pair.quantity) * (pair.item.price ?? 0)) + } + } + + private var unitPrice: Double { + (product.price ?? 0) + addonsTotal + } + + private var totalPrice: Double { + unitPrice * Double(quantity) + } + + private var cartItemId: String { + let addonKey = encodedAddonKey + return "\(storeId)::\(product.id)::\(addonKey)" + } + + private var selectedAddonsSummary: String? { + let names = selectedAddonItems.map { pair in + pair.quantity > 1 ? "\(pair.item.name) x\(pair.quantity)" : pair.item.name + } + if names.isEmpty { return nil } + return names.joined(separator: ", ") + } + + private var selectedAddonsPayload: [CartItemAddonState] { + selectedAddonItems.map { pair in + CartItemAddonState( + id: pair.item.id, + name: pair.item.name, + quantity: pair.quantity, + unitPrice: pair.item.price ?? 0 + ) + } + } + + private var addButtonTitle: String { + if quantity <= 0 { + return "Remover do carrinho" + } + return "Atualizar • \(formatCurrency(totalPrice))" + } + + var body: some View { + LCENavigationView { + content + } + .setLeftButton(image: AnyView(AppBackButtonIcon())) { dismiss() } + .setTitle(text: Text("Detalhes").font(AppTypography.heading2).foregroundStyle(AppColors.textPrimary)) + .setNavigationBarBackgroundColor(AppColors.backgroundLight) + .buttonStyle(.plain) + } + + private var content: some View { + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 16) { + AsyncStoreImage(imageURL: imageURL) + .frame(height: 220) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous)) + + Text(product.name) + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + + if let description = product.description, description.isEmpty == false { + Text(description) + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + } + + Text(formatCurrency(unitPrice)) + .font(AppTypography.heading2) + .foregroundStyle(AppColors.primary) + + if addonsTotal > 0 { + Text("Inclui adicionais: \(formatCurrency(addonsTotal))") + .font(.caption) + .foregroundStyle(AppColors.textMuted) + } else { + Text("Sem adicionais") + .font(.caption) + .foregroundStyle(AppColors.textMuted) + } + + if product.addonGroups.isEmpty == false { + Text("Adicionais") + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + + ForEach(product.addonGroups) { group in + VStack(alignment: .leading, spacing: 8) { + Text(group.name) + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + + ForEach(group.items) { item in + HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 4) { + Text(item.name) + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + Text(String(format: "+ R$ %.2f", item.price ?? 0).replacingOccurrences(of: ".", with: ",")) + .font(.caption) + .foregroundStyle(AppColors.textMuted) + } + + Spacer() + + HStack(spacing: 8) { + Button(action: { decrementAddon(item.id) }) { + Image(systemName: "minus") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(AppColors.textPrimary) + .frame(width: 26, height: 26) + .background(AppColors.brandSoft) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .disabled(quantity(forAddonId: item.id) <= 0 || quantity <= 0) + + Text("\(quantity(forAddonId: item.id))") + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + .frame(minWidth: 18) + + Button(action: { incrementAddon(item.id) }) { + Image(systemName: "plus") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(AppColors.textPrimary) + .frame(width: 26, height: 26) + .background(AppColors.tertiary) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .disabled(quantity <= 0) + } + } + } + } + .padding(12) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + } + } + .padding(20) + .padding(.bottom, 80) + } + .appBottomSafeAreaInset { + HStack(spacing: 12) { + HStack(spacing: 10) { + Button(action: { if quantity > 0 { quantity -= 1 } }) { + Image(systemName: "minus") + .font(.system(size: 14, weight: .bold)) + .foregroundStyle(AppColors.textPrimary) + .frame(width: 32, height: 32) + .background(AppColors.surface) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .disabled(quantity <= 0) + + Text("\(quantity)") + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + .frame(minWidth: 20) + + Button(action: { quantity += 1 }) { + Image(systemName: "plus") + .font(.system(size: 14, weight: .bold)) + .foregroundStyle(AppColors.textPrimary) + .frame(width: 32, height: 32) + .background(AppColors.surface) + .clipShape(Circle()) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 8) + .frame(height: 48) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .buttonStyle(.plain) + + PrimaryButton(title: addButtonTitle) { + let item = CartItemState( + id: cartItemId, + productId: product.id, + storeId: storeId, + name: product.name, + imageURL: imageURL, + details: selectedAddonsSummary, + addons: selectedAddonsPayload, + quantity: quantity, + unitPrice: unitPrice + ) + onAdd(item) + dismiss() + } + } + .padding(.horizontal, 20) + .padding(.top, 8) + .padding(.bottom, 12) + .background(.ultraThinMaterial) + } + .background(AppColors.backgroundLight.ignoresSafeArea()) + .appHiddenNavigationBar() + .navigationBarBackButtonHidden(true) + .onAppear { + let existing = currentQuantityForItemId(cartItemId) + quantity = existing > 0 ? existing : 1 + } + .onChange(of: selectedAddonQuantities) { _, _ in + // Keep the main quantity stable when changing addon quantities. + // Only hydrate from cart if this exact configuration already exists. + let existingQuantity = currentQuantityForItemId(cartItemId) + if existingQuantity > 0 { + quantity = existingQuantity + } + } + .onChange(of: quantity) { _, newValue in + if newValue <= 0 { + selectedAddonQuantities.removeAll() + } + } + } + + + private func formatCurrency(_ value: Double) -> String { + String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",") + } + + private var encodedAddonKey: String { + let tokens = selectedAddonQuantities + .filter { $0.value > 0 } + .map { "\($0.key):\($0.value)" } + .sorted() + return tokens.isEmpty ? "base" : tokens.joined(separator: ",") + } + + private func quantity(forAddonId addonId: String) -> Int { + selectedAddonQuantities[addonId] ?? 0 + } + + private func incrementAddon(_ addonId: String) { + selectedAddonQuantities[addonId, default: 0] += 1 + } + + private func decrementAddon(_ addonId: String) { + let current = selectedAddonQuantities[addonId] ?? 0 + if current <= 1 { + selectedAddonQuantities.removeValue(forKey: addonId) + } else { + selectedAddonQuantities[addonId] = current - 1 + } + } +} diff --git a/PediFoods/Views/Main/ProfileView.swift b/PediFoods/Views/Main/ProfileView.swift new file mode 100644 index 0000000..7c74e9e --- /dev/null +++ b/PediFoods/Views/Main/ProfileView.swift @@ -0,0 +1,429 @@ +import SwiftUI +#if canImport(LCEssentials) +import LCEssentials +#endif +import UIKit + +struct ProfileView: View { + @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 + @State var showDeleteAccountAlert = false + @State var isDeletingAccount = false + @State private var openOrders = false + let tabBarClearance: CGFloat = 120 + + var body: some View { + ScrollView(showsIndicators: false) { + VStack(spacing: 18) { + NavigationLink { + UserProfileView(appState: $appState) + } label: { + header + } + .buttonStyle(.plain) + + VStack(spacing: 14) { + NavigationLink { + OrdersView(appState: $appState) + } label: { + ProfileMenuRow(icon: "list.bullet.clipboard.fill", title: "Meus Pedidos") + } + .buttonStyle(.plain) + + NavigationLink { + AddressesView(message: nil, appState: $appState) + } label: { + ProfileMenuRow(icon: "mappin.circle.fill", title: "Meus Endereços") + } + .buttonStyle(.plain) + + NavigationLink { + SavedCardsView(appState: $appState) + } label: { + ProfileMenuRow(icon: "creditcard.fill", title: "Meus Cartões") + } + .buttonStyle(.plain) + + NavigationLink { + MyReviewsView() + } label: { + ProfileMenuRow(icon: "star.fill", title: "Minhas Avaliações") + } + .buttonStyle(.plain) + + if appState.featureFlags.isEnabled("at.cupons") { + NavigationLink { + Text("Cupons de Desconto") + } label: { + ProfileMenuRow(icon: "ticket.fill", title: "Cupons de Desconto", badge: "NOVO") + } + .buttonStyle(.plain) + } + +// NavigationLink { +// Text("Ajuda") +// } label: { +// ProfileMenuRow(icon: "gearshape.fill", title: "Configurações") +// } +// .buttonStyle(.plain) + } + .padding(.horizontal, 20) + + Button(action: { showLogoutAlert = true }) { + HStack(spacing: 10) { + Image(systemName: "rectangle.portrait.and.arrow.right") + .font(.system(size: 18, weight: .semibold)) + Text("Sair da Conta") + .font(AppTypography.heading3) + } + .foregroundStyle(Color.red) + } + .buttonStyle(.plain) + .padding(.top, 10) + .padding(.horizontal, 20) + + Button(action: { showDeleteAccountAlert = true }) { + HStack(spacing: 10) { + if isDeletingAccount { + ProgressView() + .tint(Color.red) + } else { + Image(systemName: "trash.fill") + .font(.system(size: 16, weight: .semibold)) + } + Text("Excluir Conta") + .font(AppTypography.body) + } + .foregroundStyle(Color.red.opacity(0.7)) + } + .buttonStyle(.plain) + .disabled(isDeletingAccount) + .padding(.top, 2) + .padding(.horizontal, 20) + + Text("Versão 1.0b") + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(AppColors.textMuted) + + HStack(spacing: 6) { + NavigationLink { + TermsOfUseView() + } label: { + Text("Termos de Uso") + } + .buttonStyle(.plain) + + Text("·") + + NavigationLink { + PrivacyPolicyView() + } label: { + Text("Política de Privacidade") + } + .buttonStyle(.plain) + } + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(AppColors.textMuted) + .padding(.bottom, 12) + } + } + .ignoresSafeArea(edges: .top) + .appBottomSafeAreaInset { + Rectangle() + .fill(AppColors.backgroundLight.opacity(0.8)) + .frame(height: tabBarClearance) + .padding(.bottom, -UIDevice.bottomNotch) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(AppColors.backgroundLight) + .alert("Sair da conta?", isPresented: $showLogoutAlert) { + Button("Cancelar", role: .cancel) {} + Button("Sair", role: .destructive) { + logout() + } + } message: { + Text("Tem certeza que deseja sair da sua conta?") + } + .alert("Excluir sua conta?", isPresented: $showDeleteAccountAlert) { + Button("Cancelar", role: .cancel) {} + Button("Excluir", role: .destructive) { + Task { await deleteAccount() } + } + } message: { + Text("Essa ação é permanente. Seus dados de perfil, endereços, cartões e favoritos serão excluídos e não poderão ser recuperados.") + } + .onAppear { + guard let message = appState.address.onboardingMessage else { + return + } + onboardingMessage = message + appState.address.onboardingMessage = nil + openAddressesOnboarding = true + } + .sheet(isPresented: $openAddressesOnboarding) { + NavigationStack { + AddressesView(message: onboardingMessage, appState: $appState) + } + } + .navigationDestination(isPresented: $openOrders) { + OrdersView(appState: $appState) + } + .onChange(of: appState.shouldNavigateToOrders) { _, val in + if val { + appState.shouldNavigateToOrders = false + openOrders = true + } + } + .onChange(of: appState.pendingOrderDeepLink) { _, val in + if val != nil { + openOrders = true + } + } + } + + private var header: some View { + VStack(spacing: 10) { + ZStack(alignment: .bottomTrailing) { + Circle() + .fill(Color.white.opacity(0.18)) + .frame(width: 96, height: 96) + .overlay( + Group { + if let picture = profilePictureURL { + AsyncStoreImage(imageURL: picture) + .frame(width: 92, height: 92) + .clipShape(Circle()) + } else { + Text(profileInitials) + .font(.system(size: 30, weight: .bold)) + .foregroundStyle(AppColors.textInverse) + } + } + ) + + Circle() + .fill(AppColors.tertiary) + .frame(width: 36, height: 36) + .overlay( + Image(systemName: "pencil") + .font(.system(size: 15, weight: .bold)) + .foregroundStyle(AppColors.textPrimary) + ) + .overlay( + Circle() + .stroke(Color.black.opacity(0.15), lineWidth: 1) + ) + } + + Text(profileName) + .font(.system(size: 22, weight: .heavy)) + .foregroundStyle(AppColors.textInverse) + .lineLimit(1) + .minimumScaleFactor(0.8) + + HStack(spacing: 8) { + Text("Ver Perfil") + .font(.system(size: 16, weight: .medium)) + Image(systemName: "arrow.right") + .font(.system(size: 14, weight: .semibold)) + } + .foregroundStyle(AppColors.tertiary) + } + .frame(maxWidth: .infinity) + .padding(.top, 54) + .padding(.bottom, 32) + .background(headerGradient) + .clipShape( + ProfileHeaderShape( + topLeadingRadius: 0, + bottomLeadingRadius: 42, + bottomTrailingRadius: 42, + topTrailingRadius: 0 + ) + ) + .ignoresSafeArea(edges: .top) + } + + private var headerGradient: LinearGradient { + LinearGradient( + colors: [Color(hex: "#123221"), Color(hex: "#0F2A1C")], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } + + private var profileName: String { + let trimmed = appState.profile.name.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? "Alex Silva" : trimmed + } + + private var profileInitials: String { + let parts = profileName.split(separator: " ").prefix(2) + let joined = parts.compactMap { $0.first }.map(String.init).joined() + return joined.isEmpty ? "AS" : joined.uppercased() + } + + private var profilePictureURL: String? { + let raw = appState.profile.profilePicture.trimmingCharacters(in: .whitespacesAndNewlines) + guard raw.isEmpty == false else { return nil } + return ImageSourceResolver.resolve(raw) + } + + private func logout() { + tokenStore.clear() + SessionStateStore.clearActiveUser() + SessionStateStore.clearTrackedOrders() + AppContentCache.shared.invalidate() + AppImageCache.shared.invalidateAll() + appState = AppState() + enterAuth() + } + + @MainActor + private func deleteAccount() async { + guard isDeletingAccount == false else { return } + isDeletingAccount = true + defer { isDeletingAccount = false } + + do { + let response = try await ApiService().deleteAccount() + guard response.error == false else { + SnackbarCenter.shared.show( + title: response.message ?? "Não foi possível excluir sua conta.", + style: .error, + icon: "xmark.octagon.fill", + duration: 3.5 + ) + return + } + logout() + } catch { + SnackbarCenter.shared.show( + title: "Não foi possível excluir sua conta. Tente novamente.", + style: .error, + icon: "xmark.octagon.fill", + duration: 3.5 + ) + } + } +} + +struct ProfileLoggedOutView: View { + let enterAuth: () -> Void + + var body: some View { + VStack(spacing: 18) { + Spacer() + + Image(systemName: "person.crop.circle.badge.questionmark") + .font(.system(size: 56, weight: .regular)) + .foregroundStyle(AppColors.textMuted) + + Text("Entre na sua conta") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + + Text("Faça login ou cadastre-se para ver seu perfil, pedidos e endereços.") + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + + Button { + enterAuth() + } label: { + Text("Entrar ou Cadastrar") + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textInverse) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(AppColors.primary) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + } + .buttonStyle(.plain) + .padding(.horizontal, 32) + .padding(.top, 8) + + Spacer() + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(AppColors.backgroundLight) + } +} + +struct ProfileMenuRow: View { + let icon: String + let title: String + var badge: String? = nil + + var body: some View { + HStack(spacing: 12) { + Circle() + .fill(Color(hex: "#E9F0E2")) + .frame(width: 44, height: 44) + .overlay( + Image(systemName: icon) + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(Color(hex: "#173824")) + ) + + Text(title) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(Color(hex: "#0F1A34")) + + Spacer(minLength: 10) + + if let badge { + Text(badge) + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(Color(hex: "#1C2A1C")) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color(hex: "#EAF1D6")) + .clipShape(Capsule()) + } + + Image(systemName: "chevron.right") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(Color(hex: "#BFC7D4")) + } + .padding(.horizontal, 18) + .padding(.vertical, 14) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + .shadow(color: Color.black.opacity(0.02), radius: 6, y: 2) + } +} + +struct ProfileHeaderShape: Shape { + var topLeadingRadius: CGFloat + var bottomLeadingRadius: CGFloat + var bottomTrailingRadius: CGFloat + var topTrailingRadius: CGFloat + + func path(in rect: CGRect) -> Path { + let tl = min(min(topLeadingRadius, rect.width / 2), rect.height / 2) + let tr = min(min(topTrailingRadius, rect.width / 2), rect.height / 2) + let bl = min(min(bottomLeadingRadius, rect.width / 2), rect.height / 2) + let br = min(min(bottomTrailingRadius, rect.width / 2), rect.height / 2) + + var path = Path() + path.move(to: CGPoint(x: rect.minX + tl, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.maxX - tr, y: rect.minY)) + path.addArc(center: CGPoint(x: rect.maxX - tr, y: rect.minY + tr), radius: tr, startAngle: .degrees(-90), endAngle: .degrees(0), clockwise: false) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - br)) + path.addArc(center: CGPoint(x: rect.maxX - br, y: rect.maxY - br), radius: br, startAngle: .degrees(0), endAngle: .degrees(90), clockwise: false) + path.addLine(to: CGPoint(x: rect.minX + bl, y: rect.maxY)) + path.addArc(center: CGPoint(x: rect.minX + bl, y: rect.maxY - bl), radius: bl, startAngle: .degrees(90), endAngle: .degrees(180), clockwise: false) + path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + tl)) + path.addArc(center: CGPoint(x: rect.minX + tl, y: rect.minY + tl), radius: tl, startAngle: .degrees(180), endAngle: .degrees(270), clockwise: false) + path.closeSubpath() + return path + } +} diff --git a/PediFoods/Views/Main/PublicLocationPickerView.swift b/PediFoods/Views/Main/PublicLocationPickerView.swift new file mode 100644 index 0000000..33547e3 --- /dev/null +++ b/PediFoods/Views/Main/PublicLocationPickerView.swift @@ -0,0 +1,163 @@ +import SwiftUI +#if canImport(LCEssentials) +import LCEssentials +#endif + +/// State -> city picker for anonymous browsing (public store locator). +/// Replaces AddressesView in the address-picker modal when the user is +/// not authenticated — see docs/plans/public-store-locator-sdd.md. +struct PublicLocationPickerView: View { + @Binding var appState: AppState + @Environment(\.dismiss) var dismiss + + private enum Step { + case state + case city + } + + @State private var step: Step = .state + @State private var locations: PublicLocationsResult = [:] + @State private var selectedState: String? = nil + @State private var isLoading = false + @State private var errorMessage: String? = nil + + private var states: [String] { + locations.keys.sorted() + } + + private var cities: [String] { + guard let selectedState else { return [] } + return (locations[selectedState] ?? []).sorted() + } + + var body: some View { + LCENavigationView { + content + } + .setLeftButton(image: AnyView(AppBackButtonIcon())) { back() } + .setTitle(text: Text(step == .state ? "Escolha seu estado" : "Escolha sua cidade").font(AppTypography.heading2).foregroundStyle(AppColors.textPrimary)) + .setNavigationBarBackgroundColor(AppColors.backgroundLight) + .buttonStyle(.plain) + } + + private var content: some View { + ZStack { + AppColors.backgroundLight.ignoresSafeArea() + + VStack(spacing: 20) { + if isLoading { + ProgressView() + .padding(.top, 40) + } else if let errorMessage { + VStack(spacing: 12) { + Text(errorMessage) + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + .multilineTextAlignment(.center) + Button("Tentar novamente") { + Task { await loadLocations() } + } + .font(AppTypography.heading3) + .foregroundStyle(AppColors.primary) + } + .padding(.horizontal, 20) + .padding(.top, 40) + } else { + list + } + + Spacer(minLength: 0) + } + .padding(.top, 18) + } + .navigationBarBackButtonHidden(true) + .appHiddenNavigationBar() + .task { + await loadLocations() + } + } + + private var list: some View { + ScrollView(showsIndicators: false) { + LazyVStack(spacing: 12) { + switch step { + case .state: + ForEach(states, id: \.self) { state in + rowButton(title: state) { + selectedState = state + step = .city + } + } + case .city: + ForEach(cities, id: \.self) { city in + rowButton(title: city) { + confirmSelection(city: city) + } + } + } + } + .padding(.horizontal, 20) + .padding(.top, 8) + } + } + + private func rowButton(title: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + HStack { + Text(title) + .font(AppTypography.body) + .foregroundStyle(AppColors.textPrimary) + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(AppColors.textMuted) + } + .padding(.horizontal, 18) + .padding(.vertical, 16) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + } + .buttonStyle(.plain) + } + + private func back() { + switch step { + case .city: + step = .state + errorMessage = nil + case .state: + dismiss() + } + } + + private func confirmSelection(city: String) { + guard let selectedState else { return } + GuestLocationStore.shared.selectedState = selectedState + GuestLocationStore.shared.selectedCity = city + appState.address.display = "\(city), \(selectedState)" + appState.address.onboardingMessage = nil + dismiss() + } + + @MainActor + private func loadLocations() async { + isLoading = true + errorMessage = nil + do { + locations = try await PublicLocationService.shared.fetchLocations() + if locations.isEmpty { + errorMessage = "Nenhum estado disponível no momento." + } + } catch { + print("[PublicLocationPickerView] loadLocations failed: \(error)") + errorMessage = "Não foi possível carregar. Tente novamente." + } + isLoading = false + } +} + +#Preview { + NavigationStack { + PublicLocationPickerView(appState: .constant(AppState())) + } +} diff --git a/PediFoods/Views/Main/ReviewsView.swift b/PediFoods/Views/Main/ReviewsView.swift new file mode 100644 index 0000000..b9e951c --- /dev/null +++ b/PediFoods/Views/Main/ReviewsView.swift @@ -0,0 +1,1589 @@ +import SwiftUI +#if os(iOS) +import LCEssentials +#endif + +struct ReviewDraft: Identifiable, Hashable { + var id: String { orderId } + let orderId: String + let storeId: String? + let shortId: String? + let storeName: String? + let storeLogoURL: String? + let createdAt: String? + let total: Double? +} + +struct MyReviewsView: View { + let initialOrder: ReviewDraft? + + @State var reviews: [OrderReviewRecord] = [] + @State var pendingReviews: [ReviewDraft] = [] + @State var selectedDraft: ReviewDraft? = nil + @State var didHandleInitialRoute = false + @State var isLoading = false + @State var loadError: String? = nil + + @Environment(\.dismiss) var dismiss + + init(initialOrder: ReviewDraft? = nil) { + self.initialOrder = initialOrder + } + + var body: some View { + LCENavigationView { + content + } + .setLeftButton(image: AnyView(AppBackButtonIcon())) { dismiss() } + .setTitle(text: Text("Minhas Avaliações").font(AppTypography.heading2).foregroundStyle(AppColors.textPrimary)) + .setNavigationBarBackgroundColor(AppColors.backgroundLight) + .buttonStyle(.plain) + .navigationDestination(item: $selectedDraft) { draft in + OrderReviewView(draft: draft) { + Task { await loadReviewsFromBackend(forceRefresh: true) } + } + } + } + + private var content: some View { + ScrollView(showsIndicators: false) { + VStack(spacing: 12) { + if isLoading && reviews.isEmpty && pendingReviews.isEmpty { + ProgressView() + .frame(maxWidth: .infinity) + .padding(.top, 24) + } else if let loadError, loadError.isEmpty == false { + Text(loadError) + .font(AppTypography.body) + .foregroundStyle(Color.red) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .padding(.top, 24) + } else if reviews.isEmpty && pendingReviews.isEmpty { + emptyState + } else { + if pendingReviews.isEmpty == false { + sectionTitle("Pendentes de avaliação") + ForEach(pendingReviews) { draft in + pendingReviewRow(draft) + } + } + + if reviews.isEmpty == false { + sectionTitle("Avaliações enviadas") + ForEach(reviews) { review in + reviewRow(review) + } + } + + } + } + .padding(.horizontal, 20) + .padding(.top, 14) + .padding(.bottom, UIDevice.bottomNotch + 30) + } + .background(AppColors.backgroundLight) + .onAppear { + Task { await loadReviewsFromBackend(forceRefresh: true) } + } + .refreshable { + // Decoupled from .refreshable's own cancellable wrapping Task — + // see StoreDetailView's .refreshable for why. + await Task { await loadReviewsFromBackend(forceRefresh: true) }.value + } + .task { + guard didHandleInitialRoute == false else { return } + didHandleInitialRoute = true + if let initialOrder { selectedDraft = initialOrder } + } + } + + private var emptyState: some View { + VStack(spacing: 8) { + Text("Você não tem avaliações nem pendências no momento.") + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + .multilineTextAlignment(.center) + + if let initialOrder { + Button("Avaliar pedido #\(initialOrder.shortId ?? initialOrder.orderId)") { + selectedDraft = initialOrder + } + .font(AppTypography.heading3) + .foregroundStyle(AppColors.primary) + .buttonStyle(.plain) + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 30) + } + + private func sectionTitle(_ value: String) -> some View { + Text(value) + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 6) + } + + private func pendingReviewRow(_ draft: ReviewDraft) -> some View { + Button { + selectedDraft = draft + } label: { + HStack(spacing: 12) { + AsyncStoreImage(imageURL: resolvedMediaURL(draft.storeLogoURL)) + .frame(width: 44, height: 44) + .clipShape(Circle()) + .background(AppColors.brandSoft, in: Circle()) + + VStack(alignment: .leading, spacing: 6) { + Text(draft.storeName ?? "Pedido #\(draft.shortId ?? draft.orderId)") + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + + Text("Pedido #\(draft.shortId ?? draft.orderId)") + .font(AppTypography.caption) + .foregroundStyle(AppColors.textMuted) + + Text("Toque para avaliar") + .font(AppTypography.caption) + .foregroundStyle(AppColors.primary) + } + + Spacer(minLength: 0) + + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(AppColors.textMuted) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + .buttonStyle(.plain) + } + + private func reviewRow(_ review: OrderReviewRecord) -> some View { + let draft = ReviewDraft( + orderId: review.orderId, + storeId: review.storeId, + shortId: review.shortId, + storeName: review.storeName, + storeLogoURL: review.storeLogoURL, + createdAt: review.createdAt, + total: nil + ) + + return Button { + selectedDraft = draft + } label: { + HStack(spacing: 12) { + AsyncStoreImage(imageURL: resolvedMediaURL(review.storeLogoURL)) + .frame(width: 44, height: 44) + .clipShape(Circle()) + .background(AppColors.brandSoft, in: Circle()) + + VStack(alignment: .leading, spacing: 6) { + Text(review.storeName ?? "Pedido #\(review.shortId ?? review.orderId)") + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + + HStack(spacing: 4) { + ForEach(0..<5, id: \.self) { index in + Image(systemName: index < review.rating ? "star.fill" : "star") + .foregroundStyle(index < review.rating ? Color(hex: "#7CF02A") : Color(hex: "#BFC7D4")) + } + } + + if review.comment.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false { + Text(review.comment) + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + .lineLimit(2) + } + } + + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + .buttonStyle(.plain) + } + + @MainActor + private func loadReviewsFromBackend(forceRefresh: Bool) async { + if isLoading { return } + isLoading = true + loadError = nil + defer { isLoading = false } + + do { + let service = ApiService() + let ordersResponse = try await service.listOrders(forceRefresh: forceRefresh) + guard ordersResponse.error == false else { + loadError = ordersResponse.message ?? "Não foi possível carregar avaliações." + return + } + var stores: [StoreSummary] = AppContentCache.shared.value( + for: AppCacheKey.homeStoresLatestSnapshot, + as: [StoreSummary].self + ) ?? [] + if stores.isEmpty { + let storesResponse = try? await service.listStores() + stores = storesResponse?.result ?? [] + } +#if os(iOS) + for store in stores { + printLog( + title: "LOGO REVIEW LISTA", + msg: "storeId=\(store.id) | storeName=\(store.name) | logoRaw=\(store.logo ?? "nil")" + ) + } +#endif + var storeLogoById: [String: String] = [:] + var storeLogoByName: [String: String] = [:] + for store in stores { + let idKey = normalized(store.id) + let nameKey = normalizedStoreKey(store.name) + if idKey.isEmpty == false, let logo = store.logo, normalized(logo).isEmpty == false { + storeLogoById[idKey] = logo + } + if nameKey.isEmpty == false, let logo = store.logo, normalized(logo).isEmpty == false { + storeLogoByName[nameKey] = logo + } + } + + let orders = ordersResponse.result ?? [] + var orderById: [String: AppOrderSummary] = [:] + var storeIds: Set = [] + for order in orders { + let orderId = resolveOrderId(order) + if orderId.isEmpty == false { + orderById[orderId] = order + } + let storeId = normalized(order.storeId) + if storeId.isEmpty == false { + storeIds.insert(storeId) + } + } + + var remoteReviewsByOrderId: [String: OrderReviewRecord] = [:] + remoteReviewsByOrderId.reserveCapacity(orderById.count) + + for storeId in storeIds { + do { + let response = try await service.publicStoreReviews(storeId: storeId) + guard response.error == false else { continue } + let storeReviews = response.result?.reviews ?? [] + + for review in storeReviews { + let orderId = normalized(review.orderId) + guard orderId.isEmpty == false, let order = orderById[orderId] else { + continue + } + let reviewStoreId = normalized(review.storeId) + let resolvedStoreId = reviewStoreId.isEmpty ? storeId : reviewStoreId + let resolvedName = normalized(order.storeName).isEmpty ? nil : order.storeName + let resolvedLogo = firstNonEmpty([ + storeLogoById[resolvedStoreId], + storeLogoByName[normalizedStoreKey(resolvedName)], + storeLogoById[normalized(order.storeId)], + storeLogoByName[normalizedStoreKey(order.storeName)] + ]) + + remoteReviewsByOrderId[orderId] = OrderReviewRecord( + orderId: orderId, + storeId: resolvedStoreId, + shortId: order.shortId, + storeName: resolvedName, + storeLogoURL: resolvedLogo, + createdAt: order.createdAt, + submittedAt: review.date ?? ISO8601DateFormatter().string(from: Date()), + rating: review.orderRate ?? review.rate ?? 0, + comment: review.orderComment ?? review.message ?? "", + orderPositiveTags: review.orderPositiveTags, + orderImprovementTags: review.orderImprovementTags, + deliverySentiment: review.deliverySentiment, + deliveryPositiveTags: review.deliveryPositiveTags, + deliveryNegativeTags: review.deliveryNegativeTags, + appNps: review.appNps, + platform: review.platform, + editableUntil: review.editableUntil, + storeReplyUntil: review.storeReplyUntil, + reviewWindowExpiresAt: review.reviewWindowExpiresAt, + storeReplyMessage: review.storeReplyMessage, + storeReplyAt: review.storeReplyAt + ) + } + } catch { + continue + } + } + + var localReviewByOrderId: [String: OrderReviewRecord] = [:] + for item in SessionStateStore.loadOrderReviews() { + let key = normalized(item.orderId).lowercased() + if key.isEmpty == false { + localReviewByOrderId[key] = item + } + } + let reviewedKeys = Set(remoteReviewsByOrderId.keys.map { normalized($0).lowercased() }) + .union(localReviewByOrderId.keys) + + pendingReviews = orders + .filter { canReview($0) } + .compactMap { order in + let resolvedId = resolveOrderId(order) + let normalizedId = normalized(resolvedId).lowercased() + guard normalizedId.isEmpty == false, reviewedKeys.contains(normalizedId) == false else { + return nil + } + + let resolvedLogo = firstNonEmpty([ + storeLogoById[normalized(order.storeId)], + storeLogoByName[normalizedStoreKey(order.storeName)] + ]) + return ReviewDraft( + orderId: resolvedId, + storeId: order.storeId, + shortId: order.shortId, + storeName: order.storeName, + storeLogoURL: resolvedLogo, + createdAt: order.createdAt, + total: order.total + ) + } + .sorted { lhs, rhs in + parseDateForSort(lhs.createdAt) > parseDateForSort(rhs.createdAt) + } + + reviews = Array(remoteReviewsByOrderId.values).sorted { lhs, rhs in + parseDateForSort(lhs.submittedAt) > parseDateForSort(rhs.submittedAt) + } + } catch { + loadError = error.localizedDescription + } + } + + private func canReview(_ order: AppOrderSummary) -> Bool { + let status = (order.status ?? "").uppercased() + let detailed = (order.statusDetailed ?? "").uppercased() + if status.contains("COMPLETED") || status.contains("DELIVERED") { + return true + } + return detailed.contains("COMPLETED") || detailed.contains("DELIVERED") + } + + private func parseDateForSort(_ value: String?) -> Date { + let raw = (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + guard raw.isEmpty == false else { return .distantPast } + + let iso = ISO8601DateFormatter() + iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = iso.date(from: raw) { return date } + iso.formatOptions = [.withInternetDateTime] + if let date = iso.date(from: raw) { return date } + return .distantPast + } + + private func resolveOrderId(_ order: AppOrderSummary) -> String { + let orderId = normalized(order.orderId) + if orderId.isEmpty == false { return orderId } + let realId = normalized(order.realId) + if realId.isEmpty == false { return realId } + return normalized(order.id) + } + + private func normalized(_ value: String?) -> String { + (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func normalizedStoreKey(_ value: String?) -> String { + normalized(value) + .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) + .lowercased() + } + + private func firstNonEmpty(_ values: [String?]) -> String? { + for value in values { + let trimmed = normalized(value) + if trimmed.isEmpty == false { + return trimmed + } + } + return nil + } + + private func resolvedMediaURL(_ raw: String?) -> String? { + ImageSourceResolver.resolve(raw) + } +} + +struct OrderReviewView: View { + let draft: ReviewDraft + var onSaved: (() -> Void)? = nil + + @Environment(\.dismiss) var dismiss + @State var rating: Int = 0 + @State var comment: String = "" + @State var existingReview: OrderReviewRecord? = nil + @State var isSubmitting = false + @State var isLoadingTags = false + @State var tagsCatalog: ReviewTagsCatalog? = nil + @State var selectedOrderPositiveTags: Set = [] + @State var selectedOrderImprovementTags: Set = [] + @State var deliverySentiment: String = "" + @State var selectedDeliveryPositiveTags: Set = [] + @State var selectedDeliveryNegativeTags: Set = [] + @State var appNps: Int = -1 + @State var homeStoreLogoURL: String? = nil + @State var orderItems: [PublicOrderItem] = [] + + var body: some View { + LCENavigationView { + content + } + .setLeftButton(image: AnyView(AppBackButtonIcon())) { dismiss() } + .setTitle(text: Text("Avaliar Pedido").font(AppTypography.heading2).foregroundStyle(AppColors.textPrimary)) + .setNavigationBarBackgroundColor(AppColors.backgroundLight) + .buttonStyle(.plain) + } + + private var content: some View { + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 24) { + topSection + orderItemsSection + if existingReview != nil { + existingReviewContent + } else { + newReviewContent + } + if existingReview == nil || canEditExistingReviewStars { + submitButton + } + } + .padding(.horizontal, 20) + .padding(.top, 10) + .padding(.bottom, UIDevice.bottomNotch + 140) + } + .background(AppColors.backgroundLight) + .appHiddenNavigationBar() + .navigationBarBackButtonHidden(true) + .onAppear { + if let cachedReview = SessionStateStore.loadOrderReview(orderId: draft.orderId) { + existingReview = cachedReview + rating = cachedReview.rating + comment = cachedReview.comment + selectedOrderPositiveTags = Set(cachedReview.orderPositiveTags ?? []) + selectedOrderImprovementTags = Set(cachedReview.orderImprovementTags ?? []) + deliverySentiment = normalizedSentiment(cachedReview.deliverySentiment) + selectedDeliveryPositiveTags = Set(cachedReview.deliveryPositiveTags ?? []) + selectedDeliveryNegativeTags = Set(cachedReview.deliveryNegativeTags ?? []) + if let savedNps = cachedReview.appNps { + appNps = savedNps + } + } + if let savedDraft = SessionStateStore.loadOrderReviewDraft(orderId: draft.orderId) { + rating = savedDraft.orderRate + comment = savedDraft.orderComment + selectedOrderPositiveTags = Set(savedDraft.orderPositiveTags) + selectedOrderImprovementTags = Set(savedDraft.orderImprovementTags) + deliverySentiment = normalizedSentiment(savedDraft.deliverySentiment) + selectedDeliveryPositiveTags = Set(savedDraft.deliveryPositiveTags) + selectedDeliveryNegativeTags = Set(savedDraft.deliveryNegativeTags) + appNps = savedDraft.appNps + } + } + .task { + await preloadStoreLogoFromHomeStores() + await preloadOrderItems() + await loadTagsCatalog() + await syncReviewFromBackend() + } + .onChange(of: rating) { _, newValue in + enforceOrderTagRules() + } + } + + private var newReviewContent: some View { + VStack(alignment: .leading, spacing: 24) { + starsSection + orderTagsSection + if rating > 0 { + commentSection + } + deliverySection + npsSection + } + } + + private var existingReviewContent: some View { + ExistingReviewReadOnlyView( + likedText: selectedOrderPositiveTags.isEmpty ? nil : joinedSelectedTags(selectedOrderPositiveTags, catalog: orderPositiveTags), + improvementText: selectedOrderImprovementTags.isEmpty ? nil : joinedSelectedTags(selectedOrderImprovementTags, catalog: orderImprovementTags), + comment: comment.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "-" : comment, + storeReply: normalized(existingReview?.storeReplyMessage), + storeReplyDate: formatReplyDate(existingReview?.storeReplyAt), + deliveryTitle: deliverySentimentTitle, + deliveryTagsTitle: deliveryTagsSummaryTitle, + deliveryTagsText: deliveryTagsSummaryText, + rating: rating, + canEditStars: canEditExistingReviewStars + ) { value in + rating = value + } + } + + private var topSection: some View { + let resolvedLogoURL = firstNonEmpty([homeStoreLogoURL, existingReview?.storeLogoURL]) + let resolvedStoreName = normalized(existingReview?.storeName).isEmpty ? draft.storeName : existingReview?.storeName + return VStack(spacing: 10) { + Circle() + .fill(Color(hex: "#C9EDB2")) + .frame(width: 100, height: 100) + .overlay( + AsyncStoreImage(imageURL: resolvedMediaURL(resolvedLogoURL)) + .frame(width: 94, height: 94) + .clipShape(Circle()) + ) + + Text(resolvedStoreName ?? "Pedido") + .font(AppTypography.heading1) + .foregroundStyle(AppColors.textPrimary) + + Text("Pedido #\(draft.shortId ?? draft.orderId) • \(orderDateLabel)") + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + } + .frame(maxWidth: .infinity) + } + + private var starsSection: some View { + VStack(spacing: 12) { + requiredBadge + .frame(maxWidth: .infinity, alignment: .leading) + + Text("O que você achou do pedido?") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + .frame(maxWidth: .infinity, alignment: .leading) + + Text("Escolha de 1 a 5 estrelas para classificar.") + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + .frame(maxWidth: .infinity, alignment: .leading) + + HStack(spacing: 10) { + if canEditRating { + ForEach(1...5, id: \.self) { value in + Button { + rating = value + } label: { + Image(systemName: value <= rating ? "star.fill" : "star") + .font(.system(size: 34, weight: .semibold)) + .foregroundStyle(value <= rating ? Color(hex: "#7CF02A") : Color(hex: "#C5CBD4")) + } + .buttonStyle(.plain) + } + } else { + ForEach(1...5, id: \.self) { value in + Image(systemName: value <= rating ? "star.fill" : "star") + .font(.system(size: 34, weight: .semibold)) + .foregroundStyle(value <= rating ? Color(hex: "#7CF02A") : Color(hex: "#C5CBD4")) + } + } + } + } + .frame(maxWidth: .infinity) + .padding(.top, 2) + } + + private var orderItemsSection: some View { + VStack(alignment: .leading, spacing: 10) { + ForEach(orderItems) { item in + HStack(alignment: .top, spacing: 8) { + let qty = max(1, item.qty ?? 1) + Text("\(qty)x \(item.name ?? "Item")") + .font(AppTypography.body) + .foregroundStyle(AppColors.textPrimary) + .lineLimit(2) + + Spacer(minLength: 8) + + if let price = item.price { + Text(formatCurrency(price)) + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + } + } + .padding(.vertical, 3) + } + } + } + + private var orderTagsSection: some View { + VStack(alignment: .leading, spacing: 10) { + if isLoadingTags { + ProgressView() + .frame(maxWidth: .infinity, alignment: .leading) + } else { + if isOrderPositiveTagsEnabled { + Text("Do que você gostou?") + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + tagCloud( + tags: orderPositiveTags, + selected: selectedOrderPositiveTags + ) { tag in + toggleTag(tag, in: &selectedOrderPositiveTags) + } + } + + if isOrderImprovementTagsEnabled { + Text("O que pode melhorar?") + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + tagCloud( + tags: orderImprovementTags, + selected: selectedOrderImprovementTags + ) { tag in + toggleTag(tag, in: &selectedOrderImprovementTags) + } + } + } + } + } + + private var deliverySection: some View { + VStack(alignment: .leading, spacing: 10) { + requiredBadge + .frame(maxWidth: .infinity, alignment: .leading) + + Text("Você gostou da entrega?") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + + Text("Conte-nos se gostou ou não.") + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + + HStack(spacing: 10) { + sentimentButton(id: "positive", title: "Sim, gostei") + sentimentButton(id: "negative", title: "Não, poderia melhorar") + } + + if deliverySentiment == "positive" { + tagCloud(tags: deliveryPositiveTags, selected: selectedDeliveryPositiveTags) { tag in + toggleTag(tag, in: &selectedDeliveryPositiveTags) + } + } else if deliverySentiment == "negative" { + tagCloud(tags: deliveryNegativeTags, selected: selectedDeliveryNegativeTags) { tag in + toggleTag(tag, in: &selectedDeliveryNegativeTags) + } + } + } + } + + private var npsSection: some View { + VStack(alignment: .leading, spacing: 10) { + requiredBadge + .frame(maxWidth: .infinity, alignment: .leading) + + Text("Avalie o Pedi Foods também") + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + + Text("Em uma escala de 0 a 10, qual é a chance de você indicar o Pedi Foods para um amigo?") + .font(AppTypography.overline) + .foregroundStyle(AppColors.textMuted) + + HStack(spacing: 4) { + ForEach(npsRange, id: \.self) { value in + Button { + appNps = value + } label: { + Text("\(value)") + .font(AppTypography.caption) + .foregroundStyle(appNps == value ? Color.white : AppColors.textPrimary) + .frame(width: 28, height: 28) + .background(appNps == value ? AppColors.primary : AppColors.surface) + .clipShape(Circle()) + } + .buttonStyle(.plain) + } + } + } + } + + private var deliveryTagsSummaryTitle: String? { + if deliverySentiment == "positive", selectedDeliveryPositiveTags.isEmpty == false { + return "Pontos positivos da entrega" + } + if deliverySentiment == "negative", selectedDeliveryNegativeTags.isEmpty == false { + return "Pontos a melhorar na entrega" + } + return nil + } + + private var deliveryTagsSummaryText: String? { + if deliverySentiment == "positive", selectedDeliveryPositiveTags.isEmpty == false { + return joinedSelectedTags(selectedDeliveryPositiveTags, catalog: deliveryPositiveTags) + } + if deliverySentiment == "negative", selectedDeliveryNegativeTags.isEmpty == false { + return joinedSelectedTags(selectedDeliveryNegativeTags, catalog: deliveryNegativeTags) + } + return nil + } + + private var canEditRating: Bool { + if existingReview == nil { return true } + return canEditExistingReviewStars + } + + private var canEditExistingReviewStars: Bool { + guard let review = existingReview else { return false } + let candidateDates = [review.editableUntil, review.storeReplyUntil] + .compactMap(parseISODate) + guard let endDate = candidateDates.max() else { return false } + return Date() <= endDate + } + + private var deliverySentimentTitle: String { + if deliverySentiment == "positive" { return "Sim, gostei" } + if deliverySentiment == "negative" { return "Não, poderia melhorar" } + return "Não informado" + } + + private func joinedSelectedTags(_ selected: Set, catalog: [ReviewTagItem]) -> String { + if selected.isEmpty { return "-" } + let labels = selected.map { token in + canonicalTagLabel(for: token, in: catalog) ?? token + } + return labels.sorted().joined(separator: ", ") + } + + private func canonicalTagLabel(for raw: String, in tags: [ReviewTagItem]) -> String? { + let target = normalizeTagToken(raw) + return tags.first { item in + normalizeTagToken(item.id) == target || normalizeTagToken(item.label) == target + }?.label + } + + private func parseISODate(_ raw: String?) -> Date? { + let value = normalized(raw) + guard value.isEmpty == false else { return nil } + let iso = ISO8601DateFormatter() + iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = iso.date(from: value) { return date } + iso.formatOptions = [.withInternetDateTime] + return iso.date(from: value) + } + + private func formatReplyDate(_ raw: String?) -> String? { + guard let date = parseISODate(raw) else { return nil } + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "pt_BR") + formatter.dateFormat = "dd/MM 'às' HH:mm" + return formatter.string(from: date) + } + + private var requiredBadge: some View { + Text("Obrigatório") + .font(AppTypography.overline) + .foregroundStyle(AppColors.textMuted) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(AppColors.surface) + .clipShape(Capsule()) + .overlay( + Capsule().stroke(AppColors.secondary.opacity(0.2), lineWidth: 1) + ) + } + + private var commentSection: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Comentário") + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + + if existingReview != nil { + Text(comment.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "-" : comment) + .font(AppTypography.body) + .foregroundStyle(AppColors.textPrimary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + } else { + TextEditor(text: $comment) + .frame(height: 110) + .padding(.horizontal, 8) + .padding(.vertical, 8) + .foregroundStyle(AppColors.textPrimary) + .scrollContentBackground(.hidden) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .stroke(AppColors.secondary.opacity(0.18), lineWidth: 1) + ) + .overlay(alignment: .topLeading) { + if comment.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + Text(rating > 4 ? "O que você achou do pedido? Conte-nos sua experiência..." : "O que podemos melhorar? Conte-nos sua experiência...") + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted.opacity(0.9)) + .padding(.horizontal, 14) + .padding(.vertical, 16) + } + } + } + } + } + + private func formatCurrency(_ value: Double) -> String { + String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",") + } + + private var photosSection: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Adicionar fotos") + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + + HStack(spacing: 12) { + Button { + SnackbarCenter.shared.show( + title: "Upload de imagem para review ainda não está disponível na API Mobile.", + style: .info, + icon: "photo.on.rectangle", + duration: 3.0 + ) + } label: { + RoundedRectangle(cornerRadius: 18, style: .continuous) + .stroke(style: StrokeStyle(lineWidth: 2, dash: [6, 6])) + .foregroundStyle(Color(hex: "#D4DAE3")) + .frame(width: 86, height: 86) + .overlay( + VStack(spacing: 2) { + Image(systemName: "camera.fill") + .foregroundStyle(AppColors.textMuted) + Text("UPLOAD") + .font(AppTypography.caption) + .foregroundStyle(AppColors.textMuted) + } + ) + } + .buttonStyle(.plain) + } + } + } + + private var submitButton: some View { + Button(existingReview == nil ? "Enviar Avaliação" : "Reavaliar") { + Task { await submitReview() } + } + .font(AppTypography.button) + .foregroundStyle(AppColors.textInverse) + .frame(maxWidth: .infinity, minHeight: 56) + .background(AppColors.primary) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + .buttonStyle(.plain) + .disabled(canSubmitReview == false || isSubmitting) + .opacity((canSubmitReview == false || isSubmitting) ? 0.55 : 1.0) + } + + private var canSubmitReview: Bool { + let hasRequiredNps = existingReview == nil ? (appNps >= npsMin && appNps <= npsMax) : true + let canEditExisting = existingReview == nil || canEditExistingReviewStars + return rating > 0 && deliverySentiment.isEmpty == false && hasRequiredNps && canEditExisting + } + + private var orderDateLabel: String { + guard let raw = draft.createdAt?.trimmingCharacters(in: .whitespacesAndNewlines), raw.isEmpty == false else { + return "sem data" + } + + let iso = ISO8601DateFormatter() + iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + var date = iso.date(from: raw) + if date == nil { + iso.formatOptions = [.withInternetDateTime] + date = iso.date(from: raw) + } + guard let date else { return raw } + + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "pt_BR") + formatter.dateFormat = "dd/MM 'às' HH:mm" + return formatter.string(from: date) + } + + @MainActor + private func submitReview() async { + if canSubmitReview == false { + SnackbarCenter.shared.show(title: "Preencha nota, sentimento de entrega e NPS.", style: .warning, icon: "star", duration: 2.0) + return + } + + let cleanMessage = comment.trimmingCharacters(in: .whitespacesAndNewlines) + isSubmitting = true + defer { isSubmitting = false } + + do { + let payloadNps: Int + if appNps >= npsMin && appNps <= npsMax { + payloadNps = appNps + } else if let cachedNps = SessionStateStore.loadOrderReview(orderId: draft.orderId)?.appNps, + cachedNps >= npsMin && cachedNps <= npsMax { + payloadNps = cachedNps + } else { + payloadNps = npsMin + } + + let payload = SubmitOrderReviewPayload( + rate: rating, + message: cleanMessage, + orderRate: rating, + orderComment: cleanMessage, + orderPositiveTags: Array(selectedOrderPositiveTags).sorted(), + orderImprovementTags: Array(selectedOrderImprovementTags).sorted(), + deliverySentiment: deliverySentiment, + deliveryPositiveTags: Array(selectedDeliveryPositiveTags).sorted(), + deliveryNegativeTags: Array(selectedDeliveryNegativeTags).sorted(), + appNps: payloadNps, + platform: selectedPlatform + ) + let response = try await ApiService().submitOrderReview(orderId: draft.orderId, payload: payload) + if response.error { + persistDraft() + SnackbarCenter.shared.show( + title: response.message ?? "Não foi possível enviar a avaliação.", + style: .error, + icon: "xmark.octagon.fill", + duration: 3.0 + ) + return + } + + let nowISO = ISO8601DateFormatter().string(from: Date()) + let submittedRecord = OrderReviewRecord( + orderId: normalized(draft.orderId).isEmpty ? draft.orderId : normalized(draft.orderId), + storeId: draft.storeId, + shortId: draft.shortId, + storeName: draft.storeName, + storeLogoURL: homeStoreLogoURL, + createdAt: draft.createdAt, + submittedAt: nowISO, + rating: rating, + comment: cleanMessage, + orderPositiveTags: Array(selectedOrderPositiveTags).sorted(), + orderImprovementTags: Array(selectedOrderImprovementTags).sorted(), + deliverySentiment: deliverySentiment, + deliveryPositiveTags: Array(selectedDeliveryPositiveTags).sorted(), + deliveryNegativeTags: Array(selectedDeliveryNegativeTags).sorted(), + appNps: payloadNps, + platform: selectedPlatform, + editableUntil: nil, + storeReplyUntil: nil, + reviewWindowExpiresAt: nil, + storeReplyMessage: nil, + storeReplyAt: nil + ) + SessionStateStore.saveOrderReview(submittedRecord) + + await syncReviewFromBackend() + SessionStateStore.clearOrderReviewDraft(orderId: draft.orderId) + NotificationCenter.default.post(name: .orderReviewDidSave, object: nil, userInfo: ["orderId": draft.orderId]) + onSaved?() + SnackbarCenter.shared.show(title: "Avaliação enviada com sucesso.", style: .success, icon: "checkmark.circle.fill", duration: 2.0) + dismiss() + } catch { + let message: String + if let serviceError = error as? ApiServiceError { + message = serviceError.localizedDescription + } else if let networkError = error as? NetworkError { + message = networkError.localizedDescription + } else { + message = "Não foi possível enviar a avaliação." + } + persistDraft() + SnackbarCenter.shared.show( + title: message, + style: .error, + icon: "xmark.octagon.fill", + duration: 3.0 + ) + } + } + + private func persistDraft() { + if canSubmitReview == false { + return + } + SessionStateStore.saveOrderReviewDraft( + OrderReviewDraftState( + orderId: draft.orderId, + orderRate: rating, + orderComment: comment.trimmingCharacters(in: .whitespacesAndNewlines), + orderPositiveTags: Array(selectedOrderPositiveTags).sorted(), + orderImprovementTags: Array(selectedOrderImprovementTags).sorted(), + deliverySentiment: deliverySentiment, + deliveryPositiveTags: Array(selectedDeliveryPositiveTags).sorted(), + deliveryNegativeTags: Array(selectedDeliveryNegativeTags).sorted(), + appNps: appNps, + platform: selectedPlatform + ) + ) + } + + @MainActor + private func loadTagsCatalog() async { + if isLoadingTags { return } + isLoadingTags = true + defer { isLoadingTags = false } + + do { + let response = try await ApiService().reviewTagsCatalog() + if response.error == false, let result = response.result { + tagsCatalog = result + remapSelectionsToCatalog() + enforceOrderTagRules() + enforceDeliveryTagRules() + } else { + SnackbarCenter.shared.show( + title: response.message ?? "Não foi possível carregar as tags de avaliação.", + style: .warning, + icon: "tag", + duration: 2.5 + ) + } + } catch { + SnackbarCenter.shared.show( + title: "Não foi possível carregar as tags de avaliação.", + style: .warning, + icon: "tag", + duration: 2.5 + ) + } + } + + private var orderPositiveTags: [ReviewTagItem] { + tagsCatalog?.order?.positive ?? [] + } + + private var orderImprovementTags: [ReviewTagItem] { + tagsCatalog?.order?.improvement ?? [] + } + + private var deliveryPositiveTags: [ReviewTagItem] { + tagsCatalog?.delivery?.positive ?? [] + } + + private var deliveryNegativeTags: [ReviewTagItem] { + tagsCatalog?.delivery?.negative ?? [] + } + + private var positiveAllowedThreshold: Int { + tagsCatalog?.order?.rules?.positiveAllowedWhenRateGte ?? 5 + } + + private var improvementAllowedThreshold: Int { + tagsCatalog?.order?.rules?.improvementAllowedWhenRateLte ?? 4 + } + + private var isOrderPositiveTagsEnabled: Bool { + rating > 0 && rating >= positiveAllowedThreshold + } + + private var isOrderImprovementTagsEnabled: Bool { + rating > 0 && rating <= improvementAllowedThreshold + } + + private var npsMin: Int { + tagsCatalog?.app?.nps?.min ?? 0 + } + + private var npsMax: Int { + tagsCatalog?.app?.nps?.max ?? 10 + } + + private var npsRange: [Int] { + if npsMin <= npsMax { + return Array(npsMin...npsMax) + } + return Array(0...10) + } + + private var selectedPlatform: String { + let current = ReviewPlatform.current.rawValue + let allowed = (tagsCatalog?.app?.platforms ?? []).map { $0.lowercased() } + if allowed.isEmpty || allowed.contains(current) { + return current + } + return allowed.first ?? current + } + + private func enforceOrderTagRules() { + if isOrderPositiveTagsEnabled == false { + selectedOrderPositiveTags.removeAll() + } + if isOrderImprovementTagsEnabled == false { + selectedOrderImprovementTags.removeAll() + } + } + + private func enforceDeliveryTagRules() { + if deliverySentiment != "positive" { + selectedDeliveryPositiveTags.removeAll() + } + if deliverySentiment != "negative" { + selectedDeliveryNegativeTags.removeAll() + } + } + + private func sentimentButton(id: String, title: String) -> some View { + Button { + deliverySentiment = id + enforceDeliveryTagRules() + } label: { + Text(title) + .font(AppTypography.button) + .foregroundStyle(deliverySentiment == id ? Color.white : AppColors.textPrimary) + .frame(maxWidth: .infinity, minHeight: 44) + .background(deliverySentiment == id ? AppColors.primary : AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous) + .stroke(AppColors.secondary.opacity(deliverySentiment == id ? 0 : 0.25), lineWidth: 1) + ) + } + .buttonStyle(.plain) + } + + private func tagCloud(tags: [ReviewTagItem], selected: Set, onTap: @escaping (ReviewTagItem) -> Void) -> some View { + let columns = [GridItem(.adaptive(minimum: 120), spacing: 8)] + return AnyView( + LazyVGrid(columns: columns, alignment: .leading, spacing: 8) { + ForEach(tags) { tag in + let isSelected = containsSelectedTag(selected, tag: tag) + Button { + onTap(tag) + } label: { + Text(tag.label) + .font(AppTypography.caption) + .foregroundStyle(isSelected ? AppColors.textInverse : AppColors.textPrimary) + .frame(maxWidth: .infinity) + .padding(.horizontal, 10) + .padding(.vertical, 9) + .background(isSelected ? AppColors.primary : AppColors.surface) + .clipShape(Capsule()) + .overlay( + Capsule() + .stroke(AppColors.secondary.opacity(isSelected ? 0 : 0.2), lineWidth: 1) + ) + } + .buttonStyle(.plain) + } + } + ) + } + + private func toggleTag(_ tag: ReviewTagItem, in set: inout Set) { + let normalizedId = normalizeTagToken(tag.id) + let normalizedLabel = normalizeTagToken(tag.label) + let hasEquivalent = set.contains { value in + let normalizedValue = normalizeTagToken(value) + return normalizedValue == normalizedId || normalizedValue == normalizedLabel + } + + set = Set(set.filter { value in + let normalizedValue = normalizeTagToken(value) + return normalizedValue != normalizedId && normalizedValue != normalizedLabel + }) + + if hasEquivalent == false { + set.insert(tag.id) + } + } + + @MainActor + private func syncReviewFromBackend() async { + do { + let service = ApiService() + var storeId = normalized(draft.storeId) + var orderName = normalized(draft.storeName) + if storeId.isEmpty { + let ordersResponse = try? await service.listOrders(forceRefresh: true) + if let order = ordersResponse?.result?.first(where: { + normalized($0.orderId) == normalized(draft.orderId) + || normalized($0.id) == normalized(draft.orderId) + || normalized($0.realId) == normalized(draft.orderId) + || normalized($0.shortId) == normalized(draft.shortId) + }) { + storeId = normalized(order.storeId) + if orderName.isEmpty { + orderName = normalized(order.storeName) + } + } + } + guard storeId.isEmpty == false else { return } + + var stores: [StoreSummary] = AppContentCache.shared.value( + for: AppCacheKey.homeStoresLatestSnapshot, + as: [StoreSummary].self + ) ?? [] + if stores.isEmpty { + let storesResponse = try? await service.listStores() + stores = storesResponse?.result ?? [] + } + let storeLogoFromList = stores.first(where: { normalized($0.id) == storeId })?.logo + ?? stores.first(where: { normalizedStoreKey($0.name) == normalizedStoreKey(orderName) })?.logo + let storeNameFromList = stores.first(where: { normalized($0.id) == storeId })?.name + homeStoreLogoURL = firstNonEmpty([storeLogoFromList, homeStoreLogoURL]) + + let response = try await service.publicStoreReviews(storeId: storeId) + guard response.error == false else { return } + let remoteReview = response.result?.reviews.first { review in + normalized(review.orderId) == normalized(draft.orderId) + } + guard let remoteReview else { return } + + let cachedReviewNps = SessionStateStore.loadOrderReview(orderId: draft.orderId)?.appNps + let localNps = (appNps >= npsMin && appNps <= npsMax) ? appNps : nil + let resolvedNps = remoteReview.appNps ?? cachedReviewNps ?? existingReview?.appNps ?? localNps + + let record = OrderReviewRecord( + orderId: normalized(remoteReview.orderId).isEmpty ? draft.orderId : normalized(remoteReview.orderId), + storeId: storeId, + shortId: draft.shortId, + storeName: firstNonEmpty([orderName, storeNameFromList, draft.storeName]), + storeLogoURL: firstNonEmpty([storeLogoFromList, homeStoreLogoURL]), + createdAt: draft.createdAt, + submittedAt: remoteReview.date ?? ISO8601DateFormatter().string(from: Date()), + rating: remoteReview.orderRate ?? remoteReview.rate ?? 0, + comment: remoteReview.orderComment ?? remoteReview.message ?? "", + orderPositiveTags: remoteReview.orderPositiveTags, + orderImprovementTags: remoteReview.orderImprovementTags, + deliverySentiment: remoteReview.deliverySentiment, + deliveryPositiveTags: remoteReview.deliveryPositiveTags, + deliveryNegativeTags: remoteReview.deliveryNegativeTags, + appNps: resolvedNps, + platform: remoteReview.platform, + editableUntil: remoteReview.editableUntil, + storeReplyUntil: remoteReview.storeReplyUntil, + reviewWindowExpiresAt: remoteReview.reviewWindowExpiresAt, + storeReplyMessage: remoteReview.storeReplyMessage, + storeReplyAt: remoteReview.storeReplyAt + ) + + SessionStateStore.saveOrderReview(record) + existingReview = record + rating = record.rating + comment = record.comment + selectedOrderPositiveTags = Set(record.orderPositiveTags ?? []) + selectedOrderImprovementTags = Set(record.orderImprovementTags ?? []) + deliverySentiment = normalizedSentiment(record.deliverySentiment) + selectedDeliveryPositiveTags = Set(record.deliveryPositiveTags ?? []) + selectedDeliveryNegativeTags = Set(record.deliveryNegativeTags ?? []) + if let savedNps = record.appNps { + appNps = savedNps + } + } catch { + // Keep local state when sync fails. + } + } + + @MainActor + private func preloadOrderItems() async { + do { + let service = ApiService() + let orderIds = [ + normalized(draft.orderId), + normalized(draft.shortId) + ].filter { $0.isEmpty == false } + + var orderResult: PublicOrderResult? = nil + for candidate in orderIds { + let response = try? await service.publicOrder(orderId: candidate) + if response?.error == false, let result = response?.result { + orderResult = result + break + } + } + + if orderResult == nil { + let ordersResponse = try? await service.listOrders(forceRefresh: true) + if let order = ordersResponse?.result?.first(where: { + normalized($0.orderId) == normalized(draft.orderId) + || normalized($0.id) == normalized(draft.orderId) + || normalized($0.realId) == normalized(draft.orderId) + || normalized($0.shortId) == normalized(draft.shortId) + }) { + let fallbackCandidates = [ + normalized(order.orderId), + normalized(order.realId), + normalized(order.id) + ].filter { $0.isEmpty == false } + for candidate in fallbackCandidates { + let response = try? await service.publicOrder(orderId: candidate) + if response?.error == false, let result = response?.result { + orderResult = result + break + } + } + } + } + + orderItems = orderResult?.items ?? [] + } catch { + orderItems = [] + } + } + + @MainActor + private func preloadStoreLogoFromHomeStores() async { + do { + let service = ApiService() + var stores: [StoreSummary] = AppContentCache.shared.value( + for: AppCacheKey.homeStoresLatestSnapshot, + as: [StoreSummary].self + ) ?? [] + if stores.isEmpty { + let storesResponse = try? await service.listStores() + stores = storesResponse?.result ?? [] + } +#if os(iOS) + for store in stores { + printLog( + title: "LOGO REVIEW DETALHE", + msg: "storeId=\(store.id) | storeName=\(store.name) | logoRaw=\(store.logo ?? "nil")" + ) + } +#endif + guard stores.isEmpty == false else { return } + + var logoById: [String: String] = [:] + var logoByName: [String: String] = [:] + for store in stores { + let normalizedId = normalized(store.id) + let normalizedName = normalizedStoreKey(store.name) + let normalizedLogo = normalized(store.logo) + guard normalizedLogo.isEmpty == false else { continue } + if normalizedId.isEmpty == false { + logoById[normalizedId] = normalizedLogo + } + if normalizedName.isEmpty == false { + logoByName[normalizedName] = normalizedLogo + } + } + + var resolvedLogo = firstNonEmpty([ + logoById[normalized(draft.storeId)], + logoByName[normalizedStoreKey(draft.storeName)] + ]) + + if resolvedLogo == nil { + let ordersResponse = try? await service.listOrders(forceRefresh: true) + if let order = ordersResponse?.result?.first(where: { + normalized($0.orderId) == normalized(draft.orderId) + || normalized($0.id) == normalized(draft.orderId) + || normalized($0.realId) == normalized(draft.orderId) + || normalized($0.shortId) == normalized(draft.shortId) + }) { + resolvedLogo = firstNonEmpty([ + logoById[normalized(order.storeId)], + logoByName[normalizedStoreKey(order.storeName)] + ]) + } + } + + if let resolvedLogo { + homeStoreLogoURL = resolvedLogo + } + } catch { + // Keep placeholder when preload fails. + } + } + + private func normalizedSentiment(_ raw: String?) -> String { + let value = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if value == "positive" || value == "negative" { + return value + } + return "" + } + + private func normalized(_ value: String?) -> String { + (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func normalizedStoreKey(_ value: String?) -> String { + normalized(value) + .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) + .lowercased() + } + + private func firstNonEmpty(_ values: [String?]) -> String? { + for value in values { + let trimmed = normalized(value) + if trimmed.isEmpty == false { + return trimmed + } + } + return nil + } + + private func resolvedMediaURL(_ raw: String?) -> String? { + ImageSourceResolver.resolve(raw) + } + + private func remapSelectionsToCatalog() { + selectedOrderPositiveTags = remapSelection(selectedOrderPositiveTags, tags: orderPositiveTags) + selectedOrderImprovementTags = remapSelection(selectedOrderImprovementTags, tags: orderImprovementTags) + selectedDeliveryPositiveTags = remapSelection(selectedDeliveryPositiveTags, tags: deliveryPositiveTags) + selectedDeliveryNegativeTags = remapSelection(selectedDeliveryNegativeTags, tags: deliveryNegativeTags) + } + + private func remapSelection(_ selection: Set, tags: [ReviewTagItem]) -> Set { + if selection.isEmpty || tags.isEmpty { return selection } + var mapped: Set = [] + for raw in selection { + if let canonical = canonicalTagId(for: raw, in: tags) { + mapped.insert(canonical) + } else { + mapped.insert(raw) + } + } + return mapped + } + + private func canonicalTagId(for raw: String, in tags: [ReviewTagItem]) -> String? { + let target = normalizeTagToken(raw) + return tags.first { item in + normalizeTagToken(item.id) == target || normalizeTagToken(item.label) == target + }?.id + } + + private func containsSelectedTag(_ selected: Set, tag: ReviewTagItem) -> Bool { + let normalizedId = normalizeTagToken(tag.id) + let normalizedLabel = normalizeTagToken(tag.label) + return selected.contains { value in + let normalizedValue = normalizeTagToken(value) + return normalizedValue == normalizedId || normalizedValue == normalizedLabel + } + } + + private func normalizeTagToken(_ value: String) -> String { + value + .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) + .lowercased() + .replacingOccurrences(of: "_", with: "") + .replacingOccurrences(of: "-", with: "") + .replacingOccurrences(of: " ", with: "") + .trimmingCharacters(in: .whitespacesAndNewlines) + } +} + +struct ExistingReviewReadOnlyView: View { + let likedText: String? + let improvementText: String? + let comment: String + let storeReply: String? + let storeReplyDate: String? + let deliveryTitle: String + let deliveryTagsTitle: String? + let deliveryTagsText: String? + let rating: Int + let canEditStars: Bool + let onSelectStar: (Int) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + if let likedText, likedText.isEmpty == false { + summaryBlock(title: "Do que você gostou no pedido", text: likedText) + } + if let improvementText, improvementText.isEmpty == false { + summaryBlock(title: "O que você sugeriu melhorar", text: improvementText) + } + + summaryBlock(title: "Como foi a entrega", text: deliveryTitle) + if let deliveryTagsTitle, let deliveryTagsText, deliveryTagsText.isEmpty == false { + summaryBlock(title: deliveryTagsTitle, text: deliveryTagsText) + } + + summaryCard(title: "Comentário", text: comment) + + if let storeReply, storeReply.isEmpty == false { + summaryCard( + title: "Resposta da loja", + subtitle: storeReplyDate, + text: storeReply + ) + } + + VStack(alignment: .leading, spacing: 10) { + Text("Nota do pedido") + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + HStack(spacing: 10) { + if canEditStars { + ForEach(1...5, id: \.self) { value in + Button { + onSelectStar(value) + } label: { + Image(systemName: value <= rating ? "star.fill" : "star") + .font(.system(size: 26, weight: .semibold)) + .foregroundStyle(value <= rating ? Color(hex: "#7CF02A") : Color(hex: "#C5CBD4")) + } + .buttonStyle(.plain) + } + } else { + ForEach(1...5, id: \.self) { value in + Image(systemName: value <= rating ? "star.fill" : "star") + .font(.system(size: 26, weight: .semibold)) + .foregroundStyle(value <= rating ? Color(hex: "#7CF02A") : Color(hex: "#C5CBD4")) + } + } + } + } + } + } + + private func summaryBlock(title: String, text: String) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + Text(text) + .font(AppTypography.body) + .foregroundStyle(AppColors.textPrimary) + } + } + + private func summaryCard(title: String, subtitle: String? = nil, text: String) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + + if let subtitle, subtitle.isEmpty == false { + Text(subtitle) + .font(AppTypography.caption) + .foregroundStyle(AppColors.textMuted) + } + + Text(text) + .font(AppTypography.body) + .foregroundStyle(AppColors.textPrimary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + } + } +} diff --git a/PediFoods/Views/Main/SavedCardsView.swift b/PediFoods/Views/Main/SavedCardsView.swift new file mode 100644 index 0000000..ec915c3 --- /dev/null +++ b/PediFoods/Views/Main/SavedCardsView.swift @@ -0,0 +1,234 @@ +import SwiftUI +#if canImport(LCEssentials) +import LCEssentials +#endif + +struct SavedCardsView: View { + @Binding var appState: AppState + @Environment(\.dismiss) var dismiss + + @State private var cards: [SavedCard] = [] + @State private var isLoading = false + @State private var errorMessage: String? = nil + @State private var openSwipeRowId: String? = nil + @State private var deletingCardId: String? = nil + @State private var showAddCard = false + + private var canDelete: Bool { cards.count > 1 } + private let tabBarClearance: CGFloat = 96 + + var body: some View { + LCENavigationView { + content + } + .setLeftButton(image: AnyView(AppBackButtonIcon())) { dismiss() } + .setTitle(text: Text("Meus Cartões").font(AppTypography.heading2).foregroundStyle(AppColors.textPrimary)) + .setNavigationBarBackgroundColor(AppColors.backgroundLight) + .buttonStyle(.plain) + } + + private var content: some View { + ZStack { + AppColors.backgroundLight.ignoresSafeArea() + + ScrollView(showsIndicators: false) { + VStack(spacing: 20) { + VStack(spacing: 12) { + if isLoading { + ProgressView() + .frame(maxWidth: .infinity) + .padding(.top, 32) + } else if let errorMessage { + Text(errorMessage) + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + .multilineTextAlignment(.center) + .padding(.top, 32) + } else if cards.isEmpty { + Text("Nenhum cartão cadastrado") + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + .padding(.top, 32) + } else { + ForEach(cards) { card in + if canDelete { + SwipeToDeleteAddressRow( + rowId: card.id, + openRowId: $openSwipeRowId, + isDeleting: deletingCardId == card.id, + onDelete: { deleteCard(card) } + ) { + cardRow(card) + .appContentShape(Rectangle()) + .onTapGesture { + if openSwipeRowId == card.id { openSwipeRowId = nil } + } + } + .id(card.id) + .opacity(deletingCardId == card.id ? 0.6 : 1.0) + .disabled(deletingCardId != nil) + } else { + cardRow(card) + } + } + } + } + } + .padding(.horizontal, 20) + .padding(.top, 18) + .padding(.bottom, tabBarClearance + 20) + } + + VStack { + Spacer() + addCardButton + .padding(.bottom, tabBarClearance) + } + } + .navigationBarBackButtonHidden(true) + .appHiddenNavigationBar() + .sheet(isPresented: $showAddCard) { + NavigationStack { + AddCardFormView(appState: appState, isFirstCard: cards.isEmpty) { newCard in + cards.append(newCard) + } + } + } + .task { await loadCards() } + } + + private var addCardButton: some View { + ZStack(alignment: .bottom) { + Rectangle() + .fill(AppColors.backgroundLight) + .frame(height: 136) + Button(action: { showAddCard = true }) { + HStack(spacing: 12) { + Image(systemName: "creditcard.fill") + .font(.system(size: 20)) + Text("Adicionar novo cartão") + .font(AppTypography.heading3) + } + .foregroundStyle(AppColors.textInverse) + .frame(maxWidth: .infinity) + .padding(.vertical, 18) + .background(AppColors.primary) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous)) + } + .buttonStyle(.plain) + .padding(.horizontal, 20) + .padding(.bottom, 14) + } + } + + private func cardRow(_ card: SavedCard) -> some View { + HStack(spacing: 14) { + ZStack { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(AppColors.backgroundLight) + .frame(width: 52, height: 52) + if let logo = brandLogoName(for: card.brand) { + Image(logo) + .resizable() + .scaledToFit() + .frame(width: 36, height: 24) + } else { + Image(systemName: "creditcard.fill") + .font(.system(size: 22)) + .foregroundStyle(AppColors.textMuted) + } + } + + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + Text(card.displayLabel) + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + if card.isDefault { + Text("Principal") + .font(AppTypography.caption) + .foregroundStyle(AppColors.primary) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(AppColors.brandSoft) + .clipShape(Capsule()) + } + } + Text("Vence \(card.expiryLabel)") + .font(AppTypography.caption) + .foregroundStyle(AppColors.textMuted) + if !canDelete { + Text("Ao menos um cartão deve permanecer") + .font(AppTypography.caption) + .foregroundStyle(AppColors.textMuted.opacity(0.7)) + } + } + + Spacer(minLength: 0) + } + .padding(14) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + + private func brandLogoName(for brand: String?) -> String? { + switch brand?.lowercased() + .trimmingCharacters(in: .whitespacesAndNewlines) + .folding(options: .diacriticInsensitive, locale: .current) { + case "visa": return "visacard_logo" + case "mastercard", "master": return "mastercard_logo" + case "amex", "american express", "americanexpress": return "amexcard_logo" + case "hipercard": return "hipercard_logo" + case "alelo": return "alelocard_logo" + case "sodexo": return "sodexo_logo" + default: return nil + } + } + + @MainActor + private func loadCards() async { + guard isLoading == false else { return } + isLoading = true + errorMessage = nil + defer { isLoading = false } + do { + let response = try await ApiService().listCards() + if response.error == false { + cards = response.result ?? [] + } else { + errorMessage = response.message ?? "Erro ao carregar cartões." + } + } catch { + errorMessage = "Não foi possível carregar seus cartões." + } + } + + private func deleteCard(_ card: SavedCard) { + guard canDelete, deletingCardId == nil else { return } + deletingCardId = card.id + openSwipeRowId = nil + Task { + do { + let response = try await ApiService().deleteCard(cardId: card.id) + if response.error == false { + cards.removeAll { $0.id == card.id } + } else { + SnackbarCenter.shared.show( + title: response.message ?? "Não foi possível excluir o cartão.", + style: .error, + icon: "xmark.octagon.fill", + duration: 3.0 + ) + } + } catch { + SnackbarCenter.shared.show( + title: "Erro ao excluir cartão.", + style: .error, + icon: "xmark.octagon.fill", + duration: 3.0 + ) + } + deletingCardId = nil + } + } +} diff --git a/PediFoods/Views/Main/StoreDetailPizzaSupport.swift b/PediFoods/Views/Main/StoreDetailPizzaSupport.swift new file mode 100644 index 0000000..f8e1eb4 --- /dev/null +++ b/PediFoods/Views/Main/StoreDetailPizzaSupport.swift @@ -0,0 +1,32 @@ +import Foundation + +struct StoreCatalogListItem: Identifiable { + let id: String + let product: StoreCatalogProduct + let title: String + let description: String? + let imageURL: String? + let isPizzaSummary: Bool + let pizzaCategoryId: String? + let pizzaProductIds: [String] + + init( + id: String, + product: StoreCatalogProduct, + title: String, + description: String?, + imageURL: String?, + isPizzaSummary: Bool = false, + pizzaCategoryId: String? = nil, + pizzaProductIds: [String] = [] + ) { + self.id = id + self.product = product + self.title = title + self.description = description + self.imageURL = imageURL + self.isPizzaSummary = isPizzaSummary + self.pizzaCategoryId = pizzaCategoryId + self.pizzaProductIds = pizzaProductIds + } +} diff --git a/PediFoods/Views/Main/StoreDetailSupport.swift b/PediFoods/Views/Main/StoreDetailSupport.swift new file mode 100644 index 0000000..a1e2e74 --- /dev/null +++ b/PediFoods/Views/Main/StoreDetailSupport.swift @@ -0,0 +1,76 @@ +import SwiftUI + +struct CategoryHeaderOffsetPreferenceKey: PreferenceKey { + static let defaultValue: [String: CGFloat] = [:] + + static func reduce(value: inout [String: CGFloat], nextValue: () -> [String: CGFloat]) { + value.merge(nextValue(), uniquingKeysWith: { _, new in new }) + } +} + +enum StoreDetailScrollCoordinateSpace { + static let name = "store-detail-scroll" +} + +struct ScrollOffsetPreferenceKey: PreferenceKey { + static let defaultValue: CGFloat = 0 + + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = nextValue() + } +} + +struct ScrollOffsetReader: View { + @Binding var offsetY: CGFloat + @State private var baseline: CGFloat? = nil + + var body: some View { + Color.clear + .frame(height: 0) + .background( + GeometryReader { geometry in + Color.clear.preference( + key: ScrollOffsetPreferenceKey.self, + value: geometry.frame(in: .named(StoreDetailScrollCoordinateSpace.name)).minY + ) + } + ) + .onPreferenceChange(ScrollOffsetPreferenceKey.self) { minY in + if baseline == nil { baseline = minY } + let offset = (baseline ?? 0) - minY + if abs(offsetY - offset) > 0.5 { + offsetY = offset + } + } + } +} + +struct AsyncStoreImage: View { + let imageURL: String? + var fallbackImageName: String = "placeholder-product" + var fitMode: ImageFitMode = .fill + + var body: some View { + GeometryReader { geometry in + CachedRemoteImage(imageURL: imageURL, fitMode: fitMode) { + fallback + } + // Lock the scaledToFill image to the actually proposed box. + // Without this, a wide/landscape source image's fill-scaled + // ideal width can exceed the box and balloon the parent + // ZStack's ideal width, pushing sibling content off-screen. + .frame(width: geometry.size.width, height: geometry.size.height) + .clipped() + } + .background(AppColors.brandSoft) + } + + private var fallback: some View { + Image(fallbackImageName) + .resizable() + .scaledToFill() + } +} + + + diff --git a/PediFoods/Views/Main/StoreDetailView+Components.swift b/PediFoods/Views/Main/StoreDetailView+Components.swift new file mode 100644 index 0000000..379aa93 --- /dev/null +++ b/PediFoods/Views/Main/StoreDetailView+Components.swift @@ -0,0 +1,368 @@ +import SwiftUI + +extension StoreDetailView { + + var topSection: some View { + ZStack(alignment: .top) { + heroSection + .frame(height: topSectionHeight) + + // Hard cut: cover cannot appear below this line. + Rectangle() + .fill(AppColors.backgroundLight) + .frame(height: max(0, topSectionHeight - coverVisibleUntilY)) + .offset(y: coverVisibleUntilY) + + summaryCard + .padding(.horizontal, 16) + .padding(.top, cardTopInset) + + storeLogoBadge + .padding(.top, cardTopInset - (storeLogoSize / 2)) + } + .frame(height: topSectionHeight) + } + + var heroSection: some View { + ZStack(alignment: .top) { + AsyncStoreImage(imageURL: resolvedURL(storeCoverURL), fitMode: .heightFit) + .frame(height: topSectionHeight + stretchAmount) + .offset(y: -stretchAmount) + .ignoresSafeArea(.container, edges: .top) + + LinearGradient( + colors: [Color.black.opacity(0.32), Color.black.opacity(0.05)], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: topSectionHeight) + .ignoresSafeArea(.container, edges: .top) + .allowsHitTesting(false) + + VStack(spacing: 0) { + HStack { + heroIconButton(icon: "chevron.left") { + dismiss() + } + Spacer() + heroIconButton(icon: "magnifyingglass") {} + heroIconButton( + icon: isFavoriteStore ? "heart.fill" : "heart", + foregroundStyle: isFavoriteStore ? Color.red : Color.white + ) { + Task { + await toggleFavoriteStore() + } + } + } + .padding(.horizontal, 14) + .padding(.top, UIDevice.topNotch) + + Spacer() + + Text("RESTAURANT") + .font(AppTypography.overline) + .tracking(1.8) + .foregroundStyle(Color.white.opacity(0.92)) + .padding(.bottom, 14) + } + } + } + + var storeLogoBadge: some View { + ZStack { + Circle() + .fill(AppColors.surface) + .frame(width: storeLogoSize, height: storeLogoSize) + .overlay( + Circle() + .stroke(Color.white, lineWidth: 0.1) + ) + + AsyncStoreImage(imageURL: resolvedURL(storeLogoURL)) + .frame(width: storeLogoSize - 10, height: storeLogoSize - 10) + .clipShape(Circle()) + } + .shadow(color: Color.black.opacity(0.10), radius: 8, y: 3) + } + + var summaryCard: some View { + VStack(spacing: 0) { + VStack(alignment: .leading, spacing: 14) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 6) { + Text(storeName) + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + .lineLimit(2) + .padding(.top, 30) + + Text(storeSubtitle) + .font(.caption) + .foregroundStyle(AppColors.textMuted) + .lineLimit(1) + } + + Spacer() + + ratingChip + } + + HStack(spacing: 0) { + // No distance available: drop the tile and its divider + // instead of showing a placeholder - see + // docs/plans/public-store-distance-consumer.md. + if hasStoreDistance { + statItem(title: "DISTÂNCIA", value: distanceValueLabel) + Divider().frame(height: 34) + } + if let deliveryTime = info?.deliveryTime { + statItem(title: "TEMPO MIN.", value: deliveryTime+" min.") + } else { + statItem(title: "TEMPO", value: "--") + } + Divider().frame(height: 34) + statItem(title: "PED. MIN.", value: deliveryValueLabel) + } + .padding(.vertical, 4) + } + .padding(16) + + if isStoreOpen == false { + Text(closedStoreBannerText) + .font(AppTypography.heading3) + .foregroundStyle(Color.white) + .frame(maxWidth: .infinity, minHeight: closedBannerHeight) + .background(AppColors.brandDark) + } + } + .frame(height: summaryCardHeight) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous)) + .shadow(color: AppShadow.soft.color, radius: AppShadow.soft.radius, y: AppShadow.soft.y) + } + + @ViewBuilder + var sectionedProducts: some View { + // Once we have categories loaded, keep showing them regardless of a + // subsequent refresh's isLoading/errorMessage state — a failed or + // in-flight pull-to-refresh must never hide already-loaded content. + if categories.isEmpty == false { + sectionedProductsList + } else if isLoading { + VStack(spacing: 10) { + ProgressView() + Text("Carregando cardápio...") + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + } + .frame(maxWidth: .infinity, minHeight: 280, alignment: .center) + } else if let errorMessage { + VStack(alignment: .leading, spacing: 10) { + Text(errorMessage) + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + Button("Tentar novamente") { + Task { await loadStoreData() } + } + .font(AppTypography.heading3) + .foregroundStyle(AppColors.primary) + } + .padding(.top, 16) + } else { + Text("Cardápio indisponível no momento.") + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + .padding(.top, 16) + .padding(.horizontal, 16) + .padding(.bottom, 120) + } + } + + @ViewBuilder + private var sectionedProductsList: some View { + ForEach(categories, id: \.id) { category in + Text(category.name) + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + .id(sectionAnchorId(for: category.id)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.top, 8) + .padding(.bottom, 8) + .background(AppColors.backgroundLight) + .background( + GeometryReader { geometry in + Color.clear.preference( + key: CategoryHeaderOffsetPreferenceKey.self, + value: [category.id: geometry.frame(in: .global).minY] + ) + } + ) + + VStack(spacing: 12) { + ForEach(listItems(for: category)) { item in + productCard(item, in: category) + } + } + .padding(.horizontal, 16) + .padding(.bottom, 8) + } + Color.clear.frame(height: 140) + } + + func categoryTabs(proxy: ScrollViewProxy, isPinned: Bool = false) -> some View { + let safeTop: CGFloat = { + guard isPinned else { return 0 } + return UIDevice.appSafeAreaTop + }() + + return ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 10) { + ForEach(categories, id: \.id) { category in + let active = selectedCategoryId == category.id + Button { + selectedCategoryId = category.id + isProgrammaticCategoryScroll = true + withAnimation(.easeInOut(duration: 0.25)) { + proxy.scrollTo(sectionAnchorId(for: category.id), anchor: .top) + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { + isProgrammaticCategoryScroll = false + } + } label: { + Text(category.name) + .font(AppTypography.heading3) + .foregroundStyle(active ? AppColors.textInverse : AppColors.textMuted) + .padding(.horizontal, 16) + .padding(.vertical, 9) + .background(active ? AppColors.primary : AppColors.surface) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + .padding(.top, safeTop) + .background(AppColors.backgroundLight) + .animation(.easeInOut(duration: 0.15), value: isPinned) + } + + func productCard(_ item: StoreCatalogListItem, in category: StoreCatalogCategory) -> some View { + let product = item.product + let hasSelectableAddons = product.addonGroups.contains { $0.items.isEmpty == false } + + return HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 8) { + Text(item.title) + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + .lineLimit(2) + .multilineTextAlignment(.leading) + + if let description = item.description, description.isEmpty == false { + Text(description) + .font(.caption) + .foregroundStyle(AppColors.textMuted) + .lineLimit(2) + .multilineTextAlignment(.leading) + } + + Text(listPriceLabel(for: product, in: category)) + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + } + + Spacer() + + ZStack(alignment: .bottomTrailing) { + Group { + if item.isPizzaSummary { + Image("placeholder-pizza") + .resizable() + .scaledToFill() + } else { + AsyncStoreImage(imageURL: resolvedURL(item.imageURL)) + } + } + .frame(width: 92, height: 92) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + + Button { + guard isStoreOpen else { + SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0) + return + } + if item.isPizzaSummary, let pizzaCategoryId = item.pizzaCategoryId { + requestOpenPizzaSheet(categoryId: pizzaCategoryId) + return + } + if hasSelectableAddons == false { + let basePrice = product.price ?? 0 + let item = CartItemState( + id: "\(storeId)::\(product.id)::base", + productId: product.id, + storeId: storeId, + name: product.name, + imageURL: resolvedURL(product.image), + quantity: 1, + unitPrice: basePrice + ) + requestAddToCart(item) + } else { + requestOpenProductSheet(product) + } + } label: { + ZStack(alignment: .leading) { + Image(systemName: "plus") + .font(.system(size: 16, weight: .bold)) + .foregroundStyle(AppColors.textPrimary) + .frame(width: 30, height: 30) + .background(AppColors.tertiary) + .clipShape(Circle()) + + let qty = quantityInCart(for: item) + if qty > 0 { + Text("\(qty)") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(Color.white) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background(Color.red) + .clipShape(Capsule()) + .offset(x: -6, y: -10) + .zIndex(1) + } + } + .offset(x: 3, y: -3) + .frame(width: 30, height: 30) + .appContentShape(Circle()) + } + .buttonStyle(.plain) + .frame(width: 30, height: 30) + .disabled(isStoreOpen == false) + .opacity(isStoreOpen ? 1 : 0.65) + .offset(x: 7, y: 7) + } + } + .padding(12) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + .appContentShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + .onTapGesture { + guard isStoreOpen else { + SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0) + return + } + if item.isPizzaSummary, let pizzaCategoryId = item.pizzaCategoryId { + requestOpenPizzaSheet(categoryId: pizzaCategoryId) + return + } + guard hasSelectableAddons else { return } + requestOpenProductSheet(product) + } + } + +} diff --git a/PediFoods/Views/Main/StoreDetailView+Logic.swift b/PediFoods/Views/Main/StoreDetailView+Logic.swift new file mode 100644 index 0000000..1e3daef --- /dev/null +++ b/PediFoods/Views/Main/StoreDetailView+Logic.swift @@ -0,0 +1,485 @@ +import SwiftUI + +extension StoreDetailView { + func heroIconButton( + icon: String, + foregroundStyle: Color = .white, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + Image(systemName: icon) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(foregroundStyle) + .frame(width: 32, height: 32) + .background(Color.white.opacity(0.24)) + .clipShape(Circle()) + } + .buttonStyle(.plain) + } + + func statItem(title: String, value: String) -> some View { + VStack(spacing: 4) { + Text(title) + .font(AppTypography.caption) + .foregroundStyle(AppColors.textMuted) + Text(value) + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + } + .frame(maxWidth: .infinity) + } + + var ratingChip: some View { + HStack(spacing: 6) { + Image(systemName: "star.fill") + .font(.caption) + .foregroundStyle(Color(hex: "#F5B335")) + Text(String(format: "%.1f", storeRating ?? 0)) + .font(AppTypography.caption) + .foregroundStyle(AppColors.textPrimary) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(AppColors.brandSoft) + .clipShape(Capsule()) + } + + var storeSubtitle: String { + let category = (storeCategory ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if category.isEmpty { return "Restaurant" } + return category + } + + var deliveryValueLabel: String { + if let minOrder = info?.minOrder { + return formatCurrency(minOrder) + } + return "R$ --" + } + + /// Already formatted upstream by `HomeView.formatDistance`, which returns + /// an empty string when there's no distance to show. + var distanceValueLabel: String { + (storeDistance ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + } + + var hasStoreDistance: Bool { + distanceValueLabel.isEmpty == false + } + + var isStoreOpen: Bool { + info?.isOpen ?? true + } + + var isFavoriteStore: Bool { + appState.favorites.storeIds.contains(storeId) + } + + @MainActor + func toggleFavoriteStore() async { + guard isFavoriteRequestInFlight == false else { return } + guard appState.session.isAuthenticated else { + SnackbarCenter.shared.show(title: "Faça login para favoritar lojas.", style: .warning, icon: "person.crop.circle.badge.exclamationmark", duration: 2.5) + return + } + + let isFavorite = isFavoriteStore + isFavoriteRequestInFlight = true + defer { isFavoriteRequestInFlight = false } + + do { + let response = try await ApiService().setStoreFavorite(storeId: storeId, isFavorite: isFavorite == false) + guard response.error == false, let result = response.result else { + let message = response.message ?? "Não foi possível atualizar seus favoritos." + SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 3.5) + return + } + + appState.favorites.storeIds = Set(result.favorites) + let successTitle = isFavorite + ? "\(storeName) removida dos favoritos." + : "\(storeName) adicionada aos favoritos." + let successIcon = isFavorite ? "heart.slash.fill" : "heart.fill" + SnackbarCenter.shared.show(title: successTitle, style: .success, icon: successIcon, duration: 2.2) + } catch { + let message: String + if let networkError = error as? NetworkError { + message = networkError.errorDescription ?? "Não foi possível atualizar seus favoritos." + } else if let serviceError = error as? ApiServiceError { + message = serviceError.errorDescription ?? "Não foi possível atualizar seus favoritos." + } else { + message = "Não foi possível atualizar seus favoritos." + } + SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 3.5) + } + } + + var summaryCardHeight: CGFloat { + summaryCardBaseHeight + (isStoreOpen ? 0 : closedBannerHeight) + } + + var closedStoreBannerText: String { + let label = info?.statusLabel?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if label.isEmpty { + return "Loja fechada • Consulte o horário de abertura" + } + let normalized = label.lowercased() + if normalized.hasPrefix("fechado") { + let cleaned = label.replacingOccurrences(of: "Fechado", with: "") + .replacingOccurrences(of: "fechado", with: "") + .trimmingCharacters(in: CharacterSet(charactersIn: " -:•")) + if cleaned.isEmpty == false { + return "Loja fechada • \(cleaned)" + } + } + return "Loja fechada • \(label)" + } + + @MainActor + func loadStoreData(forceRefresh: Bool = false) async { + // A refresh (pull-to-refresh) that fails must never wipe content the + // user is already looking at — only a first load with nothing yet + // loaded is allowed to show a blocking error state. + let hadExistingContent = categories.isEmpty == false + isLoading = true + if hadExistingContent == false { + errorMessage = nil + } + + let infoCacheKey = "store-info:\(storeId)" + let catalogCacheKey = "store-catalog:\(storeId)" + + if forceRefresh == false, + let cachedInfo: StoreInfoResult = AppContentCache.shared.value(for: infoCacheKey, as: StoreInfoResult.self), + let cachedCatalog: [StoreCatalogCategory] = AppContentCache.shared.value(for: catalogCacheKey, as: [StoreCatalogCategory].self) { + let normalizedCatalog = StoreCatalogNormalizer.sanitize(categories: cachedCatalog, storeId: storeId) + info = cachedInfo + categories = normalizedCatalog + selectedCategoryId = StoreCatalogNormalizer.preferredCategoryId( + from: normalizedCatalog, + preferredId: selectedCategoryId + ) + isLoading = false + return + } + + if forceRefresh { + AppContentCache.shared.invalidate(prefix: "store-info:\(storeId)") + AppContentCache.shared.invalidate(prefix: "store-catalog:\(storeId)") + } + + do { + if appState.session.isAuthenticated { + let apiService = ApiService() + let infoResponse = try await apiService.storeInfo(storeId: storeId) + let catalogResponse = try await apiService.storeCatalog(storeId: storeId) + + if infoResponse.error { + isLoading = false + reportStoreLoadFailure(infoResponse.message ?? "Não foi possível carregar a loja.", hadExistingContent: hadExistingContent) + return + } + if catalogResponse.error { + isLoading = false + reportStoreLoadFailure(catalogResponse.message ?? "Não foi possível carregar o catálogo.", hadExistingContent: hadExistingContent) + return + } + + let normalizedCatalog = StoreCatalogNormalizer.sanitize( + categories: catalogResponse.result ?? [], + storeId: storeId + ) + + info = infoResponse.result + categories = normalizedCatalog + selectedCategoryId = StoreCatalogNormalizer.preferredCategoryId( + from: normalizedCatalog, + preferredId: selectedCategoryId + ) + if let info = infoResponse.result { + AppContentCache.shared.set(info, for: infoCacheKey, ttl: AppCacheTTL.twoHours) + } + AppContentCache.shared.set(normalizedCatalog, for: catalogCacheKey, ttl: AppCacheTTL.twoHours) + } else { + // Anonymous browsing — public/no-login store detail + catalog + // via pedifoods.com.br, mapped onto the same StoreInfoResult / + // StoreCatalogCategory models the authenticated path uses + // above, so the rest of this view doesn't need to know which + // source the data came from. + async let publicDetail = PublicLocationService.shared.fetchStoreDetail(identifier: storeId) + async let publicProducts = PublicLocationService.shared.fetchStoreProducts(storeId: storeId) + let (detail, products) = try await (publicDetail, publicProducts) + + let normalizedCatalog = StoreCatalogNormalizer.sanitize(categories: products, storeId: storeId) + let publicInfo = StoreInfoResult(publicDetail: detail) + + info = publicInfo + categories = normalizedCatalog + selectedCategoryId = StoreCatalogNormalizer.preferredCategoryId( + from: normalizedCatalog, + preferredId: selectedCategoryId + ) + AppContentCache.shared.set(publicInfo, for: infoCacheKey, ttl: AppCacheTTL.twoHours) + AppContentCache.shared.set(normalizedCatalog, for: catalogCacheKey, ttl: AppCacheTTL.twoHours) + } + errorMessage = nil + isLoading = false + } catch { + isLoading = false + // A cancelled request (e.g. the .refreshable task torn down by a + // re-render, or superseded by a newer pull) is not a failure — + // it never got a response either way, so there is nothing to + // report and no content to touch. + if isCancelledRequest(error) { + return + } + let message: String + if let network = error as? NetworkError { + message = network.errorDescription ?? "Erro ao carregar loja." + } else if let service = error as? ApiServiceError { + message = service.errorDescription ?? "Erro ao carregar loja." + } else { + message = "Erro ao carregar loja." + } + reportStoreLoadFailure(message, hadExistingContent: hadExistingContent) + } + } + + private func isCancelledRequest(_ error: Error) -> Bool { + if error is CancellationError { + return true + } + + if let networkError = error as? NetworkError { + switch networkError { + case .cancelled: + return true + case .transportError(let message): + let normalized = message.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return normalized.contains("cancel") + default: + break + } + } + + return error.localizedDescription.lowercased().contains("cancel") + } + + @MainActor + private func reportStoreLoadFailure(_ message: String, hadExistingContent: Bool) { + if hadExistingContent { + SnackbarCenter.shared.show(title: message, style: .warning, icon: "exclamationmark.triangle.fill", duration: 3.0) + } else { + errorMessage = message + } + } + + func resolvedURL(_ raw: String?) -> String? { + ImageSourceResolver.resolve(raw) + } + + func formatCurrency(_ value: Double?) -> String { + guard let value else { return "R$ --" } + return String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",") + } + + func listPriceLabel(for product: StoreCatalogProduct, in category: StoreCatalogCategory) -> String { + guard product.type?.lowercased() == "pizza", product.pizzaPrices.isEmpty == false else { + return formatCurrency(product.price) + } + + if let firstSizeId = category.pizzaConfig?.sizes.first?.id, + let firstSizePrice = product.pizzaPrices[firstSizeId] { + return "A partir de \(formatCurrency(firstSizePrice))" + } + + if let fallback = product.pizzaPrices.sorted(by: { $0.key < $1.key }).first?.value { + return "A partir de \(formatCurrency(fallback))" + } + + return formatCurrency(product.price) + } + + var topSectionHeight: CGFloat { + cardTopInset + summaryCardHeight + } + + func sectionAnchorId(for categoryId: String) -> String { + "category-section-\(categoryId)" + } + + func syncCategoryWithScroll() { + guard isLoading == false else { return } + guard isProgrammaticCategoryScroll == false else { return } + guard categoryHeaderOffsets.isEmpty == false else { return } + + // Section whose header is nearest to the top content area wins. + let topThreshold: CGFloat = 180 + let sorted = categoryHeaderOffsets.sorted { $0.value < $1.value } + + if let current = sorted.last(where: { $0.value <= topThreshold })?.key { + selectedCategoryId = current + return + } + + if let firstVisible = sorted.first?.key { + selectedCategoryId = firstVisible + } + } + + func quantityInCart(for productId: String) -> Int { + appState.cart.items + .filter { $0.storeId == storeId && $0.productId == productId } + .reduce(0) { $0 + $1.quantity } + } + + func quantityInCart(for item: StoreCatalogListItem) -> Int { + if item.isPizzaSummary { + let ids = Set(item.pizzaProductIds) + return appState.cart.items + .filter { $0.storeId == storeId && ids.contains($0.productId) } + .reduce(0) { $0 + $1.quantity } + } + return quantityInCart(for: item.product.id) + } + + func listItems(for category: StoreCatalogCategory) -> [StoreCatalogListItem] { + if category.isPizzaCategory { + guard let first = category.products.first else { return [] } + let representativeImage = category.products + .compactMap(\.image) + .first { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false } + + return [ + StoreCatalogListItem( + id: "\(category.id)::pizza-summary", + product: first, + title: "Escolha seu sabor", + description: "Escolha o tamanho da sua fome", + imageURL: representativeImage ?? first.image, + isPizzaSummary: true, + pizzaCategoryId: category.id, + pizzaProductIds: category.products.map(\.id) + ) + ] + } + + return category.products.map { product in + StoreCatalogListItem( + id: product.id, + product: product, + title: product.name, + description: product.description, + imageURL: product.image + ) + } + } + + func requestAddToCart(_ item: CartItemState) { + guard isStoreOpen else { + SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0) + return + } + pendingCartAction = .add + if shouldAskForStoreSwitch(for: storeId) { + pendingCartItem = item + pendingProductSheet = nil + showSwitchStoreAlert = true + return + } + applyAddToCart(item) + } + + func requestSetCartItem(_ item: CartItemState) { + guard isStoreOpen || item.quantity <= 0 else { + SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0) + return + } + pendingCartAction = .set + if shouldAskForStoreSwitch(for: storeId) && item.quantity > 0 { + pendingCartItem = item + pendingProductSheet = nil + showSwitchStoreAlert = true + return + } + applySetCartItem(item) + } + + func requestOpenProductSheet(_ product: StoreCatalogProduct) { + guard isStoreOpen else { + SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0) + return + } + pendingCartAction = .openProductSheet + if shouldAskForStoreSwitch(for: storeId) { + pendingCartItem = nil + pendingProductSheet = product + showSwitchStoreAlert = true + return + } + selectedProduct = product + } + + func requestOpenPizzaSheet(categoryId: String) { + guard isStoreOpen else { + SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0) + return + } + pendingCartAction = .openPizzaSheet + if shouldAskForStoreSwitch(for: storeId) { + pendingCartItem = nil + pendingProductSheet = nil + pendingPizzaCategoryId = categoryId + showSwitchStoreAlert = true + return + } + selectedPizzaCategoryId = categoryId + } + + func applyAddToCart(_ item: CartItemState) { + if appState.cart.storeId == nil { + appState.cart.storeId = storeId + appState.cart.storeName = storeName + } + appState.cart.add(item: item) + SnackbarCenter.shared.show(title: "Produto adicionado ao carrinho.", style: .success, icon: "checkmark.seal.fill", duration: 2.0) + } + + func applySetCartItem(_ item: CartItemState) { + if item.quantity > 0, appState.cart.storeId == nil { + appState.cart.storeId = storeId + appState.cart.storeName = storeName + } + appState.cart.set(item: item) + if item.quantity > 0 { + SnackbarCenter.shared.show(title: "Carrinho atualizado.", style: .success, icon: "checkmark.seal.fill", duration: 1.8) + } else { + SnackbarCenter.shared.show(title: "Produto removido do carrinho.", style: .success, icon: "checkmark.seal.fill", duration: 1.8) + } + } + + func currentQuantity(forCartItemId itemId: String) -> Int { + appState.cart.items.first(where: { $0.id == itemId })?.quantity ?? 0 + } + + func shouldAskForStoreSwitch(for targetStoreId: String) -> Bool { + guard appState.cart.items.isEmpty == false else { return false } + guard let currentStoreId = currentCartStoreId(), + currentStoreId.isEmpty == false else { return false } + return currentStoreId != targetStoreId + } + + func currentCartStoreId() -> String? { + if let storeId = appState.cart.storeId, storeId.isEmpty == false { + return storeId + } + return appState.cart.items.first?.storeId + } +} + +enum CartAction { + case add + case set + case openProductSheet + case openPizzaSheet +} diff --git a/PediFoods/Views/Main/StoreDetailView.swift b/PediFoods/Views/Main/StoreDetailView.swift new file mode 100644 index 0000000..b8415ae --- /dev/null +++ b/PediFoods/Views/Main/StoreDetailView.swift @@ -0,0 +1,199 @@ +import SwiftUI +#if canImport(LCEssentials) +import LCEssentials +#endif +import UIKit + +struct StoreDetailView: View { + let storeId: String + let storeName: String + let storeCoverURL: String? + let storeLogoURL: String? + let storeCategory: String? + let storeRating: Double? + let storeDistance: String? + let storeDeliveryFee: Double? + @Binding var appState: AppState + + @Environment(\.dismiss) var dismiss + + @State var isLoading = true + @State var errorMessage: String? = nil + @State var info: StoreInfoResult? = nil + @State var categories: [StoreCatalogCategory] = [] + @State var selectedCategoryId: String? = nil + @State var selectedProduct: StoreCatalogProduct? = nil + @State var selectedPizzaCategoryId: String? = nil + @State var showSwitchStoreAlert = false + @State var pendingCartItem: CartItemState? = nil + @State var pendingProductSheet: StoreCatalogProduct? = nil + @State var pendingPizzaCategoryId: String? = nil + @State var pendingCartAction: CartAction = .add + @State var didLoad = false + @State var categoryHeaderOffsets: [String: CGFloat] = [:] + @State var isProgrammaticCategoryScroll = false + @State var isFavoriteRequestInFlight = false + @State var scrollOffset: CGFloat = 0 + + var isCategoryTabsPinned: Bool { scrollOffset >= topSectionHeight } + var stretchAmount: CGFloat { max(0, -scrollOffset) } + + let cardTopInset: CGFloat = 180 + let summaryCardBaseHeight: CGFloat = 212 + let closedBannerHeight: CGFloat = 44 + let coverVisibleUntilY: CGFloat = 253 + let storeLogoSize: CGFloat = 84 + + var safeAreaTop: CGFloat { + UIDevice.appSafeAreaTop + } + + var body: some View { + ScrollViewReader { proxy in + ZStack(alignment: .top) { + ScrollView(showsIndicators: false) { + ScrollOffsetReader(offsetY: $scrollOffset) + LazyVStack(spacing: 0) { + topSection + // Guaranteed-visible refresh feedback, right below + // the hero — not relying on the native spinner's + // position (unreliable here, see .refreshable note + // below). + if isLoading && categories.isEmpty == false { + HStack(spacing: 8) { + ProgressView() + Text("Atualizando...") + .font(AppTypography.caption) + .foregroundStyle(AppColors.textMuted) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + } + categoryTabs(proxy: proxy, isPinned: false) + .opacity(isCategoryTabsPinned ? 0 : 1) + sectionedProducts + } + } + .coordinateSpace(name: StoreDetailScrollCoordinateSpace.name) + .refreshable { + // Run the actual load in its own unstructured Task and + // await that, instead of awaiting loadStoreData directly + // in this closure. SwiftUI can cancel .refreshable's own + // wrapping Task (e.g. the gesture not fully "committing") + // independent of whether the network call is still + // legitimately in flight. Awaiting Task.value here + // blocks until the detached load genuinely finishes + // (success, error, or our own 20s ApiClient timeout), + // so a premature refreshable-cancellation can no longer + // silently swallow a real in-flight request. + await Task { await loadStoreData(forceRefresh: true) }.value + } + // NOT .ignoresSafeArea here: combined with .refreshable on + // the same view, it breaks the native pull-to-refresh + // spinner's positioning (renders invisible/off-place) even + // though the gesture still fires the closure. The hero + // image and gradient above already bleed under the status + // bar independently via their own .ignoresSafeArea calls. + .background(AppColors.backgroundLight) + + categoryTabs(proxy: proxy, isPinned: true) + .opacity(isCategoryTabsPinned ? 1 : 0) + .allowsHitTesting(isCategoryTabsPinned) + .zIndex(10) + } + .ignoresSafeArea(edges: .top) + .saturation(isStoreOpen ? 1 : 0) + } + .navigationBarBackButtonHidden(true) + .appHiddenNavigationBar() + .task { + guard didLoad == false else { return } + didLoad = true + await loadStoreData(forceRefresh: false) + } + .sheet(item: $selectedProduct) { product in + NavigationStack { + ProductDetailSheet( + product: product, + imageURL: resolvedURL(product.image), + storeId: storeId, + currentQuantityForItemId: { itemId in + currentQuantity(forCartItemId: itemId) + }, + onAdd: { item in + guard isStoreOpen else { + SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0) + return + } + requestSetCartItem(item) + } + ) + } + } + .sheet( + isPresented: Binding( + get: { selectedPizzaCategoryId != nil }, + set: { isPresented in + if isPresented == false { + selectedPizzaCategoryId = nil + } + } + ) + ) { + if let category = categories.first(where: { $0.id == selectedPizzaCategoryId }) { + NavigationStack { + PizzaProductDetailSheet( + category: category, + storeId: storeId, + resolveImageURL: { raw in resolvedURL(raw) }, + currentQuantityForItemId: { itemId in + currentQuantity(forCartItemId: itemId) + }, + onAdd: { item in + guard isStoreOpen else { + SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0) + return + } + requestSetCartItem(item) + } + ) + } + } else { + ProgressView() + } + } + .alert("Trocar de loja?", isPresented: $showSwitchStoreAlert) { + Button("Cancelar", role: .cancel) { + pendingCartItem = nil + pendingProductSheet = nil + pendingPizzaCategoryId = nil + } + Button("Limpar carrinho e adicionar", role: .destructive) { + appState.cart.clear() + switch pendingCartAction { + case .add: + guard let pendingCartItem else { return } + applyAddToCart(pendingCartItem) + case .set: + guard let pendingCartItem else { return } + applySetCartItem(pendingCartItem) + case .openProductSheet: + guard let pendingProductSheet else { return } + selectedProduct = pendingProductSheet + case .openPizzaSheet: + guard let pendingPizzaCategoryId else { return } + selectedPizzaCategoryId = pendingPizzaCategoryId + } + self.pendingCartItem = nil + self.pendingProductSheet = nil + self.pendingPizzaCategoryId = nil + } + } message: { + Text("Seu carrinho tem itens de outra loja. Deseja limpar o carrinho atual para adicionar este produto?") + } + .onPreferenceChange(CategoryHeaderOffsetPreferenceKey.self) { offsets in + categoryHeaderOffsets = offsets + syncCategoryWithScroll() + } + } +} diff --git a/PediFoods/Views/Main/UserProfileView.swift b/PediFoods/Views/Main/UserProfileView.swift new file mode 100644 index 0000000..ff88ef0 --- /dev/null +++ b/PediFoods/Views/Main/UserProfileView.swift @@ -0,0 +1,498 @@ +import SwiftUI +#if os(iOS) +import PhotosUI +import UIKit +#endif +#if canImport(LCEssentials) +import LCEssentials +#endif + +struct UserProfileView: View { + @Binding var appState: AppState + @Environment(\.dismiss) var dismiss + @Environment(\.openURL) private var openURL + + @State var name: String = "" + @State var email: String = "" + @State var phone: String = "" + @State var cpf: String = "" + @State var profilePicture: String = "" + @State var isSaving = false + @State private var notificationsEnabled = false + @State private var isTogglingNotifications = false + @State private var showNotificationsDeniedAlert = false + @State private var faceIdEnabled = false + @State private var isTogglingFaceId = false + +#if os(iOS) + @State private var selectedPhotoItem: PhotosPickerItem? +#endif + + var body: some View { + LCENavigationView { + content + } + .setLeftButton(image: AnyView(AppBackButtonIcon())) { dismiss() } + .setTitle(text: Text("Meu Perfil").font(AppTypography.heading2).foregroundStyle(AppColors.textPrimary)) + .setNavigationBarBackgroundColor(AppColors.backgroundLight) + .buttonStyle(.plain) + } + + private var content: some View { + ScrollView(showsIndicators: false) { + VStack(spacing: 22) { + avatarSection + formSection + preferencesSection + saveButton + } + .padding(.horizontal, 20) + .padding(.top, 18) + .padding(.bottom, UIDevice.bottomNotch + 24) + } + .background(AppColors.backgroundLight) + .appHiddenNavigationBar() + .navigationBarBackButtonHidden(true) + .alert("Notificações desativadas", isPresented: $showNotificationsDeniedAlert) { + Button("Agora não", role: .cancel) {} + Button("Abrir Ajustes") { openSystemSettings() } + } message: { + Text("Para receber notificações sobre seus pedidos, ative a permissão nos Ajustes do iPhone.") + } + .onAppear { + hydrateFromAppState() + } +#if os(iOS) + .onChange(of: selectedPhotoItem) { _, newItem in + Task { await applySelectedPhoto(newItem) } + } +#endif + } + + private var avatarSection: some View { + VStack(spacing: 12) { + Circle() + .fill(AppColors.brandSoft) + .frame(width: 110, height: 110) + .overlay { + if let imageSource = resolvedProfilePicture { + AsyncStoreImage(imageURL: imageSource) + .frame(width: 104, height: 104) + .clipShape(Circle()) + } else { + Text(initials) + .font(.system(size: 32, weight: .bold)) + .foregroundStyle(AppColors.primary) + } + } + + HStack(spacing: 10) { +#if os(iOS) + PhotosPicker(selection: $selectedPhotoItem, matching: .images, photoLibrary: .shared()) { + Text("Trocar Foto") + .font(AppTypography.caption) + .foregroundStyle(AppColors.primary) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(AppColors.surface) + .clipShape(Capsule()) + } +#endif + Button("Remover") { + profilePicture = "" + } + .font(AppTypography.caption) + .foregroundStyle(AppColors.textMuted) + .buttonStyle(.plain) + .disabled(resolvedProfilePicture == nil) + } + } + .frame(maxWidth: .infinity) + } + + private var formSection: some View { + VStack(alignment: .leading, spacing: 14) { + textFieldSection(title: "Nome", placeholder: "Seu nome completo", text: $name) + + textFieldSection(title: "E-mail", placeholder: "seu@email.com", text: $email) + .appNoAutoCap() + + textFieldSection(title: "Telefone", placeholder: "(00) 00000-0000", text: $phone) + .onChange(of: phone) { _, newValue in + let masked = formatPhoneBR(displayPhoneDigits(newValue)) + if masked != newValue { + phone = masked + } + } + + textFieldSection(title: "CPF", placeholder: "000.000.000-00", text: $cpf) + .keyboardType(.numberPad) + .onChange(of: cpf) { _, newValue in + let digits = newValue.filter(\.isNumber) + let masked = formatCPF(digits) + if masked != newValue { cpf = masked } + } + .appNoAutoCap() + } + .padding(16) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + + private var preferencesSection: some View { + VStack(alignment: .leading, spacing: 14) { + toggleRow( + icon: "bell.fill", + title: "Notificações", + isOn: $notificationsEnabled, + isDisabled: isTogglingNotifications + ) + .onChange(of: notificationsEnabled) { _, newValue in + Task { await handleNotificationsToggle(newValue) } + } + + Divider() + + toggleRow( + icon: "faceid", + title: "Login com biometria", + isOn: $faceIdEnabled, + isDisabled: isTogglingFaceId + ) + .onChange(of: faceIdEnabled) { _, newValue in + Task { await handleFaceIdToggle(newValue) } + } + } + .padding(16) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + + private func toggleRow(icon: String, title: String, isOn: Binding, isDisabled: Bool) -> some View { + HStack(spacing: 12) { + Image(systemName: icon) + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(AppColors.primary) + .frame(width: 28) + + Text(title) + .font(AppTypography.body) + .foregroundStyle(AppColors.textPrimary) + + Spacer(minLength: 10) + + Toggle("", isOn: isOn) + .labelsHidden() + .disabled(isDisabled) + } + } + + private var saveButton: some View { + Button(isSaving ? "Salvando..." : "Salvar Alterações") { + Task { await saveProfile() } + } + .font(AppTypography.button) + .foregroundStyle(AppColors.textInverse) + .frame(maxWidth: .infinity, minHeight: 56) + .background(AppColors.primary) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + .buttonStyle(.plain) + .disabled(isSaving || canSave == false) + .opacity((isSaving || canSave == false) ? 0.6 : 1.0) + } + + private var resolvedProfilePicture: String? { + let trimmed = profilePicture.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.isEmpty == false else { return nil } + return ImageSourceResolver.resolve(trimmed) + } + + private var initials: String { + let parts = name + .trimmingCharacters(in: .whitespacesAndNewlines) + .split(separator: " ") + .prefix(2) + let letters = parts.compactMap { $0.first }.map(String.init).joined() + return letters.isEmpty ? "PF" : letters.uppercased() + } + + private var canSave: Bool { + let cleanName = name.trimmingCharacters(in: .whitespacesAndNewlines) + let cleanEmail = email.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedPhone = normalizePhoneNumberForAPI(phone) + return cleanName.isEmpty == false + && cleanEmail.isEmpty == false + && cleanEmail.contains("@") + && normalizedPhone.isEmpty == false + } + + private func hydrateFromAppState() { + name = appState.profile.name + email = appState.profile.email + phone = formatPhoneForDisplay(appState.profile.phone) + profilePicture = appState.profile.profilePicture + cpf = formatCPF(appState.profile.cpf.filter(\.isNumber)) + notificationsEnabled = appState.profile.notificationsEnabled + faceIdEnabled = appState.profile.faceIdEnabled + } + + /// Touchpoint 1 of docs/api/push-notifications-integration-guide.md §2b — + /// request OS permission (if needed) before flipping the server-side flag; + /// revert the toggle and explain why if the OS denies it. + @MainActor + private func handleNotificationsToggle(_ enabled: Bool) async { + guard isTogglingNotifications == false, enabled != appState.profile.notificationsEnabled else { return } + isTogglingNotifications = true + defer { isTogglingNotifications = false } + + if enabled { + if let profile = await PushNotificationCoordinator.shared.enableNotifications() { + let serverValue = profile.notificationsEnabled ?? false + appState.profile.notificationsEnabled = serverValue + notificationsEnabled = serverValue + } else { + notificationsEnabled = false + showNotificationsDeniedAlert = true + } + return + } + + do { + let response = try await ApiService().updateNotificationsEnabled(false) + if response.error { + notificationsEnabled = true + SnackbarCenter.shared.show( + title: response.message ?? "Não foi possível atualizar suas notificações.", + style: .error, + icon: "xmark.octagon.fill", + duration: 3.0 + ) + } else { + let serverValue = response.result?.notificationsEnabled ?? false + appState.profile.notificationsEnabled = serverValue + notificationsEnabled = serverValue + } + } catch { + notificationsEnabled = true + SnackbarCenter.shared.show( + title: "Não foi possível atualizar suas notificações.", + style: .error, + icon: "xmark.octagon.fill", + duration: 3.0 + ) + } + } + + private func openSystemSettings() { + if let url = URL(string: UIApplication.openSettingsURLString) { + openURL(url) + } + } + + /// Preference only for now — the actual Face ID/Touch ID unlock flow + /// (LocalAuthentication) is a separate, later plan. + @MainActor + private func handleFaceIdToggle(_ enabled: Bool) async { + guard isTogglingFaceId == false, enabled != appState.profile.faceIdEnabled else { return } + isTogglingFaceId = true + defer { isTogglingFaceId = false } + + do { + let response = try await ApiService().updateFaceIdEnabled(enabled) + if response.error { + faceIdEnabled = appState.profile.faceIdEnabled + SnackbarCenter.shared.show( + title: response.message ?? "Não foi possível atualizar essa preferência.", + style: .error, + icon: "xmark.octagon.fill", + duration: 3.0 + ) + } else { + let serverValue = response.result?.faceIdEnabled ?? enabled + appState.profile.faceIdEnabled = serverValue + faceIdEnabled = serverValue + } + } catch { + faceIdEnabled = appState.profile.faceIdEnabled + SnackbarCenter.shared.show( + title: "Não foi possível atualizar essa preferência.", + style: .error, + icon: "xmark.octagon.fill", + duration: 3.0 + ) + } + } + + private func formatCPF(_ digits: String) -> String { + let d = String(digits.prefix(11)) + if d.count <= 3 { return d } + if d.count <= 6 { return "\(d.prefix(3)).\(d.dropFirst(3))" } + if d.count <= 9 { return "\(d.prefix(3)).\(d.dropFirst(3).prefix(3)).\(d.dropFirst(6))" } + return "\(d.prefix(3)).\(d.dropFirst(3).prefix(3)).\(d.dropFirst(6).prefix(3))-\(d.dropFirst(9))" + } + + @MainActor + private func saveProfile() async { + guard canSave else { return } + + let cleanName = name.trimmingCharacters(in: .whitespacesAndNewlines) + let cleanEmail = email.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedPhone = normalizePhoneNumberForAPI(phone) + let cleanPhoto = profilePicture.trimmingCharacters(in: .whitespacesAndNewlines) + let newPhoto = cleanPhoto.hasPrefix("data:") ? cleanPhoto : nil + + isSaving = true + defer { isSaving = false } + + do { + let response = try await ApiService().updateCustomerProfile( + name: cleanName, + email: cleanEmail, + phoneNumber: normalizedPhone, + profilePicture: newPhoto + ) + + if response.error { + SnackbarCenter.shared.show( + title: response.message ?? "Não foi possível atualizar seu perfil.", + style: .error, + icon: "xmark.octagon.fill", + duration: 3.0 + ) + return + } + + appState.profile.name = cleanName + appState.profile.email = cleanEmail + appState.profile.phone = normalizedPhone + if let pictureUrl = response.profilePictureUrl, pictureUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false { + appState.profile.profilePicture = ImageSourceResolver.resolve(pictureUrl) ?? pictureUrl + } else if cleanPhoto.isEmpty == false { + appState.profile.profilePicture = cleanPhoto + } + + let cleanCpf = cpf.filter(\.isNumber) + if cleanCpf.count == 11 { + guard isValidCPF(cleanCpf) else { + SnackbarCenter.shared.show( + title: "CPF inválido. Verifique e tente novamente.", + style: .error, + icon: "xmark.octagon.fill", + duration: 3.0 + ) + return + } + let cpfResponse = try await ApiService().updateProfileCpf(cpf: cleanCpf) + if cpfResponse.error { + SnackbarCenter.shared.show( + title: cpfResponse.message ?? "Não foi possível atualizar o CPF.", + style: .error, + icon: "xmark.octagon.fill", + duration: 3.0 + ) + return + } + appState.profile.cpf = cleanCpf + } + SessionStateStore.setActiveUserKey( + SessionStateStore.makeUserKey(profileId: appState.profile.id, email: appState.profile.email) + ) + + SnackbarCenter.shared.show( + title: "Perfil atualizado com sucesso.", + style: .success, + icon: "checkmark.circle.fill", + duration: 2.0 + ) + dismiss() + } catch { + SnackbarCenter.shared.show( + title: "Não foi possível atualizar seu perfil.", + style: .error, + icon: "xmark.octagon.fill", + duration: 3.0 + ) + } + } + + private func formatPhoneForDisplay(_ raw: String) -> String { + let digits = displayPhoneDigits(raw) + if digits.isEmpty { return "" } + return formatPhoneBR(digits) + } + + private func displayPhoneDigits(_ raw: String) -> String { + var digits = raw.filter(\.isNumber) + if digits.hasPrefix("55"), digits.count > 11 { + digits = String(digits.dropFirst(2)) + } + return String(digits.prefix(11)) + } + + private func textFieldSection(title: String, placeholder: String, text: Binding) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(AppTypography.caption) + .foregroundStyle(AppColors.textMuted) + + TextField(placeholder, text: text) + .font(AppTypography.body) + .foregroundStyle(AppColors.textPrimary) + .padding(.horizontal, 12) + .frame(height: 50) + .background(AppColors.backgroundLight) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(AppColors.secondary.opacity(0.2), lineWidth: 1) + ) + } + } + + private func isValidCPF(_ digits: String) -> Bool { + guard digits.count == 11, digits.unicodeScalars.allSatisfy({ CharacterSet.decimalDigits.contains($0) }) else { return false } + guard Set(digits).count > 1 else { return false } + func checkDigit(_ d: String, _ length: Int) -> Bool { + let sum = d.prefix(length).enumerated().reduce(0) { acc, pair in + acc + (Int(String(pair.element)) ?? 0) * (length + 1 - pair.offset) + } + let rem = (sum * 10) % 11 + let expected = rem == 10 ? 0 : rem + return Int(String(d[d.index(d.startIndex, offsetBy: length)])) == expected + } + return checkDigit(digits, 9) && checkDigit(digits, 10) + } + +#if os(iOS) + @MainActor + private func applySelectedPhoto(_ item: PhotosPickerItem?) async { + guard let item else { return } + do { + guard let data = try await item.loadTransferable(type: Data.self) else { return } + guard let image = UIImage(data: data) else { return } + let resized = resizedIfNeeded(image, maxSide: 600) + guard let jpegData = resized.jpegData(compressionQuality: 0.8) else { return } + profilePicture = "data:image/jpeg;base64,\(jpegData.base64EncodedString())" + } catch { + SnackbarCenter.shared.show( + title: "Não foi possível carregar a foto selecionada.", + style: .warning, + icon: "photo", + duration: 2.5 + ) + } + } + + private func resizedIfNeeded(_ image: UIImage, maxSide: CGFloat) -> UIImage { + let w = image.size.width + let h = image.size.height + guard w > maxSide || h > maxSide else { return image } + let scale = maxSide / max(w, h) + let newSize = CGSize(width: w * scale, height: h * scale) + let renderer = UIGraphicsImageRenderer(size: newSize) + return renderer.image { _ in + image.draw(in: CGRect(origin: .zero, size: newSize)) + } + } +#endif +} 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/PediFoodsTests/FavoritesDecodingTests.swift b/PediFoodsTests/FavoritesDecodingTests.swift new file mode 100644 index 0000000..d91e743 --- /dev/null +++ b/PediFoodsTests/FavoritesDecodingTests.swift @@ -0,0 +1,49 @@ +import Foundation +import Testing +@testable import PediFoods + +@Test("Customer profile decodes persisted favorite store ids") +func customerProfileDecodesFavoriteStoreIds() throws { + let json = """ + { + "error": false, + "result": { + "id": "cust_1", + "name": "Daniel", + "email": "daniel@example.com", + "phoneNumber": "+5511999999999", + "favorites": ["store_a", "store_b"], + "address_book": [] + } + } + """ + + let envelope = try JSONDecoder().decode(ApiEnvelope.self, from: Data(json.utf8)) + #expect(envelope.result?.favorites == ["store_a", "store_b"]) +} + +@Test("Favorite mutation decodes updated favorites array") +func favoriteMutationDecodesUpdatedFavoritesArray() throws { + let json = """ + { + "error": false, + "result": { + "favorites": ["store_a"], + "store": { + "id": "store_a", + "name": "CPS Drinks", + "category": "Doces & Bolos", + "rating": 4.8, + "totalReviews": 12, + "isOpen": true, + "statusLabel": "Aberto" + } + } + } + """ + + let envelope = try JSONDecoder().decode(ApiEnvelope.self, from: Data(json.utf8)) + #expect(envelope.result?.favorites == ["store_a"]) + #expect(envelope.result?.store?.id == "store_a") + #expect(envelope.result?.store?.name == "CPS Drinks") +} 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..3a38e82 --- /dev/null +++ b/PediFoodsTests/HomeViewFilteringTests.swift @@ -0,0 +1,79 @@ +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") +} + +/// Contract (docs/plans/public-store-distance-consumer.md): the API never +/// sends null - `0` means "no distance available". Both that and a missing +/// value render as an empty string so the caller omits the segment +/// entirely, instead of showing "Distância indisponível" or "0 m". +@Test("formatDistance renders nothing when the distance is missing or zero") +@MainActor +func formatDistanceIsEmptyWhenUnavailable() { + let view = makeHomeView() + #expect(view.formatDistance(nil) == "") + #expect(view.formatDistance(0) == "") + #expect(view.formatDistance(-1) == "") +} 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/PediFoodsTests/LegalDocumentTests.swift b/PediFoodsTests/LegalDocumentTests.swift new file mode 100644 index 0000000..6d7eb37 --- /dev/null +++ b/PediFoodsTests/LegalDocumentTests.swift @@ -0,0 +1,12 @@ +import Testing +@testable import PediFoods + +@Test("Terms URL points to PediFoods customer terms endpoint") +func termsURL() { + #expect(LegalDocument.terms.url.absoluteString == "https://atomenta.com.br/api/public/pedi-foods-customer/terms") +} + +@Test("Privacy policy URL points to PediFoods customer privacy-policy endpoint") +func privacyPolicyURL() { + #expect(LegalDocument.privacyPolicy.url.absoluteString == "https://atomenta.com.br/api/public/pedi-foods-customer/privacy-policy") +} diff --git a/PediFoodsTests/PublicStoreDistanceTests.swift b/PediFoodsTests/PublicStoreDistanceTests.swift new file mode 100644 index 0000000..f0f0da8 --- /dev/null +++ b/PediFoodsTests/PublicStoreDistanceTests.swift @@ -0,0 +1,49 @@ +import Foundation +import Testing +@testable import PediFoods + +/// Covers the public-locator distance contract documented in +/// docs/plans/public-store-distance-consumer.md: the server sends +/// `distance` as a number (never null) where `0` means "unavailable", and +/// the app collapses that to `nil` so a single representation of "unknown" +/// reaches the card label and the max-distance filter. +private func decode(_ type: T.Type, _ json: String) throws -> T { + try JSONDecoder().decode(T.self, from: Data(json.utf8)) +} + +@Test("PublicStoreListItem decodes the distance field") +func publicStoreListItemDecodesDistance() throws { + let item = try decode(PublicStoreListItem.self, #"{"id":"1","distance":3.4}"#) + #expect(item.distance == 3.4) +} + +/// The field ships on the backend after the app - a response without it +/// must still decode rather than throwing. +@Test("PublicStoreListItem still decodes a response with no distance field") +func publicStoreListItemDecodesWithoutDistance() throws { + let item = try decode(PublicStoreListItem.self, #"{"id":"1"}"#) + #expect(item.distance == nil) +} + +@Test("PublicStoreDetail decodes the distance field") +func publicStoreDetailDecodesDistance() throws { + let detail = try decode(PublicStoreDetail.self, #"{"id":"1","distance":1.2}"#) + #expect(detail.distance == 1.2) + let without = try decode(PublicStoreDetail.self, #"{"id":"1"}"#) + #expect(without.distance == nil) +} + +@Test("StoreSummary carries a real public distance through") +func storeSummaryKeepsPublicDistance() throws { + let item = try decode(PublicStoreListItem.self, #"{"id":"1","name":"A","distance":2.5}"#) + #expect(StoreSummary(publicItem: item).distance == 2.5) +} + +@Test("StoreSummary maps the unavailable sentinel and a missing distance to nil") +func storeSummaryNormalizesUnavailableDistance() throws { + let zero = try decode(PublicStoreListItem.self, #"{"id":"1","name":"A","distance":0}"#) + #expect(StoreSummary(publicItem: zero).distance == nil) + + let absent = try decode(PublicStoreListItem.self, #"{"id":"1","name":"A"}"#) + #expect(StoreSummary(publicItem: absent).distance == nil) +} 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/PediFoodsTests/SmokeTests.swift b/PediFoodsTests/SmokeTests.swift new file mode 100644 index 0000000..a7e04db --- /dev/null +++ b/PediFoodsTests/SmokeTests.swift @@ -0,0 +1,7 @@ +import Testing +@testable import PediFoods + +@Test("Smoke") +func smoke() { + #expect(Bool(true)) +} 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/PediFoodsTests/StoreCatalogNormalizerTests.swift b/PediFoodsTests/StoreCatalogNormalizerTests.swift new file mode 100644 index 0000000..cc85638 --- /dev/null +++ b/PediFoodsTests/StoreCatalogNormalizerTests.swift @@ -0,0 +1,117 @@ +import Testing +@testable import PediFoods + +@Test("Store catalog normalizer makes IDs non-empty and unique") +func storeCatalogNormalizerMakesIdsUnique() { + let catalog = [ + StoreCatalogCategory( + id: "", + name: "Pizzas", + isPizzaCategory: true, + pizzaConfig: StorePizzaConfig( + sizes: [ + StorePizzaSize(id: "", name: "Grande", slices: 8, maxFlavors: 2), + StorePizzaSize(id: "", name: "Familia", slices: 12, maxFlavors: 3) + ], + doughs: [ + StorePizzaDough(id: "massa", name: "Tradicional", active: true), + StorePizzaDough(id: "massa", name: "Fina", active: true) + ], + crusts: [ + StorePizzaCrust(id: "", name: "Cheddar", active: true, priceModifier: 5), + StorePizzaCrust(id: "", name: "Catupiry", active: true, priceModifier: 6) + ] + ), + products: [ + StoreCatalogProduct( + id: "", + type: "pizza", + name: "Calabresa", + description: nil, + image: nil, + price: 10, + originalPrice: nil, + pizzaPrices: [:], + addonGroups: [ + StoreAddonGroup( + id: "", + name: "Extras", + minSelectors: nil, + maxSelectors: nil, + items: [ + StoreAddonItem(id: "", name: "Bacon", price: 2), + StoreAddonItem(id: "", name: "Bacon em dobro", price: 4) + ] + ), + StoreAddonGroup( + id: "", + name: "Molhos", + minSelectors: nil, + maxSelectors: nil, + items: [ + StoreAddonItem(id: "", name: "Alho", price: 1) + ] + ) + ] + ), + StoreCatalogProduct( + id: "", + type: "pizza", + name: "Mussarela", + description: nil, + image: nil, + price: 12, + originalPrice: nil, + pizzaPrices: [:], + addonGroups: [] + ) + ] + ), + StoreCatalogCategory( + id: "", + name: "Bebidas", + isPizzaCategory: false, + pizzaConfig: nil, + products: [ + StoreCatalogProduct( + id: "", + type: nil, + name: "Refrigerante", + description: nil, + image: nil, + price: 7, + originalPrice: nil, + pizzaPrices: [:], + addonGroups: [] + ) + ] + ) + ] + + let normalized = StoreCatalogNormalizer.sanitize(categories: catalog, storeId: "store-1") + + let categoryIds = normalized.map(\.id) + #expect(Set(categoryIds).count == categoryIds.count) + #expect(categoryIds.allSatisfy { $0.isEmpty == false }) + + let firstCategory = normalized[0] + let productIds = firstCategory.products.map(\.id) + #expect(Set(productIds).count == productIds.count) + #expect(productIds.allSatisfy { $0.isEmpty == false }) + + let addonGroupIds = firstCategory.products[0].addonGroups.map(\.id) + #expect(Set(addonGroupIds).count == addonGroupIds.count) + #expect(addonGroupIds.allSatisfy { $0.isEmpty == false }) + + let addonItemIds = firstCategory.products[0].addonGroups.flatMap(\.items).map(\.id) + #expect(Set(addonItemIds).count == addonItemIds.count) + #expect(addonItemIds.allSatisfy { $0.isEmpty == false }) + + let sizeIds = firstCategory.pizzaConfig?.sizes.map(\.id) ?? [] + let doughIds = firstCategory.pizzaConfig?.doughs.map(\.id) ?? [] + let crustIds = firstCategory.pizzaConfig?.crusts.map(\.id) ?? [] + + #expect(Set(sizeIds).count == sizeIds.count) + #expect(Set(doughIds).count == doughIds.count) + #expect(Set(crustIds).count == crustIds.count) +} diff --git a/PediFoodsTests/URLProtocolStub.swift b/PediFoodsTests/URLProtocolStub.swift new file mode 100644 index 0000000..d219aed --- /dev/null +++ b/PediFoodsTests/URLProtocolStub.swift @@ -0,0 +1,46 @@ +import Foundation + +/// Intercepts every request made through a `URLSession` configured with it, +/// so network-dependent code (`ApiClient`, `ApiService`, +/// `FeatureControlService`, ...) can be unit tested without touching a real +/// server. Register a handler per test, then build a session via +/// `URLProtocolStub.makeSession()`. +final class URLProtocolStub: URLProtocol { + /// `nonisolated(unsafe)`: `URLProtocol` subclasses are instantiated and + /// driven by URLSession's own internal (non-Sendable-checked) machinery, + /// off the calling actor. Tests only ever set this once, synchronously, + /// before starting the request that reads it. + nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + + static func makeSession() -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [URLProtocolStub.self] + return URLSession(configuration: config) + } + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + guard let handler = URLProtocolStub.handler else { + client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + do { + let (response, data) = try handler(request) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} + +extension HTTPURLResponse { + static func stub(url: URL = URL(string: "https://example.com")!, statusCode: Int) -> HTTPURLResponse { + HTTPURLResponse(url: url, statusCode: statusCode, httpVersion: nil, headerFields: nil)! + } +} diff --git a/PediFoodsUITests/AuthenticatedProfileNavigationTests.swift b/PediFoodsUITests/AuthenticatedProfileNavigationTests.swift new file mode 100644 index 0000000..631c48b --- /dev/null +++ b/PediFoodsUITests/AuthenticatedProfileNavigationTests.swift @@ -0,0 +1,51 @@ +import XCTest + +/// Covers screens reachable directly from the authenticated Profile tab +/// with no cart/store-browse setup needed - OrdersView and MyReviewsView +/// both load their own data. The standing QA account has real order/review +/// history from prior manual and automated testing, so these don't assert +/// on empty vs. non-empty - just that the real network load completes +/// without getting stuck. See +/// decisions/2026-08-06-ui-test-account-and-app-attest-bypass.md for the +/// account (`ensureLoggedIn`). +final class AuthenticatedProfileNavigationTests: XCTestCase { + override func setUpWithError() throws { + continueAfterFailure = false + } + + override func tearDownWithError() throws { + XCUIApplication().logoutIfAuthenticated() + } + + func testOrdersScreenReachableAndLoadsRealData() throws { + let app = XCUIApplication() + app.launch() + XCTAssertTrue(app.ensureLoggedIn(), "Login never completed") + XCTAssertTrue(app.buttons["Sair da Conta"].waitForExistence(timeout: 10), "Never reached authenticated Profile") + + app.staticTexts["Meus Pedidos"].tap() + + XCTAssertTrue(app.staticTexts["Meus Pedidos"].waitForExistence(timeout: 10)) + for _ in 0..<30 { + if app.activityIndicators.firstMatch.exists == false { break } + usleep(500_000) + } + XCTAssertFalse(app.activityIndicators.firstMatch.exists, "Orders list never finished loading") + } + + func testReviewsScreenReachableAndLoadsRealData() throws { + let app = XCUIApplication() + app.launch() + XCTAssertTrue(app.ensureLoggedIn(), "Login never completed") + XCTAssertTrue(app.buttons["Sair da Conta"].waitForExistence(timeout: 10), "Never reached authenticated Profile") + + app.staticTexts["Minhas Avaliações"].tap() + + XCTAssertTrue(app.staticTexts["Minhas Avaliações"].waitForExistence(timeout: 10)) + for _ in 0..<30 { + if app.activityIndicators.firstMatch.exists == false { break } + usleep(500_000) + } + XCTAssertFalse(app.activityIndicators.firstMatch.exists, "Reviews list never finished loading") + } +} diff --git a/PediFoodsUITests/AuthenticatedSessionFlowTests.swift b/PediFoodsUITests/AuthenticatedSessionFlowTests.swift new file mode 100644 index 0000000..461d007 --- /dev/null +++ b/PediFoodsUITests/AuthenticatedSessionFlowTests.swift @@ -0,0 +1,23 @@ +import XCTest + +/// Covers a real, authenticated session with the standing QA account (see +/// TestFixtures.swift / decisions/2026-08-06-ui-test-account-and-app-attest-bypass.md). +/// Logs out in tearDown so guest-only tests elsewhere in the same run don't +/// inherit an authenticated Keychain session. +final class AuthenticatedSessionFlowTests: XCTestCase { + override func setUpWithError() throws { + continueAfterFailure = false + } + + override func tearDownWithError() throws { + XCUIApplication().logoutIfAuthenticated() + } + + func testLoginReachesAuthenticatedProfile() throws { + let app = XCUIApplication() + app.launch() + XCTAssertTrue(app.ensureLoggedIn(), "Login never completed") + + XCTAssertTrue(app.buttons["Sair da Conta"].waitForExistence(timeout: 10), "Profile tab never showed the authenticated (logged-in) state") + } +} diff --git a/PediFoodsUITests/CartCheckoutFlowTests.swift b/PediFoodsUITests/CartCheckoutFlowTests.swift new file mode 100644 index 0000000..0a1fae2 --- /dev/null +++ b/PediFoodsUITests/CartCheckoutFlowTests.swift @@ -0,0 +1,189 @@ +import XCTest + +/// Covers Store Detail -> add to cart -> Cart -> Checkout, the biggest +/// remaining zero-coverage surface (CheckoutView.swift alone is ~5000 +/// lines). Requires the standing QA account to have a real saved address +/// so Home shows a real store list - see +/// decisions/2026-08-06-ui-test-account-and-app-attest-bypass.md. +final class CartCheckoutFlowTests: XCTestCase { + override func setUpWithError() throws { + continueAfterFailure = false + } + + override func tearDownWithError() throws { + XCUIApplication().logoutIfAuthenticated() + } + + func testAddProductToCartAndReachCheckout() throws { + let app = XCUIApplication() + app.launch() + try reachCheckoutWithOneItem(app) + } + + /// Exercises CheckoutView's payment-method selection and the address + /// picker's "Alterar" entry point without ever tapping "Confirmar e + /// Pagar" - deliberately not submitting a real order (per explicit + /// user direction: cover the screen, don't create real order data). + func testCheckoutPaymentMethodSelectionAndAddressAlterar() throws { + let app = XCUIApplication() + app.launch() + try reachCheckoutWithOneItem(app) + + XCTAssertTrue(app.staticTexts["MÉTODO DE PAGAMENTO"].waitForExistence(timeout: 5)) + + // PIX is the default selection; only tap an alternative if this + // store's real catalog actually offers one - don't assume Cartão + // de Crédito exists (in-app payment methods depend on real store + // config, already found to vary - see the standing-account state + // notes in decisions/2026-08-06-ui-test-account-and-app-attest-bypass.md). + let creditCardRow = app.buttons.matching(NSPredicate(format: "label CONTAINS[c] %@", "Cartão de Crédito")).firstMatch + if creditCardRow.exists { + creditCardRow.tap() + } + + XCTAssertTrue(app.staticTexts["TIPO DE ENTREGA"].waitForExistence(timeout: 5)) + + app.buttons["Alterar"].tap() + XCTAssertTrue( + app.staticTexts["Meus Endereços"].waitForExistence(timeout: 10), + "Alterar never opened the address picker" + ) + + // Dismiss without changing anything, back to Checkout. .isHittable, + // not just .exists: a Back button that exists but is mid-transition + // throws a fatal, uncatchable "not hittable" failure on tap - same + // issue already fixed in UITestSupport.reachProfileTabRegardlessOfAuthState. + // Poll briefly since the push-in animation can still be settling + // right after "Meus Endereços" first becomes visible. + let backButton = app.buttons.matching(NSPredicate(format: "label == %@", "Back")).firstMatch + var dismissed = false + for _ in 0..<10 { + if backButton.exists, backButton.isHittable { + backButton.tap() + dismissed = true + break + } + usleep(200_000) + } + if dismissed == false { + app.swipeDown() + } + if app.staticTexts["Finalizar Pedido"].waitForExistence(timeout: 10) == false { + let dir = "/private/tmp/claude-501/-Users-loverde-co-Documents-Loverde-JOBs-Producao-Loverde-Co-LC-RAG-Struct-projects-PediFoods-ios/4a9ec4f7-c3ad-4cf5-8dad-073446f6fd81/scratchpad" + try? app.screenshot().pngRepresentation.write(to: URL(fileURLWithPath: "\(dir)/after_alterar_dismiss.png")) + } + XCTAssertTrue(app.staticTexts["Finalizar Pedido"].exists, "Never returned to Checkout") + + // Deliberately not tapping "Confirmar e Pagar" - see doc comment. + } + + /// With "Cartão de Crédito" selected, "Confirmar e Pagar" does NOT + /// submit an order immediately - `CheckoutView+Logic.handleConfirmPaymentTap()` + /// returns early and opens `CardSelectionSheet` instead (real order + /// creation only happens if a saved card is then picked and confirmed + /// inside that sheet, which this test does not do). Confirmed by + /// reading the source before relying on it - this is a real, + /// deliberate app behavior, not an assumption. + func testConfirmarEPagarWithCreditCardOpensCardSelectionWithoutSubmitting() throws { + let app = XCUIApplication() + app.launch() + try reachCheckoutWithOneItem(app) + + let creditCardRow = app.buttons.matching(NSPredicate(format: "label CONTAINS[c] %@", "Cartão de Crédito")).firstMatch + XCTAssertTrue(creditCardRow.waitForExistence(timeout: 5), "This store is expected to offer Cartão de Crédito") + creditCardRow.tap() + + app.buttons["Confirmar e Pagar"].tap() + XCTAssertTrue(app.staticTexts["Selecionar Cartão"].waitForExistence(timeout: 10), "Never reached the card selection sheet") + + // "Adicionar novo cartão" opens PaymentCardView (CheckoutView.swift's + // own card-entry form - a different struct from AddCardFormView.swift, + // reached from Profile -> Meus Cartões, needs its own coverage) as a + // sheet on top. Only fills nothing and dismisses - no card saved, no + // order created. + app.buttons["Adicionar novo cartão"].tap() + XCTAssertTrue( + app.staticTexts["Novo Cartão"].waitForExistence(timeout: 10), + "Adicionar novo cartão never opened PaymentCardView" + ) + dismissViaCloseButton(app) + XCTAssertTrue(app.staticTexts["Selecionar Cartão"].waitForExistence(timeout: 10), "Never returned to card selection") + + dismissViaCloseButton(app) + XCTAssertTrue(app.staticTexts["Finalizar Pedido"].waitForExistence(timeout: 10), "Never returned to Checkout") + } + + /// Dismisses via the topmost sheet's "x" close button + /// (`identifier: "xmark"`) - shared by `CardSelectionSheet` and + /// `PaymentCardView`. `.firstMatch` tolerates a lower sheet's own + /// close button still existing underneath in the accessibility tree. + private func dismissViaCloseButton(_ app: XCUIApplication) { + let closeButton = app.buttons.matching(identifier: "xmark").firstMatch + for _ in 0..<10 { + if closeButton.exists, closeButton.isHittable { + closeButton.tap() + return + } + usleep(200_000) + } + app.swipeDown() + } + + private func reachCheckoutWithOneItem(_ app: XCUIApplication) throws { + XCTAssertTrue(app.ensureLoggedIn(), "Login never completed") + + // ensureLoggedIn can land on whichever tab it detected the + // authenticated session from (e.g. Profile, if already logged in + // from a previous test) rather than Home - switch explicitly. + XCTAssertTrue(app.buttons["Home"].waitForExistence(timeout: 10)) + app.buttons["Home"].tap() + + // The store card is one merged tappable element (name + category + + // distance + status all combine into its accessibility label), not + // a plain static text. + let storeCard = app.buttons.matching(NSPredicate(format: "label CONTAINS[c] %@", "MARIBA")).firstMatch + if storeCard.waitForExistence(timeout: 15) == false { + let dir = "/private/tmp/claude-501/-Users-loverde-co-Documents-Loverde-JOBs-Producao-Loverde-Co-LC-RAG-Struct-projects-PediFoods-ios/4a9ec4f7-c3ad-4cf5-8dad-073446f6fd81/scratchpad" + try? app.screenshot().pngRepresentation.write(to: URL(fileURLWithPath: "\(dir)/no_store_card.png")) + } + XCTAssertTrue(storeCard.exists, "Expected store card never appeared on Home") + storeCard.tap() + + // A plain (non-addon, non-pizza) product row's "+" control adds + // directly with no sheet. Once a product's quantity is > 0, its + // outer Button's identifier moves off itself - the same badge- + // merging bug already fixed for the tab bar's cart icon - because + // the row's own quantity Text becomes the Button's accessible + // label/identity instead. The QA account's cart has genuinely + // accumulated real quantities across many runs today, so + // app.buttons.matching(identifier: "plus") stopped matching once + // the first several products all had quantity > 0 (confirmed via + // screenshot: the "+" controls were clearly visible on screen + // while the buttons-only query found nothing). The nested Image + // keeps identifier "plus" regardless of quantity, so target that + // directly instead - same fix pattern as the cart-tab icon. + let addButton = app.images.matching(identifier: "plus").firstMatch + if addButton.waitForExistence(timeout: 25) == false { + let dir = "/private/tmp/claude-501/-Users-loverde-co-Documents-Loverde-JOBs-Producao-Loverde-Co-LC-RAG-Struct-projects-PediFoods-ios/4a9ec4f7-c3ad-4cf5-8dad-073446f6fd81/scratchpad" + try? app.screenshot().pngRepresentation.write(to: URL(fileURLWithPath: "\(dir)/store_detail_timeout40.png")) + } + XCTAssertTrue(addButton.exists, "Store Detail's product list never loaded") + addButton.tap() + + // Once the cart has items, the tab bar button's accessible label + // becomes just the badge count ("4") instead of "Shopping Cart" - + // confirmed via hierarchy dump (SwiftUI's accessibility-children + // combining dropped the nested Image's default "Shopping Cart" + // label entirely once a sibling Text badge was added). The nested + // "cart.fill" Image keeps its identifier regardless, so target + // that directly rather than the outer button. + let cartTab = app.images.matching(identifier: "cart.fill").firstMatch + XCTAssertTrue(cartTab.waitForExistence(timeout: 5)) + cartTab.tap() + XCTAssertTrue(app.staticTexts["Meu Carrinho"].waitForExistence(timeout: 10)) + XCTAssertFalse(app.staticTexts["Seu carrinho está vazio"].exists, "Cart still shows empty after adding a product") + + app.buttons["Ir para o Pagamento"].tap() + XCTAssertTrue(app.staticTexts["Finalizar Pedido"].waitForExistence(timeout: 10), "Never reached Checkout") + } +} diff --git a/PediFoodsUITests/HomeFiltersFlowTests.swift b/PediFoodsUITests/HomeFiltersFlowTests.swift new file mode 100644 index 0000000..b936d6e --- /dev/null +++ b/PediFoodsUITests/HomeFiltersFlowTests.swift @@ -0,0 +1,68 @@ +import XCTest + +/// Covers Home's filter sheet (FiltersModalView.swift, ~700 lines, was +/// 0% covered), reachable via the search bar's filter icon +/// (identifier "slider.horizontal.3"). Uses the authenticated QA account +/// rather than guest mode - the guest address picker +/// (PublicLocationPickerView) currently can't load its states/cities list +/// at all, because the backend rejects the simulator's App Attest bypass +/// with 403 APP_ATTEST_VERIFICATION_FAILED (confirmed directly via curl +/// against /api/public/session, a pre-existing backend-side issue, see +/// decisions/2026-08-06-ui-test-account-and-app-attest-bypass.md). That +/// leaves the guest address-picker sheet re-presenting itself indefinitely +/// (HomeView+Data.loadGuestStores() keeps re-triggering it while no +/// location is set), permanently covering Home. Logging in with the +/// standing QA account swaps AddressPickerModalView to the +/// App-Attest-free AddressesView instead, routing around the guest wall +/// entirely. +final class HomeFiltersFlowTests: XCTestCase { + override func setUpWithError() throws { + continueAfterFailure = false + } + + override func tearDownWithError() throws { + XCUIApplication().logoutIfAuthenticated() + } + + func testFiltersSheetSelectionsAndApply() throws { + let app = XCUIApplication() + app.launch() + XCTAssertTrue(app.ensureLoggedIn(), "Login never completed") + + // ensureLoggedIn can land on whichever tab it detected the + // authenticated session from - switch explicitly. + XCTAssertTrue(app.buttons["Home"].waitForExistence(timeout: 10)) + app.buttons["Home"].tap() + + // The filter icon lives inside Home's collapsing header + // (SearchBar, positioned with .offset(y: collapseProgress * -120)) + // - a plain .tap() was separately confirmed (while still chasing + // the guest-mode blocker above) to sometimes resolve to an invalid + // "Computed hit point {-1, -1}" for this transform-positioned + // element. Tapping a normalized-offset coordinate on the element + // bypasses XCUITest's automatic hit-point computation. + let filterIcon = app.buttons.matching(identifier: "slider.horizontal.3").firstMatch + XCTAssertTrue(filterIcon.waitForExistence(timeout: 10), "Filter icon never appeared on Home") + filterIcon.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() + XCTAssertTrue(app.staticTexts["Filtros"].waitForExistence(timeout: 5), "Never reached the filters sheet") + + // Sort option - just needs to be selectable, no assertion on the + // resulting Home state (that's real backend store data, out of + // scope here). + let ratingSort = app.buttons["Avaliação"] + if ratingSort.exists { + ratingSort.tap() + } + + // Price tier - label is the raw "$"/"$$"/etc symbol. + let mediumPrice = app.buttons["$$"] + if mediumPrice.exists { + mediumPrice.tap() + } + + app.buttons["Aplicar Filtros"].tap() + + // Applying dismisses back to Home. + XCTAssertTrue(app.textFields["Search menu, restaurant or craving"].waitForExistence(timeout: 10), "Never returned to Home after applying filters") + } +} diff --git a/PediFoodsUITests/HomeGuestFlowTests.swift b/PediFoodsUITests/HomeGuestFlowTests.swift new file mode 100644 index 0000000..74a6a09 --- /dev/null +++ b/PediFoodsUITests/HomeGuestFlowTests.swift @@ -0,0 +1,73 @@ +import XCTest + +/// Covers the guest (unauthenticated) Home screen - confirmed reachable +/// and stable, see decisions/2026-08-06-ui-test-account-and-app-attest-bypass.md. +/// Authenticated-only flows (real store data, cart checkout, orders) are +/// blocked by a separate login-navigation issue documented there and are +/// not covered here. +final class HomeGuestFlowTests: XCTestCase { + override func setUpWithError() throws { + continueAfterFailure = false + } + + func testHomeRendersSearchAndGreeting() throws { + let app = XCUIApplication() + app.launch() + XCTAssertTrue(app.reachGuestHome(), "Never reached Home's search field") + + XCTAssertTrue(app.staticTexts["ENTREGAR EM:"].exists) + XCTAssertTrue(app.textFields["Search menu, restaurant or craving"].exists) + XCTAssertTrue(app.images["bell"].exists, "Notifications bell missing") + } + + func testHomeShowsDefaultCategoryChip() throws { + let app = XCUIApplication() + app.launch() + XCTAssertTrue(app.reachGuestHome()) + + XCTAssertTrue(app.staticTexts["Categories"].exists) + XCTAssertTrue(app.staticTexts["Todas"].exists, "Default 'Todas' category chip missing") + } + + func testHomeShowsGuestLocationPromptWhenNoLocationChosen() throws { + let app = XCUIApplication() + app.launch() + XCTAssertTrue(app.reachGuestHome()) + + // No guest state/city selected -> Home's own inline message, not + // the blocking sheet (which we just dismissed to get here). + XCTAssertTrue(app.staticTexts["Escolha um estado e cidade para visualizar os estabelecimentos."].waitForExistence(timeout: 3)) + } + + func testTappingAddressChipReopensLocationPicker() throws { + let app = XCUIApplication() + app.launch() + XCTAssertTrue(app.reachGuestHome()) + + // The "ENTREGAR EM:" address button re-opens the same address + // picker sheet we dismissed to reach Home - confirms the chip is + // wired to the real activeModal state, not just decorative. + let addressChip = app.buttons.matching(NSPredicate(format: "identifier == 'chevron.down'")).firstMatch + if addressChip.exists { + addressChip.tap() + } else { + // Fall back to tapping near the "ENTREGAR EM:" label's row. + app.staticTexts["ENTREGAR EM:"].tap() + } + XCTAssertTrue( + app.staticTexts["Escolha seu estado"].waitForExistence(timeout: 3) + || app.staticTexts["Escolha sua cidade"].waitForExistence(timeout: 3), + "Tapping the address chip did not reopen the location picker" + ) + } + + // No testSearchFieldAcceptsTextInput here: the guest address-picker + // sheet re-presents on an independent timer (see + // decisions/2026-08-06-ui-test-account-and-app-attest-bypass.md) and + // can land back on top between a tap and the following `typeText`, + // which XCTest treats as a fatal, non-catchable event-synthesis + // failure - unlike `.exists` checks, there is no reliable way to guard + // against it. Confirmed via accessibility-hierarchy dump that the sheet + // was back on top at the moment of failure. Dropped rather than + // papered over with more retries. +} diff --git a/PediFoodsUITests/OrderDetailsFlowTests.swift b/PediFoodsUITests/OrderDetailsFlowTests.swift new file mode 100644 index 0000000..74f8512 --- /dev/null +++ b/PediFoodsUITests/OrderDetailsFlowTests.swift @@ -0,0 +1,35 @@ +import XCTest + +/// Covers Orders -> Order Details, another big zero-coverage screen +/// (OrderDetailsView.swift, ~1100 lines). Relies on the standing QA +/// account's real order history - see +/// decisions/2026-08-06-ui-test-account-and-app-attest-bypass.md. +final class OrderDetailsFlowTests: XCTestCase { + override func setUpWithError() throws { + continueAfterFailure = false + } + + override func tearDownWithError() throws { + XCUIApplication().logoutIfAuthenticated() + } + + func testOrderDetailsReachableFromOrdersList() throws { + let app = XCUIApplication() + app.launch() + XCTAssertTrue(app.ensureLoggedIn(), "Login never completed") + XCTAssertTrue(app.buttons["Sair da Conta"].waitForExistence(timeout: 10), "Never reached authenticated Profile") + + app.staticTexts["Meus Pedidos"].tap() + XCTAssertTrue(app.staticTexts["Meus Pedidos"].waitForExistence(timeout: 10)) + + // "Ver Detalhes" appears once per order card; this account has real + // order history (confirmed in AuthenticatedProfileNavigationTests). + let detailsButton = app.buttons["Ver Detalhes"] + XCTAssertTrue(detailsButton.waitForExistence(timeout: 10), "No orders with a 'Ver Detalhes' action found") + detailsButton.tap() + + XCTAssertTrue(app.staticTexts["Detalhes do Pedido"].waitForExistence(timeout: 10), "Never reached Order Details") + XCTAssertTrue(app.staticTexts["Itens do Pedido"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.staticTexts["Resumo de Valores"].waitForExistence(timeout: 5)) + } +} diff --git a/PediFoodsUITests/PediFoodsUITestsLaunchTests.swift b/PediFoodsUITests/PediFoodsUITestsLaunchTests.swift new file mode 100644 index 0000000..74ef55c --- /dev/null +++ b/PediFoodsUITests/PediFoodsUITestsLaunchTests.swift @@ -0,0 +1,19 @@ +import XCTest + +final class PediFoodsUITestsLaunchTests: XCTestCase { + override class var runsForEachTargetApplicationUIConfiguration: Bool { true } + + override func setUpWithError() throws { + continueAfterFailure = false + } + + func testLaunch() throws { + let app = XCUIApplication() + app.launch() + + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "Launch Screen" + attachment.lifetime = .keepAlways + add(attachment) + } +} diff --git a/PediFoodsUITests/ProfileLoggedOutFlowTests.swift b/PediFoodsUITests/ProfileLoggedOutFlowTests.swift new file mode 100644 index 0000000..5a547e3 --- /dev/null +++ b/PediFoodsUITests/ProfileLoggedOutFlowTests.swift @@ -0,0 +1,68 @@ +import XCTest + +/// Covers the Profile tab's logged-out state (`ProfileLoggedOutView`) and +/// the auth intro screen (`LoginView`) it opens. +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) + } + + /// The auth intro screen must offer both entry points plus the legal + /// links, and — the reason App Review rejected the build in 2026-08 — + /// a working way back out (its `LCENavigationView` back button, which + /// sets `root = .main`). + func testAuthIntroScreenShowsChoicesAndCanGoBack() throws { + let app = XCUIApplication() + app.launch() + XCTAssertTrue(app.reachLoggedOutProfile()) + + app.buttons["Entrar ou Cadastrar"].tap() + + XCTAssertTrue(app.buttons["Criar conta"].waitForExistence(timeout: 5), "Auth intro missing 'Criar conta'") + XCTAssertTrue(app.buttons["Entrar"].exists, "Auth intro missing 'Entrar'") + XCTAssertTrue(app.buttons["Termos de Uso"].exists, "Auth intro missing 'Termos de Uso' link") + XCTAssertTrue(app.buttons["Política de Privacidade"].exists, "Auth intro missing 'Política de Privacidade' link") + + let backButton = app.buttons.matching(NSPredicate(format: "label == %@", "Back")).firstMatch + XCTAssertTrue(backButton.exists, "Auth intro has no back button — user is trapped") + backButton.tap() + + // Back out of `.auth` lands on the main tab bar again. + XCTAssertTrue(app.buttons["Home"].waitForExistence(timeout: 5), "Back button did not return to the main flow") + } + + /// The pushed auth screens (Registration, LoginEmail) must carry the + /// app's standard `AppBackButtonIcon` via `LCENavigationView`, not the + /// oversized iOS 26 system glass back button. + func testPushedAuthScreensUseAppBackButton() throws { + let app = XCUIApplication() + app.launch() + XCTAssertTrue(app.reachLoggedOutProfile()) + app.buttons["Entrar ou Cadastrar"].tap() + + XCTAssertTrue(app.buttons["Criar conta"].waitForExistence(timeout: 5)) + app.buttons["Criar conta"].tap() + XCTAssertTrue(app.staticTexts["Crie sua conta"].waitForExistence(timeout: 5)) + let regBack = app.buttons.matching(NSPredicate(format: "label == %@", "Back")).firstMatch + XCTAssertTrue(regBack.exists, "Registration screen has no LCENavigationView back button") + regBack.tap() + + XCTAssertTrue(app.buttons["Entrar"].waitForExistence(timeout: 5)) + app.buttons["Entrar"].tap() + XCTAssertTrue(app.staticTexts["Boas-vindas!"].waitForExistence(timeout: 5)) + let loginBack = app.buttons.matching(NSPredicate(format: "label == %@", "Back")).firstMatch + XCTAssertTrue(loginBack.exists, "Login e-mail screen has no LCENavigationView back button") + loginBack.tap() + XCTAssertTrue(app.buttons["Criar conta"].waitForExistence(timeout: 5), "Back did not return to the auth intro") + } +} 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..b4db582 --- /dev/null +++ b/PediFoodsUITests/UITestSupport.swift @@ -0,0 +1,193 @@ +import XCTest + +extension XCUIApplication { + /// The guest address-picker sheet (`AddressPickerModalView` -> + /// `PublicLocationPickerView`) is a plain `.sheet`, so it's + /// swipe-dismissible - but `HomeView+Data.loadGuestStores()` + /// re-presents it every time Home reappears with no guest location + /// set (see decisions/2026-08-06-ui-test-account-and-app-attest-bypass.md), + /// so dismissing it is a race against that re-presentation. Retries a + /// swipe-down + Home-tab tap until either Home's search field is + /// reachable or the attempt budget runs out. + @discardableResult + func reachGuestHome(attempts: Int = 8) -> Bool { + let searchField = textFields["Search menu, restaurant or craving"] + + // The sheet can reappear a few hundred ms after Home first shows up, + // so a single `.exists` check can pass right before it gets covered + // again - re-check after a short settle delay to confirm it stuck. + func settled() -> Bool { + guard searchField.exists else { return false } + usleep(300_000) + return searchField.exists + } + + if settled() { return true } + + for _ in 1...attempts { + swipeDown() + usleep(300_000) + if searchField.waitForExistence(timeout: 1), settled() { + return true + } + } + return settled() + } + + /// Same race as `reachGuestHome`, but continuing on to tap the + /// Profile tab each attempt until the logged-out profile screen's + /// "Entrar ou Cadastrar" button appears. Only meaningful for a genuine + /// guest session - if a real login persisted (`TokenStore` is + /// Keychain-backed, survives app relaunches), this correctly never + /// succeeds, since it only recognizes the logged-out signal. Use + /// `reachProfileTabRegardlessOfAuthState` for authenticated-flow tests. + @discardableResult + func reachLoggedOutProfile(attempts: Int = 8) -> Bool { + let loginButton = buttons["Entrar ou Cadastrar"] + if loginButton.exists { return true } + + for _ in 1...attempts { + swipeDown() + usleep(300_000) + let profileTab = buttons.matching(identifier: "person.fill").element(boundBy: 1) + if profileTab.exists { + profileTab.tap() + } + usleep(500_000) + if loginButton.exists { + return true + } + } + return loginButton.exists + } + + /// Same swipe-dismiss-then-tap race as `reachLoggedOutProfile`, but + /// recognizes *either* the logged-out ("Entrar ou Cadastrar") or + /// authenticated ("Sair da Conta") signal as success. Needed because + /// an authenticated account with no saved address hits the exact same + /// re-presenting-sheet race as guests (`HomeView`'s "no address" branch + /// re-shows the address picker on every Home reappearance), just + /// rendering `AddressesView` (a real, scrollable address list) instead + /// of `PublicLocationPickerView` (short, static content). `swipeDown()` + /// alone isn't reliable there - a real `List`/`ScrollView` can consume + /// the drag gesture as a scroll instead of a dismiss, unlike the + /// guest picker's static content - so this taps the modal's own + /// "Back" button (`.appInlineNavigationTitle()`'s `dismiss()`-backed + /// button, present on both variants) first, falling back to + /// `swipeDown()` only if that button isn't there. + @discardableResult + func reachProfileTabRegardlessOfAuthState(attempts: Int = 20) -> Bool { + let loginButton = buttons["Entrar ou Cadastrar"] + let logoutButton = buttons["Sair da Conta"] + if loginButton.exists || logoutButton.exists { return true } + + for _ in 1...attempts { + // Exact-match subscript crashes ("multiple matching elements") + // if more than one "Back" button is on screen at once (e.g. a + // pushed detail screen's own back button plus the address + // picker's) - firstMatch tolerates any count. + // .isHittable, not just .exists: tapping a Back button that + // exists but is mid-transition (e.g. multiple pushed screens + // deep, like Profile -> Meus Cartões -> Novo Cartão) throws a + // fatal, uncatchable "not hittable" test failure rather than + // just failing gracefully - confirmed via a real teardown run. + let backButton = buttons.matching(NSPredicate(format: "label == %@", "Back")).firstMatch + if backButton.exists, backButton.isHittable { + backButton.tap() + } else { + swipeDown() + } + usleep(300_000) + // Unlike reachLoggedOutProfile, this must also work once + // authenticated - Home's header avatar shows the account's + // real profile picture instead of a generic "person.fill" icon + // once one is set, dropping the match count from 2 to 1 and + // making a fixed index resolve to nothing. `.last` tolerates + // either count (confirmed via screenshot: a real authenticated + // account with a profile picture only ever produces the tab + // bar's match). + if let profileTab = buttons.matching(identifier: "person.fill").allElementsBoundByIndex.last, + profileTab.exists { + profileTab.tap() + } + usleep(500_000) + if loginButton.exists || logoutButton.exists { + return true + } + } + return loginButton.exists || logoutButton.exists + } + + // MARK: - Authenticated-flow helpers + + /// Real end-to-end login with the standing QA account (see + /// TestFixtures.swift / decisions/2026-08-06-ui-test-account-and-app-attest-bypass.md). + /// The OTP field sets `.textContentType(.oneTimeCode)` + /// (`appOTPKeyboard()`, `TextFieldCompat.swift`) - the iOS system code + /// content type - which two different input methods failed against in + /// this environment: `.typeText()` delivered zero characters (keyboard + /// showed, field stayed empty), and `UIPasteboard` + the app's own + /// "Colar código" paste button delivered one stray, unrelated digit + /// (likely Simulator's host-Mac pasteboard sync racing the write). + /// Fix: tap the real on-screen numeric keypad's digit keys + /// (`app.keys[...]`) directly instead of any text-injection path - + /// confirmed reliable end to end (real OTP validation, real routing). + @discardableResult + func ensureLoggedIn( + email: String = UITestAccount.email, + phone: String = UITestAccount.phone, + otp: String = UITestAccount.otp, + timeout: TimeInterval = 30 + ) -> Bool { + guard reachProfileTabRegardlessOfAuthState() else { return false } + + if buttons["Sair da Conta"].exists { + return true + } + + buttons["Entrar ou Cadastrar"].tap() + + let entrarButton = buttons["Entrar"] + guard entrarButton.waitForExistence(timeout: 5) else { return false } + entrarButton.tap() + + let emailField = textFields["seu@email.com"] + guard emailField.waitForExistence(timeout: 5) else { return false } + emailField.tap() + emailField.typeText(email) + + let phoneField = textFields["(00) 00000-0000"] + phoneField.tap() + phoneField.typeText(phone) + + buttons["Receber Código"].tap() + + // The real "request OTP" backend call has been observed taking + // longer than 15s under load - confirmed via trace (waited the + // full 15s, "Verificação" never appeared, not a broken selector). + guard staticTexts["Verificação"].waitForExistence(timeout: 25) else { return false } + textFields.firstMatch.tap() + guard keys["6"].waitForExistence(timeout: 5) else { return false } + for digit in otp { + keys[String(digit)].tap() + } + + return buttons["Home"].waitForExistence(timeout: timeout) + } + + /// Counterpart to `ensureLoggedIn` - call from `tearDown()` in any test + /// that logged in, so guest-only tests elsewhere in the same run don't + /// inherit an authenticated Keychain session (`TokenStore` persists + /// across app relaunches). No-op if already logged out. + @discardableResult + func logoutIfAuthenticated() -> Bool { + guard reachProfileTabRegardlessOfAuthState() else { return false } + guard buttons["Sair da Conta"].exists else { return true } + + buttons["Sair da Conta"].tap() + let confirm = buttons["Sair"] + guard confirm.waitForExistence(timeout: 3) else { return false } + confirm.tap() + return buttons["Entrar ou Cadastrar"].waitForExistence(timeout: 5) + } +} diff --git a/PediFoodsUITests/UserProfileFlowTests.swift b/PediFoodsUITests/UserProfileFlowTests.swift new file mode 100644 index 0000000..126afa6 --- /dev/null +++ b/PediFoodsUITests/UserProfileFlowTests.swift @@ -0,0 +1,24 @@ +import XCTest + +/// Covers Profile -> Meu Perfil (UserProfileView.swift, ~750 lines, was +/// 0.8% covered), reachable via the header's "Ver Perfil" link. +final class UserProfileFlowTests: XCTestCase { + override func setUpWithError() throws { + continueAfterFailure = false + } + + override func tearDownWithError() throws { + XCUIApplication().logoutIfAuthenticated() + } + + func testUserProfileReachableFromProfileHeader() throws { + let app = XCUIApplication() + app.launch() + XCTAssertTrue(app.ensureLoggedIn(), "Login never completed") + XCTAssertTrue(app.buttons["Sair da Conta"].waitForExistence(timeout: 10), "Never reached authenticated Profile") + + app.staticTexts["Ver Perfil"].tap() + + XCTAssertTrue(app.staticTexts["Meu Perfil"].waitForExistence(timeout: 10), "Never reached Meu Perfil") + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..b97da2c --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ + +![](loverde_company_logo_full.png) +Pedi Foods +---- + +This is a private repository. +[Read the license](LICENSE.md) + + +Owner +---- + +Any question or doubts, please send thru email + +Daniel Arantes Loverde - +Clarice Moro Arantes Loverde - +Gustavo Moro Arantes Loverde - +Mariana Moro Arantes Loverde - + +Autor: Daniel Arantes Loverde diff --git a/bitrise.yml b/bitrise.yml new file mode 100644 index 0000000..190b437 --- /dev/null +++ b/bitrise.yml @@ -0,0 +1,172 @@ +format_version: '26' +default_step_lib_source: https://github.com/bitrise-io/bitrise-steplib.git +project_type: ios + +meta: + bitrise.io: + stack: osx-xcode-26.0.x + machine_type_id: g2-m1.8core + +app: + envs: + - TEST_SHARD_COUNT: 2 + - BITRISE_PROJECT_PATH: PediFoods.xcodeproj + opts: + is_expand: false + - BITRISE_SCHEME: PediFoods + opts: + is_expand: false + - BITRISE_DISTRIBUTION_METHOD: app-store + opts: + is_expand: false + - APPLE_ID: developer@loverde.com.br + # MANUAL (Bitrise Secrets tab — never commit these): + # ASC_KEY_ID → App Store Connect API Key ID + # ASC_ISSUER_ID → App Store Connect Issuer ID + # ASC_KEY_CONTENT → .p8 file content base64-encoded + # ITC_TEAM_ID → iTunes Connect Team ID (numeric) + +# ─── PIPELINES ──────────────────────────────────────────────────────────────── +pipelines: + run_tests: + workflows: + build_for_testing: {} + test_without_building: + depends_on: + - build_for_testing + parallel: "$TEST_SHARD_COUNT" + +# ─── WORKFLOWS ──────────────────────────────────────────────────────────────── +workflows: + + # ── Parallel test pipeline: step 1 — build only ─────────────────────────── + build_for_testing: + description: Build app for testing (no run) — used by run_tests pipeline + steps: + - git-clone@8: {} + - xcode-build-for-test@3: + inputs: + - project_path: $BITRISE_PROJECT_PATH + - scheme: $BITRISE_SCHEME + - simulator_device: iPhone 16 + - simulator_os_version: latest + - output_tool: xcpretty + - deploy-to-bitrise-io@2: + inputs: + - pipeline_intermediate_files: "$BITRISE_TEST_BUNDLE_ZIP_PATH:BITRISE_TEST_BUNDLE_ZIP_PATH" + + # ── Parallel test pipeline: step 2 — run shards ─────────────────────────── + test_without_building: + description: Run test shards in parallel — depends on build_for_testing + steps: + - pull-intermediate-files@1: + inputs: + - artifact_sources: build_for_testing + - xcode-test-without-building@0: + inputs: + - xctestrun: $BITRISE_TEST_BUNDLE_ZIP_PATH + - destination: platform=iOS Simulator,name=iPhone 16,OS=latest + - test_shard_index: $BITRISE_IO_PARALLEL_INDEX + - total_number_of_shards: $TEST_SHARD_COUNT + - generate_code_coverage_files: "yes" + + # ── Metadata push (no build) ─────────────────────────────────────────────── + metadata: + description: Push App Store metadata and screenshots for all languages + steps: + - git-clone@8: {} + - fastlane@3: + inputs: + - lane: metadata + - work_dir: $BITRISE_SOURCE_DIR + + # ── Simple single-machine test (non-parallel) ────────────────────────────── + test: + description: Run unit tests (single machine, for PR checks) + steps: + - git-clone@8: {} + - xcode-test@5: + inputs: + - project_path: $BITRISE_PROJECT_PATH + - scheme: $BITRISE_SCHEME + - simulator_device: iPhone 16 + - simulator_os_version: latest + - should_build_before_test: "yes" + - generate_code_coverage_files: "yes" + + # ── Beta → TestFlight ────────────────────────────────────────────────────── + beta: + description: Build, sign, and upload to TestFlight + steps: + - git-clone@8: {} + - script@1: + title: Set build number + inputs: + - content: | + cd $BITRISE_SOURCE_DIR + agvtool new-version -all $BITRISE_BUILD_NUMBER + - xcode-archive@5: + inputs: + - project_path: $BITRISE_PROJECT_PATH + - scheme: $BITRISE_SCHEME + - distribution_method: $BITRISE_DISTRIBUTION_METHOD + - automatic_code_signing: api-key + - register_test_devices: "no" + - platform: iOS + - deploy-to-itunesconnect-deliver@2: + inputs: + - itunescon_user: $APPLE_ID + - api_key_path: "" + - api_issuer: $ASC_ISSUER_ID + - submit_for_review: "no" + - skip_metadata: "yes" + - skip_screenshots: "yes" + + # ── Full release → App Store ─────────────────────────────────────────────── + release: + description: Build, sign, push metadata+screenshots, upload binary to App Store + envs: + - ASC_KEY_ID: $ASC_KEY_ID + - ASC_ISSUER_ID: $ASC_ISSUER_ID + - ASC_KEY_CONTENT: $ASC_KEY_CONTENT + - ITC_TEAM_ID: $ITC_TEAM_ID + steps: + - git-clone@8: {} + - script@1: + title: Verify ASC secrets + inputs: + - content: | + if [ -z "$ASC_KEY_ID" ]; then + echo "ERROR: ASC_KEY_ID is empty" + exit 1 + fi + echo "ASC_KEY_ID is set (length: ${#ASC_KEY_ID})" + echo "ASC_ISSUER_ID is set (length: ${#ASC_ISSUER_ID})" + echo "ASC_KEY_CONTENT is set (length: ${#ASC_KEY_CONTENT})" + - script@1: + title: Set build number + inputs: + - content: | + cd $BITRISE_SOURCE_DIR + agvtool new-version -all $BITRISE_BUILD_NUMBER + - xcode-archive@5: + inputs: + - project_path: $BITRISE_PROJECT_PATH + - scheme: $BITRISE_SCHEME + - distribution_method: $BITRISE_DISTRIBUTION_METHOD + - automatic_code_signing: api-key + - platform: iOS + - fastlane@3: + inputs: + - lane: release + - work_dir: $BITRISE_SOURCE_DIR + - deploy-to-bitrise-io@2: {} + +# ─── TRIGGER MAP ────────────────────────────────────────────────────────────── +trigger_map: + - push_branch: develop + workflow: beta + - push_branch: main + workflow: release + - pull_request_source_branch: feature/* + workflow: test diff --git a/pedi-foods/Darwin/fastlane/AppStore.xcconfig b/fastlane/AppStore.xcconfig similarity index 55% rename from pedi-foods/Darwin/fastlane/AppStore.xcconfig rename to fastlane/AppStore.xcconfig index 2dd4ae8..df61c35 100644 --- a/pedi-foods/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/fastlane/Appfile b/fastlane/Appfile new file mode 100644 index 0000000..dd3e1b3 --- /dev/null +++ b/fastlane/Appfile @@ -0,0 +1,8 @@ +# For more information about the Appfile, see: +# https://docs.fastlane.tools/advanced/#appfile + +app_identifier("com.br.pedifoods.app") + +apple_id ENV["APPLE_ID"] +itc_team_id ENV["ITC_TEAM_ID"] +team_id ENV["DEVELOPMENT_TEAM"] diff --git a/pedi-foods/Darwin/fastlane/Deliverfile b/fastlane/Deliverfile similarity index 100% rename from pedi-foods/Darwin/fastlane/Deliverfile rename to fastlane/Deliverfile diff --git a/fastlane/Fastfile b/fastlane/Fastfile new file mode 100644 index 0000000..577faaf --- /dev/null +++ b/fastlane/Fastfile @@ -0,0 +1,60 @@ +# 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| + # Manual signing (team/identity/profile) for the "PediFoods" target is + # baked directly into PediFoods.xcodeproj's own project settings - not + # 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", + sdk: "iphoneos", + export_method: ENV["BITRISE_DISTRIBUTION_METHOD"] || "app-store", + xcconfig: "fastlane/AppStore.xcconfig", + xcargs: xcargs, + derived_data_path: ".build/DerivedData", + output_directory: ".build/fastlane", + skip_archive: ENV["FASTLANE_SKIP_ARCHIVE"] == "YES", + skip_codesigning: ENV["FASTLANE_SKIP_CODESIGNING"] == "YES" + ) +end + +lane :beta do |options| + desc "Build and upload to TestFlight" + + get_provisioning_profile(api_key_path: "fastlane/apikey.json") + + assemble + + upload_to_testflight( + api_key_path: "fastlane/apikey.json", + skip_waiting_for_build_processing: true + ) +end + +lane :release do |options| + desc "Build and release app" + + # see https://docs.fastlane.tools/uploading-app-privacy-details/ + #upload_app_privacy_details_to_app_store(json_path: "fastlane/app_privacy_details.json") + + # if you have an apikey.json file (https://developer.apple.com/documentation/appstoreconnectapi/creating-api-keys-for-app-store-connect-api), fastlane can automatically fetch certificates and the ASC authentication information + #get_certificates(api_key_path: "fastlane/apikey.json") + get_provisioning_profile(api_key_path: "fastlane/apikey.json") + + assemble + + upload_to_app_store( + api_key_path: "fastlane/apikey.json", + app_rating_config_path: "fastlane/metadata/rating.json", + release_notes: { default: "Fixes and improvements." }, + submit_for_review: false + ) +end + diff --git a/pedi-foods/Darwin/fastlane/metadata/app_privacy_details.json b/fastlane/metadata/app_privacy_details.json similarity index 100% rename from pedi-foods/Darwin/fastlane/metadata/app_privacy_details.json rename to fastlane/metadata/app_privacy_details.json diff --git a/pedi-foods/Darwin/fastlane/metadata/en-US/description.txt b/fastlane/metadata/en-US/description.txt similarity index 100% rename from pedi-foods/Darwin/fastlane/metadata/en-US/description.txt rename to fastlane/metadata/en-US/description.txt diff --git a/pedi-foods/Darwin/fastlane/metadata/en-US/keywords.txt b/fastlane/metadata/en-US/keywords.txt similarity index 100% rename from pedi-foods/Darwin/fastlane/metadata/en-US/keywords.txt rename to fastlane/metadata/en-US/keywords.txt diff --git a/pedi-foods/Darwin/fastlane/metadata/en-US/privacy_url.txt b/fastlane/metadata/en-US/privacy_url.txt similarity index 100% rename from pedi-foods/Darwin/fastlane/metadata/en-US/privacy_url.txt rename to fastlane/metadata/en-US/privacy_url.txt diff --git a/pedi-foods/Darwin/fastlane/metadata/en-US/release_notes.txt b/fastlane/metadata/en-US/release_notes.txt similarity index 100% rename from pedi-foods/Darwin/fastlane/metadata/en-US/release_notes.txt rename to fastlane/metadata/en-US/release_notes.txt diff --git a/pedi-foods/Darwin/fastlane/metadata/en-US/software_url.txt b/fastlane/metadata/en-US/software_url.txt similarity index 100% rename from pedi-foods/Darwin/fastlane/metadata/en-US/software_url.txt rename to fastlane/metadata/en-US/software_url.txt diff --git a/pedi-foods/Darwin/fastlane/metadata/en-US/subtitle.txt b/fastlane/metadata/en-US/subtitle.txt similarity index 100% rename from pedi-foods/Darwin/fastlane/metadata/en-US/subtitle.txt rename to fastlane/metadata/en-US/subtitle.txt diff --git a/fastlane/metadata/en-US/support_url.txt b/fastlane/metadata/en-US/support_url.txt new file mode 100644 index 0000000..7ef4109 --- /dev/null +++ b/fastlane/metadata/en-US/support_url.txt @@ -0,0 +1 @@ +https://pedifoods.com.br/support diff --git a/pedi-foods/Android/fastlane/metadata/android/en-US/title.txt b/fastlane/metadata/en-US/title.txt similarity index 100% rename from pedi-foods/Android/fastlane/metadata/android/en-US/title.txt rename to fastlane/metadata/en-US/title.txt diff --git a/pedi-foods/Darwin/fastlane/metadata/en-US/version_whats_new.txt b/fastlane/metadata/en-US/version_whats_new.txt similarity index 100% rename from pedi-foods/Darwin/fastlane/metadata/en-US/version_whats_new.txt rename to fastlane/metadata/en-US/version_whats_new.txt diff --git a/pedi-foods/Darwin/fastlane/metadata/rating.json b/fastlane/metadata/rating.json similarity index 100% rename from pedi-foods/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/pedi-foods/Android/app/build.gradle.kts b/pedi-foods/Android/app/build.gradle.kts deleted file mode 100644 index 4752bf9..0000000 --- a/pedi-foods/Android/app/build.gradle.kts +++ /dev/null @@ -1,91 +0,0 @@ -import java.util.Properties - -plugins { - alias(libs.plugins.kotlin.android) - alias(libs.plugins.kotlin.compose) - alias(libs.plugins.android.application) - id("skip-build-plugin") -} - -skip { -} - -kotlin { - compilerOptions { - jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.fromTarget(libs.versions.jvm.get().toString()) - } -} - -android { - namespace = group as String - compileSdk = libs.versions.android.sdk.compile.get().toInt() - compileOptions { - sourceCompatibility = JavaVersion.toVersion(libs.versions.jvm.get()) - targetCompatibility = JavaVersion.toVersion(libs.versions.jvm.get()) - } - packaging { - jniLibs { - keepDebugSymbols.add("**/*.so") - pickFirsts.add("**/*.so") - // this option will compress JNI .so files - useLegacyPackaging = true - } - } - - defaultConfig { - minSdk = libs.versions.android.sdk.min.get().toInt() - targetSdk = libs.versions.android.sdk.compile.get().toInt() - // skip.tools.skip-build-plugin will automatically use Skip.env properties for: - // applicationId = ANDROID_APPLICATION_ID ?? PRODUCT_BUNDLE_IDENTIFIER - // versionCode = CURRENT_PROJECT_VERSION - // versionName = MARKETING_VERSION - } - - buildFeatures { - buildConfig = true - } - - lint { - disable.add("Instantiatable") - disable.add("MissingPermission") - } - - dependenciesInfo { - // Disables dependency metadata when building APKs. - includeInApk = false - // Disables dependency metadata when building Android App Bundles. - includeInBundle = false - } - - // default signing configuration tries to load from keystore.properties - // see: https://skip.tools/docs/deployment/#export-signing - signingConfigs { - val keystorePropertiesFile = file("keystore.properties") - create("release") { - if (keystorePropertiesFile.isFile) { - val keystoreProperties = Properties() - keystoreProperties.load(keystorePropertiesFile.inputStream()) - keyAlias = keystoreProperties.getProperty("keyAlias") - keyPassword = keystoreProperties.getProperty("keyPassword") - storeFile = file(keystoreProperties.getProperty("storeFile")) - storePassword = keystoreProperties.getProperty("storePassword") - } else { - // when there is no keystore.properties file, fall back to signing with debug config - keyAlias = signingConfigs.getByName("debug").keyAlias - keyPassword = signingConfigs.getByName("debug").keyPassword - storeFile = signingConfigs.getByName("debug").storeFile - storePassword = signingConfigs.getByName("debug").storePassword - } - } - } - - buildTypes { - release { - signingConfig = signingConfigs.findByName("release") - isMinifyEnabled = true - isShrinkResources = true - isDebuggable = false // can be set to true for debugging release build, but needs to be false when uploading to store - proguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro") - } - } -} diff --git a/pedi-foods/Android/app/proguard-rules.pro b/pedi-foods/Android/app/proguard-rules.pro deleted file mode 100644 index ee51be0..0000000 --- a/pedi-foods/Android/app/proguard-rules.pro +++ /dev/null @@ -1,10 +0,0 @@ --keeppackagenames ** --keep class skip.** { *; } --keep class tools.skip.** { *; } --keep class kotlin.jvm.functions.** {*;} --keep class com.sun.jna.** { *; } --dontwarn java.awt.** --keep class * implements com.sun.jna.** { *; } --keep class * implements skip.bridge.** { *; } --keep class **._ModuleBundleAccessor_* { *; } --keep class pedi.foods.** { *; } diff --git a/pedi-foods/Android/app/src/main/AndroidManifest.xml b/pedi-foods/Android/app/src/main/AndroidManifest.xml deleted file mode 100644 index c6c7049..0000000 --- a/pedi-foods/Android/app/src/main/AndroidManifest.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/pedi-foods/Android/app/src/main/kotlin/Main.kt b/pedi-foods/Android/app/src/main/kotlin/Main.kt deleted file mode 100644 index c2648b9..0000000 --- a/pedi-foods/Android/app/src/main/kotlin/Main.kt +++ /dev/null @@ -1,167 +0,0 @@ -package pedi.foods - -import skip.lib.* -import skip.model.* -import skip.foundation.* -import skip.ui.* - -import android.Manifest -import android.app.Application -import android.graphics.Color as AndroidColor -import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge -import androidx.activity.SystemBarStyle -import androidx.activity.ComponentActivity -import androidx.appcompat.app.AppCompatActivity -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.Box -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.SideEffect -import androidx.compose.runtime.saveable.rememberSaveableStateHolder -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.luminance -import androidx.compose.ui.platform.LocalContext -import androidx.compose.material3.MaterialTheme -import androidx.core.app.ActivityCompat - -internal val logger: SkipLogger = SkipLogger(subsystem = "pedi.foods", category = "PediFoods") - -private typealias AppRootView = PediFoodsRootView -private typealias AppDelegate = PediFoodsAppDelegate - -/// AndroidAppMain is the `android.app.Application` entry point, and must match `application android:name` in the AndroidMainfest.xml file. -open class AndroidAppMain: Application { - constructor() { - } - - override fun onCreate() { - super.onCreate() - logger.info("starting app") - ProcessInfo.launch(applicationContext) - AppDelegate.shared.onInit() - } - - companion object { - } -} - -/// AndroidAppMain is initial `androidx.appcompat.app.AppCompatActivity`, and must match `activity android:name` in the AndroidMainfest.xml file. -open class MainActivity: AppCompatActivity { - constructor() { - } - - override fun onCreate(savedInstanceState: android.os.Bundle?) { - super.onCreate(savedInstanceState) - logger.info("starting activity") - UIApplication.launch(this) - enableEdgeToEdge() - - setContent { - val saveableStateHolder = rememberSaveableStateHolder() - saveableStateHolder.SaveableStateProvider(true) { - PresentationRootView(ComposeContext()) - SideEffect { saveableStateHolder.removeState(true) } - } - } - - AppDelegate.shared.onLaunch() - - // Example of requesting permissions on startup. - // These must match the permissions in the AndroidManifest.xml file. - //let permissions = listOf( - // Manifest.permission.ACCESS_COARSE_LOCATION, - // Manifest.permission.ACCESS_FINE_LOCATION - // Manifest.permission.CAMERA, - // Manifest.permission.WRITE_EXTERNAL_STORAGE, - //) - //let requestTag = 1 - //ActivityCompat.requestPermissions(self, permissions.toTypedArray(), requestTag) - } - - override fun onStart() { - logger.info("onStart") - super.onStart() - } - - override fun onResume() { - super.onResume() - AppDelegate.shared.onResume() - } - - override fun onPause() { - super.onPause() - AppDelegate.shared.onPause() - } - - override fun onStop() { - super.onStop() - AppDelegate.shared.onStop() - } - - override fun onDestroy() { - super.onDestroy() - AppDelegate.shared.onDestroy() - } - - override fun onLowMemory() { - super.onLowMemory() - AppDelegate.shared.onLowMemory() - } - - override fun onRestart() { - logger.info("onRestart") - super.onRestart() - } - - override fun onSaveInstanceState(outState: android.os.Bundle): Unit = super.onSaveInstanceState(outState) - - override fun onRestoreInstanceState(bundle: android.os.Bundle) { - // Usually you restore your state in onCreate(). It is possible to restore it in onRestoreInstanceState() as well, but not very common. (onRestoreInstanceState() is called after onStart(), whereas onCreate() is called before onStart(). - logger.info("onRestoreInstanceState") - super.onRestoreInstanceState(bundle) - } - - override fun onRequestPermissionsResult(requestCode: Int, permissions: kotlin.Array, grantResults: IntArray) { - super.onRequestPermissionsResult(requestCode, permissions, grantResults) - logger.info("onRequestPermissionsResult: ${requestCode}") - } - - companion object { - } -} - -@Composable -internal fun SyncSystemBarsWithTheme() { - val dark = MaterialTheme.colorScheme.background.luminance() < 0.5f - - val transparent = AndroidColor.TRANSPARENT - val style = if (dark) { - SystemBarStyle.dark(transparent) - } else { - SystemBarStyle.light(transparent, transparent) - } - - val activity = LocalContext.current as? ComponentActivity - DisposableEffect(style) { - activity?.enableEdgeToEdge( - statusBarStyle = style, - navigationBarStyle = style - ) - onDispose { } - } -} - -@Composable -internal fun PresentationRootView(context: ComposeContext) { - val colorScheme = if (isSystemInDarkTheme()) ColorScheme.dark else ColorScheme.light - PresentationRoot(defaultColorScheme = colorScheme, context = context) { ctx -> - SyncSystemBarsWithTheme() - val contentContext = ctx.content() - Box(modifier = ctx.modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - AppRootView().Compose(context = contentContext) - } - } -} diff --git a/pedi-foods/Android/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/pedi-foods/Android/app/src/main/res/mipmap-anydpi/ic_launcher.xml deleted file mode 100644 index c1ce612..0000000 --- a/pedi-foods/Android/app/src/main/res/mipmap-anydpi/ic_launcher.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/pedi-foods/Android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/pedi-foods/Android/app/src/main/res/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index ff862e7..0000000 Binary files a/pedi-foods/Android/app/src/main/res/mipmap-hdpi/ic_launcher.png and /dev/null differ diff --git a/pedi-foods/Android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png b/pedi-foods/Android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png deleted file mode 100644 index acf6b7e..0000000 Binary files a/pedi-foods/Android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png and /dev/null differ diff --git a/pedi-foods/Android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/pedi-foods/Android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png deleted file mode 100644 index 0eeb46e..0000000 Binary files a/pedi-foods/Android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/pedi-foods/Android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png b/pedi-foods/Android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png deleted file mode 100644 index 0eeb46e..0000000 Binary files a/pedi-foods/Android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png and /dev/null differ diff --git a/pedi-foods/Android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/pedi-foods/Android/app/src/main/res/mipmap-mdpi/ic_launcher.png deleted file mode 100644 index 3dac36a..0000000 Binary files a/pedi-foods/Android/app/src/main/res/mipmap-mdpi/ic_launcher.png and /dev/null differ diff --git a/pedi-foods/Android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png b/pedi-foods/Android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png deleted file mode 100644 index 0fdc22d..0000000 Binary files a/pedi-foods/Android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png and /dev/null differ diff --git a/pedi-foods/Android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/pedi-foods/Android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png deleted file mode 100644 index 3cf8fb1..0000000 Binary files a/pedi-foods/Android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/pedi-foods/Android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png b/pedi-foods/Android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png deleted file mode 100644 index 3cf8fb1..0000000 Binary files a/pedi-foods/Android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png and /dev/null differ diff --git a/pedi-foods/Android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/pedi-foods/Android/app/src/main/res/mipmap-xhdpi/ic_launcher.png deleted file mode 100644 index c9d77af..0000000 Binary files a/pedi-foods/Android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ diff --git a/pedi-foods/Android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png b/pedi-foods/Android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png deleted file mode 100644 index bba635b..0000000 Binary files a/pedi-foods/Android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png and /dev/null differ diff --git a/pedi-foods/Android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/pedi-foods/Android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png deleted file mode 100644 index beb10dc..0000000 Binary files a/pedi-foods/Android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/pedi-foods/Android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png b/pedi-foods/Android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png deleted file mode 100644 index beb10dc..0000000 Binary files a/pedi-foods/Android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png and /dev/null differ diff --git a/pedi-foods/Android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/pedi-foods/Android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png deleted file mode 100644 index b0bd6f9..0000000 Binary files a/pedi-foods/Android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ diff --git a/pedi-foods/Android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png b/pedi-foods/Android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png deleted file mode 100644 index 2ee0546..0000000 Binary files a/pedi-foods/Android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png and /dev/null differ diff --git a/pedi-foods/Android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/pedi-foods/Android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png deleted file mode 100644 index 7625d77..0000000 Binary files a/pedi-foods/Android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/pedi-foods/Android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png b/pedi-foods/Android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png deleted file mode 100644 index 7625d77..0000000 Binary files a/pedi-foods/Android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png and /dev/null differ diff --git a/pedi-foods/Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/pedi-foods/Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index d80e6f6..0000000 Binary files a/pedi-foods/Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/pedi-foods/Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png b/pedi-foods/Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png deleted file mode 100644 index adcde46..0000000 Binary files a/pedi-foods/Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png and /dev/null differ diff --git a/pedi-foods/Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/pedi-foods/Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png deleted file mode 100644 index a0175d9..0000000 Binary files a/pedi-foods/Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/pedi-foods/Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png b/pedi-foods/Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png deleted file mode 100644 index a0175d9..0000000 Binary files a/pedi-foods/Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png and /dev/null differ diff --git a/pedi-foods/Android/fastlane/Appfile b/pedi-foods/Android/fastlane/Appfile deleted file mode 100644 index 6f52e33..0000000 --- a/pedi-foods/Android/fastlane/Appfile +++ /dev/null @@ -1,11 +0,0 @@ -# This file contains the app distribution configuration -# for the Android half of the Skip app. -# You can find the documentation at https://docs.fastlane.tools - -# Load the shared Skip.env properties with the app info -require('dotenv') -Dotenv.load('../../Skip.env') -package_name(ENV['PRODUCT_BUNDLE_IDENTIFIER'].sub("-", "_")) - -# Path to the json secret file - Follow https://docs.fastlane.tools/actions/supply/#setup to get one -json_key_file("fastlane/apikey.json") diff --git a/pedi-foods/Android/fastlane/Fastfile b/pedi-foods/Android/fastlane/Fastfile deleted file mode 100644 index c71d4d5..0000000 --- a/pedi-foods/Android/fastlane/Fastfile +++ /dev/null @@ -1,51 +0,0 @@ -# This file contains the fastlane.tools configuration -# for the Android half of the Skip app. -# You can find the documentation at https://docs.fastlane.tools - -# Load the shared Skip.env properties with the app info -require('dotenv') -Dotenv.load '../../Skip.env' - -default_platform(:android) - -# use the Homebrew gradle rather than expecting a local gradlew -gradle_bin = (ENV['HOMEBREW_PREFIX'] ? ENV['HOMEBREW_PREFIX'] : "/opt/homebrew") + "/bin/gradle" - -default_platform(:android) - -desc "Build Skip Android App" -lane :build do |options| - build_config = (options[:release] ? "Release" : "Debug") - gradle( - task: "build${build_config}", - gradle_path: gradle_bin, - flags: "--warning-mode none -x lint" - ) -end - -desc "Test Skip Android App" -lane :test do - gradle( - task: "test", - gradle_path: gradle_bin - ) -end - -desc "Assemble Skip Android App" -lane :assemble do - gradle( - gradle_path: gradle_bin, - task: "bundleRelease" - ) - # sh "your_script.sh" -end - -desc "Deploy Skip Android App to Google Play" -lane :release do - - assemble - - upload_to_play_store( - aab: '../.build/Android/app/outputs/bundle/release/app-release.aab' - ) -end diff --git a/pedi-foods/Android/fastlane/metadata/android/en-US/full_description.txt b/pedi-foods/Android/fastlane/metadata/android/en-US/full_description.txt deleted file mode 100644 index c65ef9c..0000000 --- a/pedi-foods/Android/fastlane/metadata/android/en-US/full_description.txt +++ /dev/null @@ -1 +0,0 @@ -A great new app built with Skip! diff --git a/pedi-foods/Android/fastlane/metadata/android/en-US/short_description.txt b/pedi-foods/Android/fastlane/metadata/android/en-US/short_description.txt deleted file mode 100644 index c65ef9c..0000000 --- a/pedi-foods/Android/fastlane/metadata/android/en-US/short_description.txt +++ /dev/null @@ -1 +0,0 @@ -A great new app built with Skip! diff --git a/pedi-foods/Android/gradle.properties b/pedi-foods/Android/gradle.properties deleted file mode 100644 index 1b8d060..0000000 --- a/pedi-foods/Android/gradle.properties +++ /dev/null @@ -1,3 +0,0 @@ -org.gradle.jvmargs=-Xmx4g -android.useAndroidX=true -kotlin.code.style=official diff --git a/pedi-foods/Android/gradle/wrapper/gradle-wrapper.properties b/pedi-foods/Android/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index 630c082..0000000 --- a/pedi-foods/Android/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1 +0,0 @@ -distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip diff --git a/pedi-foods/Android/settings.gradle.kts b/pedi-foods/Android/settings.gradle.kts deleted file mode 100644 index e20f3b3..0000000 --- a/pedi-foods/Android/settings.gradle.kts +++ /dev/null @@ -1,25 +0,0 @@ -// This gradle project is part of a conventional Skip app project. -pluginManagement { - // Initialize the Skip plugin folder and perform a pre-build for non-Xcode builds - val pluginPath = File.createTempFile("skip-plugin-path", ".tmp") - - // overriding outputs for an Android IDE can be done by un-commenting and setting the Xcode path: - //System.setProperty("BUILT_PRODUCTS_DIR", "${System.getProperty("user.home")}/Library/Developer/Xcode/DerivedData/MySkipProject-HASH/Build/Products/Debug-iphonesimulator") - - val skipPluginResult = providers.exec { - commandLine("/bin/sh", "-c", "skip plugin --prebuild --package-path '${settings.rootDir.parent}' --plugin-ref '${pluginPath.absolutePath}'") - environment("PATH", "${System.getenv("PATH")}:/opt/homebrew/bin") - } - val skipPluginOutput = skipPluginResult.standardOutput.asText.get() - print(skipPluginOutput) - val skipPluginError = skipPluginResult.standardError.asText.get() - print(skipPluginError) - - includeBuild(pluginPath.readText()) { - name = "skip-plugins" - } -} - -plugins { - id("skip-plugin") apply true -} diff --git a/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/AppIcon-20@2x.png b/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/AppIcon-20@2x.png deleted file mode 100644 index f1a1740..0000000 Binary files a/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/AppIcon-20@2x.png and /dev/null differ diff --git a/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/AppIcon-20@2x~ipad.png b/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/AppIcon-20@2x~ipad.png deleted file mode 100644 index f1a1740..0000000 Binary files a/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/AppIcon-20@2x~ipad.png and /dev/null differ diff --git a/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/AppIcon-20@3x.png b/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/AppIcon-20@3x.png deleted file mode 100644 index 68c9d75..0000000 Binary files a/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/AppIcon-20@3x.png and /dev/null differ diff --git a/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/AppIcon-20~ipad.png b/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/AppIcon-20~ipad.png deleted file mode 100644 index 880a146..0000000 Binary files a/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/AppIcon-20~ipad.png and /dev/null differ diff --git a/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/AppIcon-29.png b/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/AppIcon-29.png deleted file mode 100644 index 5974ff1..0000000 Binary files a/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/AppIcon-29.png and /dev/null differ diff --git a/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/AppIcon-29@2x.png b/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/AppIcon-29@2x.png deleted file mode 100644 index 375714a..0000000 Binary files a/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/AppIcon-29@2x.png and /dev/null differ diff --git a/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/AppIcon-29@2x~ipad.png b/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/AppIcon-29@2x~ipad.png deleted file mode 100644 index 375714a..0000000 Binary files a/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/AppIcon-29@2x~ipad.png and /dev/null differ diff --git a/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/AppIcon-29@3x.png b/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/AppIcon-29@3x.png deleted file mode 100644 index c85663d..0000000 Binary files a/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/AppIcon-29@3x.png and /dev/null differ diff --git a/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/AppIcon-29~ipad.png b/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/AppIcon-29~ipad.png deleted file mode 100644 index 5974ff1..0000000 Binary files a/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/AppIcon-29~ipad.png and /dev/null differ diff --git a/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/AppIcon-40@2x.png b/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/AppIcon-40@2x.png deleted file mode 100644 index a2c885a..0000000 Binary files a/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/AppIcon-40@2x.png and /dev/null differ diff --git a/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/AppIcon-40@2x~ipad.png b/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/AppIcon-40@2x~ipad.png deleted file mode 100644 index a2c885a..0000000 Binary files a/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/AppIcon-40@2x~ipad.png and /dev/null differ diff --git a/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/AppIcon-40@3x.png b/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/AppIcon-40@3x.png deleted file mode 100644 index bd8fbed..0000000 Binary files a/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/AppIcon-40@3x.png and /dev/null differ diff --git a/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/AppIcon-40~ipad.png b/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/AppIcon-40~ipad.png deleted file mode 100644 index f1a1740..0000000 Binary files a/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/AppIcon-40~ipad.png and /dev/null differ diff --git a/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5@2x~ipad.png b/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5@2x~ipad.png deleted file mode 100644 index af0ccf8..0000000 Binary files a/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5@2x~ipad.png and /dev/null differ diff --git a/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/AppIcon@2x.png b/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/AppIcon@2x.png deleted file mode 100644 index bd8fbed..0000000 Binary files a/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/AppIcon@2x.png and /dev/null differ diff --git a/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/AppIcon@2x~ipad.png b/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/AppIcon@2x~ipad.png deleted file mode 100644 index 301602e..0000000 Binary files a/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/AppIcon@2x~ipad.png and /dev/null differ diff --git a/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/AppIcon@3x.png b/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/AppIcon@3x.png deleted file mode 100644 index d727e92..0000000 Binary files a/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/AppIcon@3x.png and /dev/null differ diff --git a/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/AppIcon~ios-marketing.png b/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/AppIcon~ios-marketing.png deleted file mode 100644 index 57e105d..0000000 Binary files a/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/AppIcon~ios-marketing.png and /dev/null differ diff --git a/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/AppIcon~ipad.png b/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/AppIcon~ipad.png deleted file mode 100644 index 4f3fa69..0000000 Binary files a/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/AppIcon~ipad.png and /dev/null differ diff --git a/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/Contents.json b/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index 611a2bb..0000000 --- a/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,122 +0,0 @@ -{ - "images" : [ - { - "filename" : "AppIcon-20@2x.png", - "idiom" : "iphone", - "scale" : "2x", - "size" : "20x20" - }, - { - "filename" : "AppIcon-20@3x.png", - "idiom" : "iphone", - "scale" : "3x", - "size" : "20x20" - }, - { - "filename" : "AppIcon-29.png", - "idiom" : "iphone", - "scale" : "1x", - "size" : "29x29" - }, - { - "filename" : "AppIcon-29@2x.png", - "idiom" : "iphone", - "scale" : "2x", - "size" : "29x29" - }, - { - "filename" : "AppIcon-29@3x.png", - "idiom" : "iphone", - "scale" : "3x", - "size" : "29x29" - }, - { - "filename" : "AppIcon-40@2x.png", - "idiom" : "iphone", - "scale" : "2x", - "size" : "40x40" - }, - { - "filename" : "AppIcon-40@3x.png", - "idiom" : "iphone", - "scale" : "3x", - "size" : "40x40" - }, - { - "filename" : "AppIcon@2x.png", - "idiom" : "iphone", - "scale" : "2x", - "size" : "60x60" - }, - { - "filename" : "AppIcon@3x.png", - "idiom" : "iphone", - "scale" : "3x", - "size" : "60x60" - }, - { - "filename" : "AppIcon-20~ipad.png", - "idiom" : "ipad", - "scale" : "1x", - "size" : "20x20" - }, - { - "filename" : "AppIcon-20@2x~ipad.png", - "idiom" : "ipad", - "scale" : "2x", - "size" : "20x20" - }, - { - "filename" : "AppIcon-29~ipad.png", - "idiom" : "ipad", - "scale" : "1x", - "size" : "29x29" - }, - { - "filename" : "AppIcon-29@2x~ipad.png", - "idiom" : "ipad", - "scale" : "2x", - "size" : "29x29" - }, - { - "filename" : "AppIcon-40~ipad.png", - "idiom" : "ipad", - "scale" : "1x", - "size" : "40x40" - }, - { - "filename" : "AppIcon-40@2x~ipad.png", - "idiom" : "ipad", - "scale" : "2x", - "size" : "40x40" - }, - { - "filename" : "AppIcon~ipad.png", - "idiom" : "ipad", - "scale" : "1x", - "size" : "76x76" - }, - { - "filename" : "AppIcon@2x~ipad.png", - "idiom" : "ipad", - "scale" : "2x", - "size" : "76x76" - }, - { - "filename" : "AppIcon-83.5@2x~ipad.png", - "idiom" : "ipad", - "scale" : "2x", - "size" : "83.5x83.5" - }, - { - "filename" : "AppIcon~ios-marketing.png", - "idiom" : "ios-marketing", - "scale" : "1x", - "size" : "1024x1024" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/pedi-foods/Darwin/Info.plist b/pedi-foods/Darwin/Info.plist deleted file mode 100644 index 6d5f8f6..0000000 --- a/pedi-foods/Darwin/Info.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - ITSAppUsesNonExemptEncryption - - - diff --git a/pedi-foods/Darwin/PediFoods.xcconfig b/pedi-foods/Darwin/PediFoods.xcconfig deleted file mode 100644 index 6b94844..0000000 --- a/pedi-foods/Darwin/PediFoods.xcconfig +++ /dev/null @@ -1,58 +0,0 @@ -#include "../Skip.env" - -// Set the action that will be executed as part of the Xcode Run Script phase -// Setting to "launch" will build and run the app in the first open Android emulator or device -// Setting to "build" will just run gradle build, but will not launch the app -// Setting to "none" will completely disable the build and launch of the Android app -SKIP_ACTION = launch -//SKIP_ACTION = build -//SKIP_ACTION = none - -ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon -ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor - -INFOPLIST_FILE = Info.plist -GENERATE_INFOPLIST_FILE = YES - -// The user-visible name of the app (localizable) -//INFOPLIST_KEY_CFBundleDisplayName = App Name -//INFOPLIST_KEY_LSApplicationCategoryType = public.app-category.utilities -//INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "This app uses your location to …" - -// iOS-specific Info.plist property keys -INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphone*] = YES -INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphone*] = YES -INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphone*] = YES -INFOPLIST_KEY_UIStatusBarStyle[sdk=iphone*] = UIStatusBarStyleDefault -INFOPLIST_KEY_UISupportedInterfaceOrientations[sdk=iphone*] = UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown - -IPHONEOS_DEPLOYMENT_TARGET = 17.0 -MACOSX_DEPLOYMENT_TARGET = 14.0 -SUPPORTS_MACCATALYST = NO - -// iPhone + iPad -TARGETED_DEVICE_FAMILY = 1,2 - -// iPhone only -// TARGETED_DEVICE_FAMILY = 1 - -SWIFT_EMIT_LOC_STRINGS = YES - -// the name of the product module; this can be anything, but cannot conflict with any Swift module names -PRODUCT_MODULE_NAME = $(PRODUCT_NAME:c99extidentifier)App - -// On-device testing may need to override the bundle ID -// PRODUCT_BUNDLE_IDENTIFIER[config=Debug][sdk=iphoneos*] = cool.beans.BundleIdentifer - -SDKROOT = auto -SUPPORTED_PLATFORMS = iphoneos iphonesimulator macosx -SWIFT_EMIT_LOC_STRINGS = YES - -SWIFT_VERSION = 6 - -// Development team ID for on-device testing -CODE_SIGNING_REQUIRED = NO -CODE_SIGN_STYLE = Automatic -CODE_SIGN_ENTITLEMENTS = Entitlements.plist -//CODE_SIGNING_IDENTITY = - -//DEVELOPMENT_TEAM = diff --git a/pedi-foods/Darwin/PediFoods.xcodeproj/project.pbxproj b/pedi-foods/Darwin/PediFoods.xcodeproj/project.pbxproj deleted file mode 100644 index 312c83d..0000000 --- a/pedi-foods/Darwin/PediFoods.xcodeproj/project.pbxproj +++ /dev/null @@ -1,289 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 56; - objects = { - -/* Begin PBXBuildFile section */ - 491F27822DA55B72004926EE /* PediFoods in Frameworks */ = {isa = PBXBuildFile; productRef = 491F27812DA55B72004926EE /* PediFoods */; }; - 491F27832DA55B72004926EE /* PediFoods in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 491F27812DA55B72004926EE /* PediFoods */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; - 496BDBEE2B8A7E9C00C09264 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 496BDBED2B8A7E9C00C09264 /* Localizable.xcstrings */; }; - 499CD43B2AC5B799001AE8D8 /* Main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49F90C2B2A52156200F06D93 /* Main.swift */; }; - 499CD4402AC5B799001AE8D8 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 49F90C2F2A52156300F06D93 /* Assets.xcassets */; }; -/* End PBXBuildFile section */ - -/* Begin PBXCopyFilesBuildPhase section */ - 499CD44A2AC5B9C6001AE8D8 /* Embed Frameworks */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 10; - files = ( - 491F27832DA55B72004926EE /* PediFoods in Embed Frameworks */, - ); - name = "Embed Frameworks"; - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXCopyFilesBuildPhase section */ - -/* Begin PBXFileReference section */ - 4900101C2BACEA710000DE33 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; - 493609562A6B7EAE00C401E2 /* PediFoods */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = PediFoods; path = ..; sourceTree = ""; }; - 496BDBEB2B89A47800C09264 /* PediFoods.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PediFoods.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 496BDBED2B8A7E9C00C09264 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; name = Localizable.xcstrings; path = ../Sources/PediFoods/Resources/Localizable.xcstrings; sourceTree = ""; }; - 496EB72F2A6AE4DE00C1253A /* Skip.env */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Skip.env; path = ../Skip.env; sourceTree = ""; }; - 496EB72F2A6AE4DE00C1253B /* PediFoods.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = PediFoods.xcconfig; sourceTree = ""; }; - 496EB72F2A6AE4DE00C1253C /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; name = README.md; path = ../README.md; sourceTree = ""; }; - 499AB9082B0581F4005E8330 /* plugins */ = {isa = PBXFileReference; lastKnownFileType = folder; name = plugins; path = ../../Intermediates.noindex/BuildToolPluginIntermediates; sourceTree = BUILT_PRODUCTS_DIR; }; - 49F90C2B2A52156200F06D93 /* Main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = Main.swift; path = Sources/Main.swift; sourceTree = SOURCE_ROOT; }; - 49F90C2F2A52156300F06D93 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - 49F90C312A52156300F06D93 /* Entitlements.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Entitlements.plist; sourceTree = ""; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - 499CD43C2AC5B799001AE8D8 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - 491F27822DA55B72004926EE /* PediFoods in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - 496BDBEC2B89A47800C09264 /* Products */ = { - isa = PBXGroup; - children = ( - 496BDBEB2B89A47800C09264 /* PediFoods.app */, - ); - name = Products; - sourceTree = ""; - }; - 49AB54462B066A7E007B79B2 /* SkipStone */ = { - isa = PBXGroup; - children = ( - 499AB9082B0581F4005E8330 /* plugins */, - ); - name = SkipStone; - sourceTree = ""; - }; - 49F90C1F2A52156200F06D93 = { - isa = PBXGroup; - children = ( - 496EB72F2A6AE4DE00C1253C /* README.md */, - 496EB72F2A6AE4DE00C1253A /* Skip.env */, - 496EB72F2A6AE4DE00C1253B /* PediFoods.xcconfig */, - 496BDBED2B8A7E9C00C09264 /* Localizable.xcstrings */, - 493609562A6B7EAE00C401E2 /* PediFoods */, - 49F90C2A2A52156200F06D93 /* App */, - 49AB54462B066A7E007B79B2 /* SkipStone */, - 496BDBEC2B89A47800C09264 /* Products */, - ); - sourceTree = ""; - }; - 49F90C2A2A52156200F06D93 /* App */ = { - isa = PBXGroup; - children = ( - 49F90C2B2A52156200F06D93 /* Main.swift */, - 49F90C2F2A52156300F06D93 /* Assets.xcassets */, - 49F90C312A52156300F06D93 /* Entitlements.plist */, - 4900101C2BACEA710000DE33 /* Info.plist */, - ); - name = App; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - 499CD4382AC5B799001AE8D8 /* PediFoods App */ = { - isa = PBXNativeTarget; - buildConfigurationList = 499CD4412AC5B799001AE8D8 /* Build configuration list for PBXNativeTarget "PediFoods App" */; - buildPhases = ( - 499CD43A2AC5B799001AE8D8 /* Sources */, - 499CD43C2AC5B799001AE8D8 /* Frameworks */, - 499CD43E2AC5B799001AE8D8 /* Resources */, - 499CD4452AC5B869001AE8D8 /* Run skip gradle */, - 499CD44A2AC5B9C6001AE8D8 /* Embed Frameworks */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = "PediFoods App"; - packageProductDependencies = ( - 491F27812DA55B72004926EE /* PediFoods */, - ); - productName = App; - productReference = 496BDBEB2B89A47800C09264 /* PediFoods.app */; - productType = "com.apple.product-type.application"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 49F90C202A52156200F06D93 /* Project object */ = { - isa = PBXProject; - attributes = { - BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1430; - LastUpgradeCheck = 1630; - }; - buildConfigurationList = 49F90C232A52156200F06D93 /* Build configuration list for PBXProject "PediFoods" */; - compatibilityVersion = "Xcode 14.0"; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - es, - ja, - "zh-Hans", - ); - mainGroup = 49F90C1F2A52156200F06D93; - packageReferences = ( - ); - productRefGroup = 496BDBEC2B89A47800C09264 /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 499CD4382AC5B799001AE8D8 /* PediFoods App */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - 499CD43E2AC5B799001AE8D8 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 499CD4402AC5B799001AE8D8 /* Assets.xcassets in Resources */, - 496BDBEE2B8A7E9C00C09264 /* Localizable.xcstrings in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXShellScriptBuildPhase section */ - 499CD4452AC5B869001AE8D8 /* Run skip gradle */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - ); - name = "Run skip gradle"; - outputFileListPaths = ( - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = "/bin/sh -e"; - shellScript = "if [ \"${SKIP_ZERO}\" != \"\" ]; then\n echo \"note: skipping skip due to SKIP_ZERO\"\n exit 0\nelif [ \"${ENABLE_PREVIEWS}\" = \"YES\" ]; then\n echo \"note: skipping skip due to ENABLE_PREVIEWS\"\n exit 0\nelif [ \"${ACTION}\" = \"install\" ]; then\n echo \"note: skipping skip due to archive install\"\n exit 0\nelif [ \"${SKIP_ACTION}\" = \"none\" ]; then\n echo \"note: skipping skip due to SKIP_ACTION none\"\n exit 0\nelse\n SKIP_ACTION=\"${SKIP_ACTION:-launch}\"\nfi\nPATH=${BUILD_ROOT}/Release:${BUILD_ROOT}/Debug:${BUILD_ROOT}/../../SourcePackages/artifacts/skip/skip/skip.artifactbundle/macos:${PATH}:${HOMEBREW_PREFIX:-/opt/homebrew}/bin\necho \"note: running gradle build with: $(which skip) gradle -p ${PWD}/../Android ${SKIP_ACTION:-launch}${CONFIGURATION:-Debug}\"\nskip gradle -p ../Android ${SKIP_ACTION:-launch}${CONFIGURATION:-Debug}\n"; - }; -/* End PBXShellScriptBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - 499CD43A2AC5B799001AE8D8 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 499CD43B2AC5B799001AE8D8 /* Main.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin XCBuildConfiguration section */ - 499CD4422AC5B799001AE8D8 /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 496EB72F2A6AE4DE00C1253B /* PediFoods.xcconfig */; - buildSettings = { - ENABLE_PREVIEWS = YES; - LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; - "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; - }; - name = Debug; - }; - 499CD4432AC5B799001AE8D8 /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 496EB72F2A6AE4DE00C1253B /* PediFoods.xcconfig */; - buildSettings = { - ENABLE_PREVIEWS = YES; - LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; - "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; - }; - name = Release; - }; - 49F90C4B2A52156300F06D93 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; - MTL_FAST_MATH = YES; - ONLY_ACTIVE_ARCH = YES; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - }; - name = Debug; - }; - 49F90C4C2A52156300F06D93 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf-with-dsym; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MTL_ENABLE_DEBUG_INFO = NO; - MTL_FAST_MATH = YES; - SWIFT_COMPILATION_MODE = wholemodule; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 499CD4412AC5B799001AE8D8 /* Build configuration list for PBXNativeTarget "PediFoods App" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 499CD4422AC5B799001AE8D8 /* Debug */, - 499CD4432AC5B799001AE8D8 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 49F90C232A52156200F06D93 /* Build configuration list for PBXProject "PediFoods" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 49F90C4B2A52156300F06D93 /* Debug */, - 49F90C4C2A52156300F06D93 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - -/* Begin XCSwiftPackageProductDependency section */ - 491F27812DA55B72004926EE /* PediFoods */ = { - isa = XCSwiftPackageProductDependency; - productName = PediFoods; - }; -/* End XCSwiftPackageProductDependency section */ - }; - rootObject = 49F90C202A52156200F06D93 /* Project object */; -} diff --git a/pedi-foods/Darwin/PediFoods.xcodeproj/xcshareddata/xcschemes/PediFoods App.xcscheme b/pedi-foods/Darwin/PediFoods.xcodeproj/xcshareddata/xcschemes/PediFoods App.xcscheme deleted file mode 100644 index 8225350..0000000 --- a/pedi-foods/Darwin/PediFoods.xcodeproj/xcshareddata/xcschemes/PediFoods App.xcscheme +++ /dev/null @@ -1,78 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/pedi-foods/Darwin/Sources/Main.swift b/pedi-foods/Darwin/Sources/Main.swift deleted file mode 100644 index bbf005a..0000000 --- a/pedi-foods/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/pedi-foods/Darwin/fastlane/Appfile b/pedi-foods/Darwin/fastlane/Appfile deleted file mode 100644 index 7ca4d56..0000000 --- a/pedi-foods/Darwin/fastlane/Appfile +++ /dev/null @@ -1,8 +0,0 @@ -# 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']) - -# apple_id("my@email") diff --git a/pedi-foods/Darwin/fastlane/Fastfile b/pedi-foods/Darwin/fastlane/Fastfile deleted file mode 100644 index bb778d9..0000000 --- a/pedi-foods/Darwin/fastlane/Fastfile +++ /dev/null @@ -1,40 +0,0 @@ -# This file contains the fastlane.tools configuration -# for the iOS half of the Skip 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" - build_app( - scheme: "PediFoods App", - sdk: "iphoneos", - xcconfig: "fastlane/AppStore.xcconfig", - xcargs: "-skipPackagePluginValidation -skipMacroValidation", - derived_data_path: "../.build/Darwin/DerivedData", - output_directory: "../.build/fastlane/Darwin", - skip_archive: ENV["FASTLANE_SKIP_ARCHIVE"] == "YES", - skip_codesigning: ENV["FASTLANE_SKIP_CODESIGNING"] == "YES" - ) -end - -lane :release do |options| - desc "Build and release app" - - # see https://docs.fastlane.tools/uploading-app-privacy-details/ - #upload_app_privacy_details_to_app_store(json_path: "fastlane/app_privacy_details.json") - - # if you have an apikey.json file (https://developer.apple.com/documentation/appstoreconnectapi/creating-api-keys-for-app-store-connect-api), fastlane can automatically fetch certificates and the ASC authentication information - #get_certificates(api_key_path: "fastlane/apikey.json") - get_provisioning_profile(api_key_path: "fastlane/apikey.json") - - assemble - - upload_to_app_store( - api_key_path: "fastlane/apikey.json", - app_rating_config_path: "fastlane/metadata/rating.json", - release_notes: { default: "Fixes and improvements." } - ) -end - diff --git a/pedi-foods/Darwin/fastlane/metadata/en-US/support_url.txt b/pedi-foods/Darwin/fastlane/metadata/en-US/support_url.txt deleted file mode 100644 index fcfa1f7..0000000 --- a/pedi-foods/Darwin/fastlane/metadata/en-US/support_url.txt +++ /dev/null @@ -1 +0,0 @@ -https://example.org/support/ diff --git a/pedi-foods/Darwin/fastlane/metadata/en-US/title.txt b/pedi-foods/Darwin/fastlane/metadata/en-US/title.txt deleted file mode 100644 index 968e303..0000000 --- a/pedi-foods/Darwin/fastlane/metadata/en-US/title.txt +++ /dev/null @@ -1 +0,0 @@ -PediFoods diff --git a/pedi-foods/Package.swift b/pedi-foods/Package.swift deleted file mode 100644 index 678e73e..0000000 --- a/pedi-foods/Package.swift +++ /dev/null @@ -1,21 +0,0 @@ -// swift-tools-version: 6.1 -// This is a Skip (https://skip.tools) package. -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://source.skip.tools/skip.git", from: "1.7.0"), - .package(url: "https://source.skip.tools/skip-fuse-ui.git", from: "1.0.0") - ], - targets: [ - .target(name: "PediFoods", dependencies: [ - .product(name: "SkipFuseUI", package: "skip-fuse-ui") - ], resources: [.process("Resources")], plugins: [.plugin(name: "skipstone", package: "skip")]), - ] -) diff --git a/pedi-foods/README.md b/pedi-foods/README.md deleted file mode 100644 index 4581632..0000000 --- a/pedi-foods/README.md +++ /dev/null @@ -1,60 +0,0 @@ -# PediFoods - -This is a [Skip](https://skip.tools) dual-platform app project. - - - - - - -## 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. diff --git a/pedi-foods/Skip.env b/pedi-foods/Skip.env deleted file mode 100644 index 97f1b74..0000000 --- a/pedi-foods/Skip.env +++ /dev/null @@ -1,23 +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 diff --git a/pedi-foods/Sources/PediFoods/ContentView.swift b/pedi-foods/Sources/PediFoods/ContentView.swift deleted file mode 100644 index f7afb09..0000000 --- a/pedi-foods/Sources/PediFoods/ContentView.swift +++ /dev/null @@ -1,177 +0,0 @@ -import SwiftUI - -enum ContentTab: String, Hashable { - case welcome, home, settings -} - -struct ContentView: View { - @AppStorage("tab") var tab = ContentTab.welcome - @AppStorage("name") var welcomeName = "Skipper" - @AppStorage("appearance") var appearance = "" - @State var viewModel = ViewModel() - - var body: some View { - TabView(selection: $tab) { - NavigationStack { - WelcomeView(welcomeName: $welcomeName) - } - .tabItem { Label("Welcome", systemImage: "heart.fill") } - .tag(ContentTab.welcome) - - NavigationStack { - ItemListView() - .navigationTitle(Text("\(viewModel.items.count) Items")) - } - .tabItem { Label("Home", systemImage: "house.fill") } - .tag(ContentTab.home) - - NavigationStack { - SettingsView(appearance: $appearance, welcomeName: $welcomeName) - .navigationTitle("Settings") - } - .tabItem { Label("Settings", systemImage: "gearshape.fill") } - .tag(ContentTab.settings) - } - .environment(viewModel) - .preferredColorScheme(appearance == "dark" ? .dark : appearance == "light" ? .light : nil) - } -} - -struct WelcomeView : View { - @State var heartBeating = false - @Binding var welcomeName: String - - var body: some View { - VStack(spacing: 0) { - Text("Hello [\(welcomeName)](https://skip.tools)!") - .padding() - Image(systemName: "heart.fill") - .foregroundStyle(.red) - .scaleEffect(heartBeating ? 1.5 : 1.0) - .animation(.easeInOut(duration: 1).repeatForever(), value: heartBeating) - .task { heartBeating = true } - } - .font(.largeTitle) - } -} - -struct ItemListView : View { - @Environment(ViewModel.self) var viewModel: ViewModel - - var body: some View { - List { - ForEach(viewModel.items) { item in - NavigationLink(value: item) { - Label { - Text(item.itemTitle) - } icon: { - if item.favorite { - Image(systemName: "star.fill") - .foregroundStyle(.yellow) - } - } - } - } - .onDelete { offsets in - viewModel.items.remove(atOffsets: offsets) - } - .onMove { fromOffsets, toOffset in - viewModel.items.move(fromOffsets: fromOffsets, toOffset: toOffset) - } - } - .navigationDestination(for: Item.self) { item in - ItemView(item: item) - .navigationTitle(item.itemTitle) - } - .toolbar { - ToolbarItemGroup { - Button { - withAnimation { - viewModel.items.insert(Item(), at: 0) - } - } label: { - Label("Add", systemImage: "plus") - } - } - } - } -} - -struct ItemView : View { - @State var item: Item - @Environment(ViewModel.self) var viewModel: ViewModel - @Environment(\.dismiss) var dismiss - - var body: some View { - Form { - TextField("Title", text: $item.title) - .textFieldStyle(.roundedBorder) - Toggle("Favorite", isOn: $item.favorite) - DatePicker("Date", selection: $item.date) - Text("Notes").font(.title3) - TextEditor(text: $item.notes) - .border(Color.secondary, width: 1.0) - } - .navigationBarBackButtonHidden() - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - dismiss() - } - } - ToolbarItem(placement: .confirmationAction) { - Button("Save") { - viewModel.save(item: item) - dismiss() - } - .disabled(!viewModel.isUpdated(item)) - } - } - } -} - -struct SettingsView : View { - @Binding var appearance: String - @Binding var welcomeName: String - - var body: some View { - Form { - TextField("Name", text: $welcomeName) - Picker("Appearance", selection: $appearance) { - Text("System").tag("") - Text("Light").tag("light") - Text("Dark").tag("dark") - } - if let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String, - let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String { - Text("Version \(version) (\(buildNumber))") - } - HStack { - PlatformHeartView() - Text("Powered by [Skip](https://skip.tools)") - } - } - } -} - -/// A view that shows a blue heart on iOS and a green heart on Android. -struct PlatformHeartView : View { - var body: some View { - #if os(Android) - ComposeView { - HeartComposer() - } - #else - Text(verbatim: "💙") - #endif - } -} - -#if SKIP -/// Use a ContentComposer to integrate Compose content. This code will be transpiled to Kotlin. -struct HeartComposer : ContentComposer { - @Composable func Compose(context: ComposeContext) { - androidx.compose.material3.Text("💚", modifier: context.modifier) - } -} -#endif diff --git a/pedi-foods/Sources/PediFoods/PediFoodsApp.swift b/pedi-foods/Sources/PediFoods/PediFoodsApp.swift deleted file mode 100644 index 1d672a5..0000000 --- a/pedi-foods/Sources/PediFoods/PediFoodsApp.swift +++ /dev/null @@ -1,59 +0,0 @@ -import Foundation -import SkipFuse -import SwiftUI - -/// A logger for the PediFoods module. -let logger: Logger = Logger(subsystem: "com.br.pedifoods.app", category: "PediFoods") - -/// The shared top-level view for the app, loaded from the platform-specific App delegates below. -/// -/// The default implementation merely loads the `ContentView` for the app and logs a message. -/* SKIP @bridge */public struct PediFoodsRootView : View { - /* SKIP @bridge */public init() { - } - - public var body: some View { - ContentView() - .task { - logger.info("Skip app logs are viewable in the Xcode console for iOS; Android logs can be viewed in Studio or using adb logcat") - } - } -} - -/// Global application delegate functions. -/// -/// These functions can update a shared observable object to communicate app state changes to interested views. -/* SKIP @bridge */public final class PediFoodsAppDelegate : Sendable { - /* SKIP @bridge */public static let shared = PediFoodsAppDelegate() - - private init() { - } - - /* SKIP @bridge */public func onInit() { - logger.debug("onInit") - } - - /* SKIP @bridge */public func onLaunch() { - logger.debug("onLaunch") - } - - /* SKIP @bridge */public func onResume() { - logger.debug("onResume") - } - - /* SKIP @bridge */public func onPause() { - logger.debug("onPause") - } - - /* SKIP @bridge */public func onStop() { - logger.debug("onStop") - } - - /* SKIP @bridge */public func onDestroy() { - logger.debug("onDestroy") - } - - /* SKIP @bridge */public func onLowMemory() { - logger.debug("onLowMemory") - } -} diff --git a/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings b/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings deleted file mode 100644 index 779b2ac..0000000 --- a/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings +++ /dev/null @@ -1,563 +0,0 @@ -{ - "sourceLanguage" : "en", - "strings" : { - "%lld Items" : { - "comment" : "Header title for a list that contains the number of items", - "localizations" : { - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld elementos" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld éléments" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "アイテム数 %lld" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld 个条目" - } - } - } - }, - "Add" : { - "comment" : "Button in items list that will cause a new item to be added", - "localizations" : { - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Añadir" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ajouter" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "追加" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "添加" - } - } - } - }, - "Appearance" : { - "comment" : "Settings select label for the interface style of the controls (light, dark, or default)", - "localizations" : { - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Apariencia" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Apparence" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "外観" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "外观" - } - } - } - }, - "Cancel" : { - "comment" : "Button title indicating that the operation should be cancelled", - "localizations" : { - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cancelar" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Annuler" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "キャンセル" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "取消" - } - } - } - }, - "Dark" : { - "comment" : "Menu item indicating that the appearance should be in dark mode", - "localizations" : { - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Oscuro" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sombre" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ダーク" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "深色" - } - } - } - }, - "Date" : { - "comment" : "Item editor form label for the Date field", - "localizations" : { - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Fecha" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Date" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "日付" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "日期" - } - } - } - }, - "Favorite" : { - "comment" : "Item editor title label for marking the item as a favorite", - "localizations" : { - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Favorito" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Favori" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "お気に入り" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "收藏" - } - } - } - }, - "Hello [%@](https://skip.tools)!" : { - "comment" : "Welcome tab contents", - "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", - "localizations" : { - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Inicio" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Accueil" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ホーム" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "首页" - } - } - } - }, - "Light" : { - "comment" : "Menu item indicating that the appearance should be in light mode", - "localizations" : { - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Claro" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Clair" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "明るい" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "浅色" - } - } - } - }, - "Name" : { - "comment" : "Placeholder title for the Name field in a form", - "localizations" : { - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nombre" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nom" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "名前" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "姓名" - } - } - } - }, - "Notes" : { - "comment" : "Item editor form label for the Notes field", - "localizations" : { - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Notas" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Notes" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ノート" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "笔记" - } - } - } - }, - "Powered by [Skip](https://skip.tools)" : { - "comment" : "Link markdown text for the Powered by… label", - "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)提供支持" - } - } - } - }, - "Save" : { - "comment" : "Button title indicating that the current contents should be saved", - "localizations" : { - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Guardar" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Enregistrer" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "保存" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "保存" - } - } - } - }, - "Settings" : { - "comment" : "Tab bar item title for the Settings tab", - "localizations" : { - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Configuración" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Paramètres" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "設定" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "设置" - } - } - } - }, - "System" : { - "comment" : "Menu item indicating that the appearance should be in the default system mode", - "localizations" : { - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sistema" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Système" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "システム" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "系统" - } - } - } - }, - "Title" : { - "comment" : "Label for the item editor form indicating the title of the item", - "localizations" : { - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Título" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Titre" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "タイトル" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "标题" - } - } - } - }, - "Version %@ (%@)" : { - "comment" : "Settings label showing the current version of the app", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Version %1$@ (%2$@)" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Versión %1$@ (%2$@)" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Version %1$@ (%2$@)" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "バージョン %1$@ (%2$@)" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "版本 %1$@ (%2$@)" - } - } - } - }, - "Welcome" : { - "comment" : "Tab bar item title for the Welcome tab", - "localizations" : { - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bienvenido" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bienvenue" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ようこそ" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "欢迎" - } - } - } - } - }, - "version" : "1.0" -} diff --git a/pedi-foods/Sources/PediFoods/Resources/Module.xcassets/Contents.json b/pedi-foods/Sources/PediFoods/Resources/Module.xcassets/Contents.json deleted file mode 100644 index 73c0059..0000000 --- a/pedi-foods/Sources/PediFoods/Resources/Module.xcassets/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/pedi-foods/Sources/PediFoods/Skip/skip.yml b/pedi-foods/Sources/PediFoods/Skip/skip.yml deleted file mode 100644 index fdfcd15..0000000 --- a/pedi-foods/Sources/PediFoods/Skip/skip.yml +++ /dev/null @@ -1,12 +0,0 @@ -# Configuration file for https://skip.tools project -# -# Kotlin dependencies and Gradle build options for this module can be configured here -#build: -# contents: -# - block: 'dependencies' -# contents: -# - 'implementation("androidx.compose.runtime:runtime")' - -# this is a natively-compiled Skip Fuse module -skip: - mode: 'native' diff --git a/pedi-foods/Sources/PediFoods/ViewModel.swift b/pedi-foods/Sources/PediFoods/ViewModel.swift deleted file mode 100644 index bc213ea..0000000 --- a/pedi-foods/Sources/PediFoods/ViewModel.swift +++ /dev/null @@ -1,93 +0,0 @@ -import Foundation -import Observation -import SkipFuse - -/// The Observable ViewModel used by the application. -@Observable public class ViewModel { - var items: [Item] = loadItems() { - didSet { saveItems() } - } - - init() { - } - - func clear() { - items.removeAll() - } - - func isUpdated(_ item: Item) -> Bool { - item != items.first { i in - i.id == item.id - } - } - - func save(item: Item) { - items = items.map { i in - i.id == item.id ? item : i - } - } -} - -/// An individual item held by the ViewModel -struct Item : Identifiable, Hashable, Codable { - let id: UUID - var date: Date - var favorite: Bool - var title: String - var notes: String - - init(id: UUID = UUID(), date: Date = .now, favorite: Bool = false, title: String = "", notes: String = "") { - self.id = id - self.date = date - self.favorite = favorite - self.title = title - self.notes = notes - } - - var itemTitle: String { - !title.isEmpty ? title : dateString - } - - var dateString: String { - date.formatted(date: .complete, time: .omitted) - } - - var dateTimeString: String { - date.formatted(date: .abbreviated, time: .shortened) - } -} - -/// Utilities for defaulting and persising the items in the list -extension ViewModel { - private static let savePath = URL.applicationSupportDirectory.appendingPathComponent("appdata.json") - - fileprivate static func loadItems() -> [Item] { - do { - let start = Date.now - let data = try Data(contentsOf: savePath) - defer { - let end = Date.now - logger.info("loaded \(data.count) bytes from \(Self.savePath.path) in \(end.timeIntervalSince(start)) seconds") - } - return try JSONDecoder().decode([Item].self, from: data) - } catch { - // perhaps the first launch, or the data could not be read - logger.warning("failed to load data from \(Self.savePath), using defaultItems: \(error)") - let defaultItems = (1...365).map { Date(timeIntervalSinceNow: Double($0 * 60 * 60 * 24 * -1)) } - return defaultItems.map({ Item(date: $0) }) - } - } - - fileprivate func saveItems() { - do { - let start = Date.now - let data = try JSONEncoder().encode(items) - try FileManager.default.createDirectory(at: URL.applicationSupportDirectory, withIntermediateDirectories: true) - try data.write(to: Self.savePath) - let end = Date.now - logger.info("saved \(data.count) bytes to \(Self.savePath.path) in \(end.timeIntervalSince(start)) seconds") - } catch { - logger.error("error saving data: \(error)") - } - } -} diff --git a/pedifoods_app_plan.md b/pedifoods_app_plan.md deleted file mode 100644 index d502cad..0000000 --- a/pedifoods_app_plan.md +++ /dev/null @@ -1,82 +0,0 @@ -# Plano de implementacao do app PediFoods (Skip + SwiftUI) - -## Premissas de design e navegacao -- A tabbar e o centro de navegacao. Toda navegacao que nao for modal/over-context acontece dentro da tabbar. -- Login e cadastro ficam fora da tabbar. Ao logar/cadastrar, a Home abre ja com as tabs ativas. -- O verde da imagem `home_tabs.png` e o primary. O verde fluorescente sera usado raramente e fica como cor terciaria. -- Tipografia base: Plus Jakarta Sans. - -## Etapa 1 — Fundacao tecnica (obrigatoria antes das telas) -1. Criar o projeto Skip (Fuse) e estrutura base de pastas. -2. Configurar tokens do design system e tema (cores, tipografia, espacamentos, raios, sombras). -3. Implementar o service base de API: - - Camada HTTP - - Headers padrao - - Tokens por modulo (customer, store, resource) - - Tratamento de erros e rate limit - - Persistencia segura de JWT -4. Criar um coordinator de navegacao: - - Fluxo de autenticacao (login/cadastro/OTP) fora da tabbar - - Fluxo principal com tabbar (Home, Cart, Orders, Profile) - - Modais over-context (address, filtros, etc) -5. Configurar state global (sessao, perfil, cart, endereco ativo, favoritos). -6. Integrar permissions e GPS: - - Solicitar permissao ao cadastrar endereço - - Obter localizacao atual ao cadastrar endereço - - Cache de ultima localizacao valida - -## Etapa 2 — Autenticacao (fora da tabbar) -1. Tela Login -2. Tela Cadastro -3. Tela OTP (8 digitos) -4. Fluxo pos-login: - - Buscar perfil - - Abrir Home com tabbar ativa - -## Etapa 3 — Home e listagem de lojas (Tab Home) -1. Home com header, busca e categorias -2. Listagem de lojas (API /api/app/stores com lat/lng) -3. Filtros e busca -4. Favoritos - -## Etapa 4 — Store Detail e Catalogo -1. Tela de detalhes da loja -2. Catalogo por categorias -3. Produtos com addons -4. Favoritos de loja - -## Etapa 5 — Produto e carrinho -1. Tela de produto (detalhes + adicionais) -2. Carrinho (Tab Cart) -3. Atualizacao de quantidade e subtotal - -## Etapa 6 — Checkout e pagamento -1. Tela de checkout -2. Endereco de entrega -3. Metodos de pagamento (PIX, credito, debito, dinheiro) -4. Criacao de pedido -5. Fluxo de PIX (payload e QR) - -## Etapa 7 — Pedidos e rastreio (Tab Orders) -1. Lista de pedidos -2. Detalhe do pedido -3. Tracking de status - -## Etapa 8 — Perfil e enderecos (Tab Profile) -1. Perfil do usuario -2. Lista de enderecos -3. Criar/editar endereco - -## Etapa 9 — Mapa e GPS -1. Mapa iOS via MapKit -2. Mapa Android via ComposeView (Google Maps) -3. Pinos de loja e localizacao atual -4. Ajustes de permissao e fallback sem GPS - -## Etapa 10 — Polimento e release -1. Dark mode -2. Estados vazios e erros -3. Performance e cache -4. Build iOS e Android -5. Export APK/AAB - diff --git a/pedifoods_system_design.md b/pedifoods_system_design.md deleted file mode 100644 index 5b43f95..0000000 --- a/pedifoods_system_design.md +++ /dev/null @@ -1,224 +0,0 @@ -# 📦 Design System — Green Food Delivery App - -Este documento define o **design system oficial** do webapp de food delivery, baseado em uma identidade visual **fresh, health-focused e energética**, com forte uso de verde neon e contrastes suaves. - -Este arquivo deve ser usado como **prompt base canônico** para qualquer IA, designer ou desenvolvedor. - ---- - -## 1. FUNDAMENTOS VISUAIS - -### 1.1 🎨 Paleta de Cores - -#### Cores Principais - -```ts -colors: { - primary: "#8CFF2E", // ação principal, CTAs, destaque - secondary: "#388E3C", // ações secundárias, títulos - backgroundLight: "#F7F8F5", - backgroundDark: "#18230F", - textPrimary: "#333333", - textInverse: "#FFFFFF" -} -``` - -Uso: -- Primary → botões principais, ícones ativos, ratings -- Secondary → headers, botões secundários -- BackgroundLight → fundo global -- BackgroundDark → dark mode - ---- - -### 1.2 🌙 Dark Theme - -Dark mode nativo, não invertido. - -```ts -dark: { - background: "#18230F", - surface: "rgba(255,255,255,0.05)", - textPrimary: "#FFFFFF", - textSecondary: "#A3A3A3", - primary: "#8CFF2E", - secondary: "#388E3C" -} -``` - ---- - -### 1.3 🔤 Tipografia - -Fonte oficial: -- **Plus Jakarta Sans** - -```ts -typography: { - fontFamily: "'Plus Jakarta Sans', sans-serif", - - heading1: { - size: "30px", - weight: 800 - }, - - heading2: { - size: "22px", - weight: 800 - }, - - body: { - size: "16px", - weight: 400, - lineHeight: "1.6" - }, - - button: { - size: "14px", - weight: 800, - transform: "uppercase", - letterSpacing: "0.08em" - }, - - caption: { - size: "10px", - weight: 700, - letterSpacing: "0.12em" - } -} -``` - ---- - -### 1.4 📐 Bordas, Espaçamento e Elevação - -```ts -radius: { - md: "0.75rem", - lg: "1rem", - xl: "1.5rem", - full: "9999px" -} - -spacing: [4, 8, 12, 16, 24, 32] - -shadow: { - soft: "0 4px 20px -2px rgba(0,0,0,0.05)", - glow: "0 8px 24px rgba(140,255,46,0.2)" -} -``` - ---- - -## 2. COMPONENTES - -### 2.1 🔘 Botões - -#### Primary Action Button -- Fundo: primary -- Texto: secondary -- Altura: 56px -- Radius: lg -- Sombra glow -- Feedback de escala - -```ts -Button { - variant: "primary" | "secondary" - fullWidth: boolean -} -``` - ---- - -### 2.2 🔍 Input & Search - -- Fundo claro -- Ícone à esquerda -- Sem borda -- Focus ring em primary - -```ts -SearchInput { - placeholder: string - icon: "search" -} -``` - ---- - -### 2.3 🃏 Food Card - -Contém: -- Imagem hero -- Botão de favorito flutuante -- Nome -- Rating -- Tempo estimado - -```ts -FoodCard { - title: string - image: string - rating: number - timeRange: string - favorite: boolean -} -``` - ---- - -### 2.4 🧭 Navigation Bar - -- Fixa no rodapé -- Ícones Material Symbols -- Item ativo em primary - -```ts -TabBar { - items: TabItem[] - activeIndex: number -} -``` - ---- - -### 2.5 🧩 Ícones - -- Material Symbols Outlined -- Peso visual leve -- Cor semântica - -```ts -Icon { - name: string - active?: boolean -} -``` - ---- - -## 3. IMPLEMENTAÇÃO (TAILWIND-FIRST) - -### 3.1 Tokens no Tailwind - -```ts -theme.extend.colors = { - primary: "#8CFF2E", - secondary: "#388E3C", - backgroundLight: "#F7F8F5", - backgroundDark: "#18230F", - typographyDark: "#333333" -} -``` - ---- - -## 4. PRINCÍPIOS DO SISTEMA - -- Mobile-first -- Tailwind-first -- Dark mode por classe -- Tokens semânticos -- UI energética e saudável -- Feedback visual imediato diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..63e3eb5 --- /dev/null +++ b/project.yml @@ -0,0 +1,164 @@ +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: + CODE_SIGN_IDENTITY: "Apple Development" + "CODE_SIGN_IDENTITY[sdk=iphoneos*]": "iPhone Developer" + DEVELOPMENT_TEAM: "" + "DEVELOPMENT_TEAM[sdk=iphoneos*]": K4E5BZMM4V + PROVISIONING_PROFILE_SPECIFIER: "" + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]": "LC Prov PediFoods Dev Profile" + Release: + CODE_SIGN_IDENTITY: "Apple Development" + "CODE_SIGN_IDENTITY[sdk=iphoneos*]": "iPhone Distribution" + DEVELOPMENT_TEAM: "" + "DEVELOPMENT_TEAM[sdk=iphoneos*]": K4E5BZMM4V + PROVISIONING_PROFILE_SPECIFIER: "" + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]": "LC Prov PediFoods Dist Profile" + 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: Manual + SWIFT_VERSION: "6" + TARGETED_DEVICE_FAMILY: "1" + SKIP_INSTALL: YES + CLANG_ENABLE_OBJC_WEAK: NO + configs: + Debug: + "CODE_SIGN_IDENTITY[sdk=iphoneos*]": "iPhone Developer" + DEVELOPMENT_TEAM: "" + "DEVELOPMENT_TEAM[sdk=iphoneos*]": K4E5BZMM4V + PROVISIONING_PROFILE_SPECIFIER: "" + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]": "LC Prov PediFoods Dev Push Profile" + Release: + CODE_SIGN_IDENTITY: "Apple Development" + "CODE_SIGN_IDENTITY[sdk=iphoneos*]": "iPhone Distribution" + DEVELOPMENT_TEAM: "" + "DEVELOPMENT_TEAM[sdk=iphoneos*]": K4E5BZMM4V + PROVISIONING_PROFILE_SPECIFIER: "" + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]": "LC Prov PediFoods Dist Push Profile" + + PediFoodsTests: + type: bundle.unit-test + platform: iOS + sources: + - path: PediFoodsTests + dependencies: + - target: PediFoods + settings: + base: + SWIFT_VERSION: "6" + GENERATE_INFOPLIST_FILE: YES + CODE_SIGN_STYLE: Manual + configs: + Debug: + "CODE_SIGN_IDENTITY[sdk=iphoneos*]": "iPhone Developer" + "CODE_SIGN_IDENTITY[sdk=macosx*]": "Apple Development" + DEVELOPMENT_TEAM: "" + "DEVELOPMENT_TEAM[sdk=iphoneos*]": K4E5BZMM4V + "DEVELOPMENT_TEAM[sdk=macosx*]": K4E5BZMM4V + PROVISIONING_PROFILE_SPECIFIER: "" + Release: + "CODE_SIGN_IDENTITY[sdk=iphoneos*]": "Apple Distribution" + "CODE_SIGN_IDENTITY[sdk=macosx*]": "-" + DEVELOPMENT_TEAM: "" + "DEVELOPMENT_TEAM[sdk=iphoneos*]": K4E5BZMM4V + PROVISIONING_PROFILE_SPECIFIER: "" + + PediFoodsUITests: + type: bundle.ui-testing + platform: iOS + sources: + - path: PediFoodsUITests + dependencies: + - target: PediFoods + settings: + base: + SWIFT_VERSION: "6" + GENERATE_INFOPLIST_FILE: YES + DEVELOPMENT_TEAM: K4E5BZMM4V + +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/reference_img/cart.png b/reference_img/cart.png deleted file mode 100644 index cccfac5..0000000 Binary files a/reference_img/cart.png and /dev/null differ diff --git a/reference_img/cart_checkout.png b/reference_img/cart_checkout.png deleted file mode 100644 index 9016bde..0000000 Binary files a/reference_img/cart_checkout.png and /dev/null differ diff --git a/reference_img/category_all.png b/reference_img/category_all.png deleted file mode 100644 index 031c9bf..0000000 Binary files a/reference_img/category_all.png and /dev/null differ diff --git a/reference_img/create_account.png b/reference_img/create_account.png deleted file mode 100644 index d9e25ec..0000000 Binary files a/reference_img/create_account.png and /dev/null differ diff --git a/reference_img/cuppons.png b/reference_img/cuppons.png deleted file mode 100644 index 049f142..0000000 Binary files a/reference_img/cuppons.png and /dev/null differ diff --git a/reference_img/home_tabs.png b/reference_img/home_tabs.png deleted file mode 100644 index 77434b7..0000000 Binary files a/reference_img/home_tabs.png and /dev/null differ diff --git a/reference_img/login.png b/reference_img/login.png deleted file mode 100644 index 49f770c..0000000 Binary files a/reference_img/login.png and /dev/null differ diff --git a/reference_img/my_address.png b/reference_img/my_address.png deleted file mode 100644 index d86056e..0000000 Binary files a/reference_img/my_address.png and /dev/null differ diff --git a/reference_img/my_favorites.png b/reference_img/my_favorites.png deleted file mode 100644 index 809438d..0000000 Binary files a/reference_img/my_favorites.png and /dev/null differ diff --git a/reference_img/my_orders.png b/reference_img/my_orders.png deleted file mode 100644 index 4c08b86..0000000 Binary files a/reference_img/my_orders.png and /dev/null differ diff --git a/reference_img/my_orders_detail.png b/reference_img/my_orders_detail.png deleted file mode 100644 index 9964d74..0000000 Binary files a/reference_img/my_orders_detail.png and /dev/null differ diff --git a/reference_img/my_profile.png b/reference_img/my_profile.png deleted file mode 100644 index b70b325..0000000 Binary files a/reference_img/my_profile.png and /dev/null differ diff --git a/reference_img/my_reviews.png b/reference_img/my_reviews.png deleted file mode 100644 index 3d951bb..0000000 Binary files a/reference_img/my_reviews.png and /dev/null differ diff --git a/reference_img/my_reviews_detail.png b/reference_img/my_reviews_detail.png deleted file mode 100644 index 25c282a..0000000 Binary files a/reference_img/my_reviews_detail.png and /dev/null differ diff --git a/reference_img/notifications.png b/reference_img/notifications.png deleted file mode 100644 index 581478f..0000000 Binary files a/reference_img/notifications.png and /dev/null differ diff --git a/reference_img/otp_validation.png b/reference_img/otp_validation.png deleted file mode 100644 index a142079..0000000 Binary files a/reference_img/otp_validation.png and /dev/null differ diff --git a/reference_img/product_detail.png b/reference_img/product_detail.png deleted file mode 100644 index 2b38d7d..0000000 Binary files a/reference_img/product_detail.png and /dev/null differ diff --git a/reference_img/real_time_track_02.png b/reference_img/real_time_track_02.png deleted file mode 100644 index a308acf..0000000 Binary files a/reference_img/real_time_track_02.png and /dev/null differ diff --git a/reference_img/real_time_track_04.png b/reference_img/real_time_track_04.png deleted file mode 100644 index 08e3bf5..0000000 Binary files a/reference_img/real_time_track_04.png and /dev/null differ diff --git a/reference_img/reviewing.png b/reference_img/reviewing.png deleted file mode 100644 index 2e2be8f..0000000 Binary files a/reference_img/reviewing.png and /dev/null differ diff --git a/reference_img/search_filter.png b/reference_img/search_filter.png deleted file mode 100644 index 4275159..0000000 Binary files a/reference_img/search_filter.png and /dev/null differ diff --git a/reference_img/store_screen.png b/reference_img/store_screen.png deleted file mode 100644 index 229e69a..0000000 Binary files a/reference_img/store_screen.png and /dev/null differ diff --git a/reference_img/support.png b/reference_img/support.png deleted file mode 100644 index 107c88a..0000000 Binary files a/reference_img/support.png and /dev/null differ