diff --git a/API_Mobile_App.md b/API_Mobile_App.md index 1c4b175..3150af2 100644 --- a/API_Mobile_App.md +++ b/API_Mobile_App.md @@ -65,15 +65,19 @@ Used when interacting with a specific store (Catalog, Checkout). ### 2. Login (Get Token) **POST** `/api/customer/login` -**Body:** +**Body (email + phoneNumber are mandatory):** ```json { "email": "daniel@example.com", - "otp": "123456" // Em dev/demo, apenas email pode bastar se configurado assim + "phoneNumber": "+5511999999999" } ``` +**Fluxo:** +- Passo 1: enviar `email` + `phoneNumber` para receber código por email. +- Passo 2: enviar `email` + `phoneNumber` e o OTP no header `Authorization: Bearer `. + **Response:** ```json @@ -112,14 +116,16 @@ Used when interacting with a specific store (Catalog, Checkout). "phoneNumber": "+5511999999999", "favorites": ["store_abc...", "store_xyz..."], "address_book": [], - "behavioral_stats": { - "total_orders": 5, - "avg_ticket_size": 45.00 - } + "verified": true, + "biometricsEnabled": false, + "createdAt": "2026-01-21T22:37:56.472Z", + "updatedAt": "2026-02-13T19:00:00.000Z" } } ``` +**Campos internos não retornados para o app:** `keyHash`, `strongHash`, `source`, `isShadow`, `profile_metrics`, `behavioral_stats`, `otpHash`, `otpHashParams`, `otpExpiresAt`. + ### 4. Update Profile **POST** `/api/customer/:id` @@ -134,14 +140,56 @@ Used when interacting with a specific store (Catalog, Checkout). { "name": "Daniel A. Loverde", // Fields to update "phoneNumber": "+5511988888888", - "biometricsEnabled": true + "biometricsEnabled": true, + "address_book": [ + { + "label": "Casa", + "type": "residential", + "address": "Avenida das Andorinhas", + "number": "477", + "neighborhood": "Jardim Andorinhas", + "city": "Campinas", + "state": "SP", + "zipCode": "13101-400", + "country": "Brasil", + "complement": "Apto 78", + "lat_long": [-22.9064, -47.0616] + } + ] } ``` +**Regra de coordenadas (`address_book.lat_long`):** + +- Se existir `zipCode` válido e houver cache do CEP no Atomenta (`/api/public/cep/:cep`) para o mesmo usuário, a API valida o ponto enviado. +- Se não houver cache local, o Atomenta consulta a AwesomeAPI e atualiza o cache antes de validar. +- Se `lat_long` estiver ausente/inválido, a API preenche com as coordenadas do CEP cacheado. +- Se a distância entre `lat_long` enviado e o ponto do CEP for maior que `1.5 km`, a API substitui `lat_long` pelo ponto do CEP cacheado. +- Objetivo: evitar discrepâncias grandes entre endereço e coordenada salva no perfil. + --- ## 🏠 App Home Screen +### List Categories +**GET** `/api/public/categories` + +**Headers:** +- `Accept: application/json` + +**Response:** + +```json +{ + "error": false, + "result": [ + { "id": "all", "name": "Todas", "icon": "🏪" }, + { "id": "lanches", "name": "Lanches", "icon": "🍔" }, + { "id": "pizza", "name": "Pizza", "icon": "🍕" } + ] +} +``` + ### List Stores (Smart Listing) **GET** `/api/app/stores` @@ -241,7 +289,7 @@ Lists stores based on user location, filtered by distance. ### 2. Full Store Info **GET** `/api/store/:storeId/info` -Returns detailed info including operating hours and accepted payment methods. +Retorna versão pública para app (sem dados administrativos/financeiros da loja). **Response:** @@ -251,30 +299,29 @@ Returns detailed info including operating hours and accepted payment methods. "code": "STORE_INFO_RETRIEVED", "result": { "isOpen": true, + "statusLabel": "Aberto", + "fantasyName": "Mc Donalds", + "specialty": "Lanches", + "minOrder": 15, + "deliveryTime": "30-45 min", + "logo": "https://...", + "cover": "https://...", "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 - } - } + "acceptPix": true }, - "openingHours": { ... }, - "address": { ... } + "openingHours": { "...": "..." }, + "address": { + "street": "Rua X", + "neighborhood": "Centro", + "city": "Campinas", + "state": "SP", + "zipcode": "13000-000", + "latitude": -22.9, + "longitude": -47.0 + } } } ``` @@ -282,7 +329,7 @@ Returns detailed info including operating hours and accepted payment methods. ### 3. Product Catalog **GET** `/api/store/:storeId/catalog` -Returns categories with their respective products. +Retorna categorias com produtos, incluindo diferenciação explícita de pizza. **Response:** @@ -291,15 +338,45 @@ Returns categories with their respective products. "error": false, "result": [ { - "id": "cat_1...", - "name": "Burgers", + "id": "cat_lanches", + "name": "Lanches", + "isPizzaCategory": false, "products": [ { - "id": "prod_1...", - "name": "Big Mac", - "price": 25.90, - "image": "https://...", - "addonGroups": [...] + "id": "prod_xburger", + "type": "prepared", + "name": "X-Burger", + "description": "Pão, carne e queijo", + "image": "/uploads/products/xburger.jpg", + "price": "25.00", + "addons": "yes", + "addonGroups": [] + } + ] + }, + { + "id": "cat_pizzas", + "name": "Pizzas", + "isPizzaCategory": true, + "pizzaConfig": { + "sizes": [{ "id": "broto", "name": "Broto", "slices": 4, "maxFlavors": 1 }], + "doughs": [{ "id": "tradicional", "name": "Tradicional", "active": true }], + "crusts": [{ "id": "catupiry", "name": "Catupiry", "active": true, "priceModifier": "10.00" }] + }, + "products": [ + { + "id": "prod_pizza_calabresa", + "type": "pizza", + "name": "Pizza Calabresa", + "description": "Molho, mussarela e calabresa", + "image": "/uploads/products/pizza-calabresa.jpg", + "price": "0.00", + "pizzaPrices": { + "broto": "35.00", + "grande": "55.00" + }, + "addons": "yes", + "addonGroups": [] } ] } @@ -307,7 +384,59 @@ Returns categories with their respective products. } ``` -### 4. Create Order (Checkout) +**Como identificar pizza no app:** +- Produto pizza: `products[].type === "pizza"`. +- Configuração de montagem/preço por tamanho: `category.isPizzaCategory === true` + `category.pizzaConfig` + `product.pizzaPrices`. + +### 4. Validate Delivery Address (Checkout) +**POST** `/api/store/:storeId/delivery/validate-address` + +Use este endpoint ao trocar endereço na tela de pagamento para validar cobertura antes de finalizar. + +**Body:** + +```json +{ + "address": { + "street": "Avenida das Andorinhas", + "number": "477", + "neighborhood": "Jardim Andorinhas", + "city": "Campinas", + "state": "SP", + "zip": "13101-400", + "lat": -22.918, + "lng": -47.01813 + } +} +``` + +**Resposta Exemplo:** + +```json +{ + "error": false, + "code": "DELIVERY_ADDRESS_VALIDATED", + "result": { + "deliveryAllowed": true, + "reasonCode": "DELIVERY_ALLOWED", + "reasonMessage": "Endereço válido para entrega", + "deliveryMode": "KM", + "distance": 1.3, + "deliveryFee": 8, + "deliveryTime": "35 min", + "sameCity": false, + "matchedKmRange": { "from": 1, "to": 2, "price": 8, "time": 35 }, + "matchedNeighborhood": null + } +} +``` + +**Regras de validação:** +- `KM`: valida distância até o limite da última faixa + tolerância de `0.5 km`. +- `NEIGHBORHOOD`: reprova se cidade do endereço for diferente da cidade da loja. +- `NEIGHBORHOOD`: aprova se bairro configurado bater, ou se for mesma cidade (fallback). + +### 5. Create Order (Checkout) **POST** `/api/store/:storeId/orders` **Body:** @@ -327,9 +456,17 @@ Returns categories with their respective products. "items": [ { "productId": "prod_123", + "name": "X-Burger", "qty": 2, "price": 25.00, - "addons": [] + "addons": [ + { + "addonId": "addon_egg", + "name": "Ovo", + "price": 2.50, + "qty": 2 + } + ] } ], "total": 50.00, @@ -343,6 +480,12 @@ Returns categories with their respective products. } ``` +**Regras para `items[].addons`:** + +- `qty` do adicional é aceito no backend (ex.: `2 ovos`). +- Se `qty` não for enviado ou vier inválido, a API assume `1`. +- `qty` mínimo efetivo é `1`. + **Response:** ```json @@ -351,8 +494,291 @@ Returns categories with their respective products. "code": "ORDER_CREATED", "result": { "id": "ord_987...", - "status": "created", - "paymentPayload": "https://www.asaas.com/i/..." // Link para pagamento (Pix/Boleto) + "shortId": "1234", + "status": "PAYMENT_PENDING", + "paymentStatus": "PENDING", + "paymentMethod": "PIX", + "paymentPayload": { + "copyPaste": "000201010212...", + "qrCodeImage": "iVBORw0KGgoAAAANSUhEUgAA...", + "expirationDate": "2026-02-14T22:30:00.000Z" + }, + "payment": { + "method": "PIX", + "status": "PENDING", + "pix": { + "copyPaste": "000201010212...", + "qrCodeImage": "iVBORw0KGgoAAAANSUhEUgAA...", + "expirationDate": "2026-02-14T22:30:00.000Z" + } + } } } ``` + +**Observação de segurança:** o Atomenta não retorna mais links de checkout/fatura do Asaas no payload de criação do pedido. + +### 6. Realtime de Status do Pedido (Socket.IO) +Use este canal para acompanhar mudança de status do pedido em tempo real (sem depender de push notification). + +**Socket Endpoint:** +- `ws://:/socket.io/` (em produção, usar `wss://` se houver TLS no balanceador) + +**Auth no handshake:** +- Enviar o JWT do customer em `auth.token` (mesmo token do `Authorization: Bearer ...` das APIs HTTP). + +**Exemplo de conexão (conceitual):** + +```json +{ + "auth": { + "token": "Bearer " + } +} +``` + +**Eventos recebidos pelo app:** +- `order_update`: enviado quando o pedido muda (pagamento aprovado, aceito pela loja, saiu para entrega, concluído, cancelado). + +**Payload típico de `order_update`:** + +```json +{ + "id": "ord_987...", + "shortId": "1234", + "storeId": "store_123...", + "userId": "cust_uuid...", + "status": "CONFIRMED", + "paymentStatus": "CONFIRMED", + "updatedAt": "2026-02-15T20:10:00.000Z" +} +``` + +### 7. Fallback oficial (Polling) +Se o socket cair, o app deve continuar consultando o status até estado final. + +**Endpoint:** +- `GET /api/public/orders/:orderId` + +**Sugestão de estratégia:** +- primeiros 60s: a cada `3s` +- próximos 2min: a cada `5s` +- depois: a cada `10s` +- parar em status final (`COMPLETED`, `CANCELED`, `REFUNDED`) ou ao sair da tela. + +### 8. Catálogo Oficial de Tags de Review + +### 8.1 Matriz Completa de Endpoints (Reviews + Tags) + +**Público/App (Customer):** +- `GET /api/public/reviews/tags` → catálogo oficial de tags e regras de validação. +- `POST /api/public/orders/:orderId/review` → cria/atualiza avaliação do pedido concluído. +- `GET /api/public/store/:storeId/reviews` → lista reviews públicos da loja. + +**Store/Backoffice (analytics e operação):** +- `GET /api/store/:storeId/reviews` → listagem detalhada + métricas de reviews da loja. +- `POST /api/store/:storeId/reviews/:reviewId/reply` → resposta oficial da loja para avaliação. +- `POST /api/store/:storeId/reviews/:reviewId/dispute` → contestação da avaliação. +- `GET /api/store/reviews/app/nps` → NPS do app (geral/por filtros). +- `GET /api/store/reviews/app/nps/platform` → NPS por plataforma (`ios`, `android`, `web`). +- `GET /api/store/reviews/observability/overview` → funil técnico (sucesso/rejeição/erro). +- `GET /api/store/reviews/observability/alerts` → alertas operacionais (ex.: pico de reviews negativas). + +**Observação de base URL:** +- No app, consumir sempre via domínio/API oficial do ecossistema (PediFoods/Atomenta), mantendo o Atomenta como orquestrador. + +O app deve buscar este endpoint para renderizar as tags válidas e enviar somente os `id` retornados. + +**Endpoint:** +- `GET /api/public/reviews/tags` + +**Headers:** +- `Accept: application/json` + +**Response (resumo):** + +```json +{ + "error": false, + "result": { + "version": "2026-03-01", + "order": { + "positive": [{ "id": "flavor", "label": "Sabor" }], + "improvement": [{ "id": "wrong_items", "label": "Itens errados" }], + "rules": { + "positiveAllowedWhenRateGte": 5, + "improvementAllowedWhenRateLte": 4 + } + }, + "delivery": { + "sentiments": [ + { "id": "positive", "allowedTags": ["politeness", "on_time"] }, + { "id": "negative", "allowedTags": ["delay", "rude"] } + ], + "positive": [{ "id": "politeness", "label": "Educação" }], + "negative": [{ "id": "delay", "label": "Atraso" }] + }, + "app": { + "nps": { "min": 0, "max": 10 }, + "platforms": ["ios", "android", "web"] + } + } +} +``` + +**Regra de integração:** +- Não hardcodear tags no app; usar o catálogo do backend. +- Enviar no POST de review apenas os `id` retornados em cada bloco. + +### 9. Enviar Avaliação do Pedido +Permite o customer enviar review da loja a partir de um pedido finalizado. + +**Endpoint:** +- `POST /api/public/orders/:orderId/review` + +**Headers:** +- `Authorization: Bearer ` (obrigatório) +- `Accept: application/json` +- `Content-Type: application/json` + +**Body:** + +```json +{ + "orderRate": 5, + "orderComment": "Pedido chegou certinho e bem embalado.", + "orderPositiveTags": ["flavor", "temperature"], + "orderImprovementTags": [], + "deliverySentiment": "positive", + "deliveryPositiveTags": ["on_time", "politeness"], + "deliveryNegativeTags": [], + "appNps": 10, + "platform": "ios", + + "rate": 5, + "message": "Pedido chegou certinho e bem embalado." +} +``` + +**Regras de negócio atuais:** +- Só aceita review para pedido com status `COMPLETED`. +- O `orderId` deve existir. +- `storeId`, `userId`, `clientName` e `orderId` do review são derivados do pedido no backend (não enviar no body). +- Campos obrigatórios para integração nova: `orderRate`, `deliverySentiment`, `appNps`, `platform`. +- Compatibilidade: `rate/message` ainda são aceitos como fallback. +- App deve manter o rascunho local do formulário em caso de erro HTTP para permitir reenvio sem redigitar. + +**Response (sucesso):** + +```json +{ + "error": false, + "result": { + "id": "rev_abc123", + "storeId": "store_1772117366848_wmqw4", + "userId": "cust_uuid_001", + "clientName": "Customer 002", + "rate": 5, + "message": "Pedido chegou certinho e bem embalado.", + "orderRate": 5, + "orderComment": "Pedido chegou certinho e bem embalado.", + "deliveryFeedback": "positive", + "platform": "ios", + "editableUntil": "2026-03-06T17:15:00.000Z", + "storeReplyUntil": "2026-03-06T17:15:00.000Z", + "reviewWindowExpiresAt": "2026-03-06T16:10:00.000Z", + "orderId": "ffdc47b4-6a55-4713-aea9-45169e4cf0d7", + "date": "2026-03-01T17:15:00.000Z" + } +} +``` + +**Erros esperados:** +- `400` quando `orderId` não for enviado. +- `400` quando o pedido não existir ou ainda não estiver concluído (`Apenas pedidos concluídos podem ser avaliados`). +- `400` `REVIEW_WINDOW_EXPIRED` quando passou da janela inicial de 5 dias para criar review. +- `400` `REVIEW_EDIT_WINDOW_EXPIRED` quando tentar alterar review após 5 dias da criação. +- `400` `INVALID_REVIEW_PAYLOAD` / `INVALID_REVIEW_TAGS` / `INVALID_DELIVERY_SENTIMENT_TAGS` para payload inválido. +- `401` se o token JWT for inválido/ausente. +- `500` em falha interna (`Erro ao enviar avaliação`). + +**Exemplos de validação (cenários):** + +Exemplo válido (nota alta + entrega positiva): + +```json +{ + "orderRate": 5, + "orderComment": "Perfeito.", + "orderPositiveTags": ["flavor", "temperature"], + "orderImprovementTags": [], + "deliverySentiment": "positive", + "deliveryPositiveTags": ["on_time", "politeness"], + "deliveryNegativeTags": [], + "appNps": 10, + "platform": "android" +} +``` + +Exemplo inválido (nota 5 com `orderImprovementTags`): + +```json +{ + "orderRate": 5, + "orderImprovementTags": ["temperature"], + "deliverySentiment": "positive", + "appNps": 8, + "platform": "ios" +} +``` + +Retorno esperado: `400 INVALID_REVIEW_TAGS`. + +Exemplo inválido (entrega positiva com tags negativas): + +```json +{ + "orderRate": 4, + "orderImprovementTags": ["temperature"], + "deliverySentiment": "positive", + "deliveryNegativeTags": ["delay"], + "appNps": 7, + "platform": "web" +} +``` + +Retorno esperado: `400 INVALID_DELIVERY_SENTIMENT_TAGS`. + +### 10. Analytics de Reviews (Store/API) +Para dashboard da loja e análise de produto, usar: + +- `GET /api/store/:storeId/reviews` + - filtros opcionais: `startDate`, `endDate`, `minRate`, `maxRate`, `deliverySentiment`, `platform` + - retorno inclui métricas de estrelas, blocos de pedido/entrega, janelas (`editableUntil`, `storeReplyUntil`) e `appNps` agregado. + +- `POST /api/store/:storeId/reviews/:reviewId/reply` + - body obrigatório: `reply` (texto da resposta da loja). + - regra: respeita janela de resposta (`storeReplyUntil`). + +- `POST /api/store/:storeId/reviews/:reviewId/dispute` + - body obrigatório: `reason` (motivo da contestação). + +- `GET /api/store/reviews/app/nps` + - filtros opcionais: `startDate`, `endDate`, `storeId`, `platform` + - retorno: volume NPS, média e score. + +- `GET /api/store/reviews/app/nps/platform` + - filtros opcionais: `startDate`, `endDate`, `storeId` + - retorno: NPS separado em `ios`, `android`, `web`. + +- `GET /api/store/reviews/observability/overview` + - filtros opcionais: `startDate`, `endDate`, `storeId` + - retorno: tentativas, sucesso/rejeição/erro, taxa de sucesso e conversão por pedidos concluídos. + +- `GET /api/store/reviews/observability/alerts` + - filtro opcional: `storeId` + - retorno: alertas de pico, ex.: `NEGATIVE_REVIEWS_SPIKE`. + +**Compatibilidade Store:** +- O painel `/store/reviews` segue funcional com os campos legados (`rate`, `message`, `itemFeedback`, `improvementFeedback`, `deliveryFeedback`). +- Campos novos coexistem para evolução gradual sem quebrar o fluxo atual. diff --git a/API_Store_External.md b/API_Store_External.md index db13983..a7c8862 100644 --- a/API_Store_External.md +++ b/API_Store_External.md @@ -22,7 +22,6 @@ Todas as chamadas devem usar autenticação híbrida. **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, @@ -73,7 +72,6 @@ Todas as chamadas devem usar autenticação híbrida. **GET** `/api/store/:storeId/dashboard` **Resposta Detalhada (100% Parity):** - ```json { "error": false, @@ -104,9 +102,84 @@ Todas as chamadas devem usar autenticação híbrida. ## 🍴 Catálogo e Menu (Complex Payloads) -### 4. Categorias de Pizza -**POST** `/api/store/:storeId/catalog/categories` +### 3.1 Catálogo Consolidado (App) +**GET** `/api/store/:storeId/catalog` +**Resposta:** +```json +{ + "error": false, + "code": "STORE_CATALOG_RETRIEVED", + "result": [ + { + "id": "cat_lanches", + "name": "Lanches", + "isPizzaCategory": false, + "products": [ + { + "id": "prod_xburger", + "type": "prepared", + "name": "X-Burger", + "description": "Pão, carne e queijo", + "image": "/uploads/products/xburger.jpg", + "price": "25.00", + "addons": "yes", + "addonGroups": [] + } + ] + }, + { + "id": "cat_pizzas", + "name": "Pizzas", + "isPizzaCategory": true, + "pizzaConfig": { + "sizes": [{ "id": "broto", "name": "Broto", "slices": 4, "maxFlavors": 1 }], + "doughs": [{ "id": "tradicional", "name": "Tradicional", "active": true }], + "crusts": [{ "id": "catupiry", "name": "Catupiry", "active": true, "priceModifier": "10.00" }] + }, + "products": [ + { + "id": "prod_pizza_calabresa", + "type": "pizza", + "name": "Pizza Calabresa", + "description": "Molho, mussarela e calabresa", + "image": "/uploads/products/pizza-calabresa.jpg", + "price": "0.00", + "pizzaPrices": { + "broto": "35.00", + "grande": "55.00" + }, + "addons": "yes", + "addonGroups": [] + } + ] + } + ] +} +``` + +**Regra de identificação de pizza:** +- Item pizza: `products[].type === "pizza"`. +- Configuração de montagem vem em `category.pizzaConfig`. +- Preço por tamanho vem em `product.pizzaPrices`. + +### 4. Listar Categorias do Catálogo +**GET** `/api/store/:storeId/catalog/categories` + +**Resposta:** +```json +{ + "error": false, + "code": "CATEGORIES_RETRIEVED", + "result": [ + { "id": "cat_1", "name": "Pizzas", "isActive": true } + ], + "message": "Categorias recuperadas com sucesso" +} +``` + +### 5. Criar Categoria (Ex.: Pizza) +**POST** `/api/store/:storeId/catalog/categories` ```json { "name": "Pizzas Salgadas", @@ -122,9 +195,14 @@ Todas as chamadas devem usar autenticação híbrida. } ``` -### 5. Produtos Tipo Combo ou Pizza -**POST** `/api/store/:storeId/catalog/products` +### 6. Atualizar Categoria +**PUT** `/api/store/:storeId/catalog/categories/:id` +### 7. Excluir Categoria +**DELETE** `/api/store/:storeId/catalog/categories/:id` + +### 8. Produtos Tipo Combo ou Pizza +**POST** `/api/store/:storeId/catalog/products` ```json { "type": "pizza", @@ -148,11 +226,10 @@ Todas as chamadas devem usar autenticação híbrida. ## 📦 Histórico e Detalhes do Pedido -### 6. Histórico com Filtros +### 9. 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", @@ -181,7 +258,7 @@ Todas as chamadas devem usar autenticação híbrida. > 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 +### 10. Detalhes de um Pedido Individual **GET** `/api/store/:storeId/orders/:orderId` Retorna o objeto completo do pedido (parity 100% com o painel). @@ -190,7 +267,7 @@ Retorna o objeto completo do pedido (parity 100% com o painel). ## 💰 Financeiro Detalhado -### 7. Resumo de Performance e Gateway +### 11. Resumo de Performance e Gateway **GET** `/api/store/:storeId/financial/summary` **Resposta:** @@ -216,7 +293,7 @@ Retorna o objeto completo do pedido (parity 100% com o painel). ## ⭐ Reviews e Avaliações -### 8. Métricas e Respostas +### 12. Métricas e Respostas **GET** `/api/store/:storeId/reviews` **Resposta:** @@ -330,13 +407,58 @@ Endpoints utilizados pela página pública (CORS habilitado para domínios parce --- ## 🛒 Checkout e Criação de Pedido +### 10.0 Validar Endereço de Entrega (Checkout) +**POST** `/api/store/:storeId/delivery/validate-address` + +Endpoint para validar cobertura de entrega no checkout, quando o cliente troca o endereço antes do pagamento. + +**Payload:** +```json +{ + "address": { + "street": "Avenida das Andorinhas", + "number": "477", + "neighborhood": "Jardim Andorinhas", + "city": "Campinas", + "state": "SP", + "zip": "13101-400", + "lat": -22.918, + "lng": -47.01813 + } +} +``` + +**Resposta Exemplo:** +```json +{ + "error": false, + "code": "DELIVERY_ADDRESS_VALIDATED", + "result": { + "deliveryAllowed": true, + "reasonCode": "DELIVERY_ALLOWED", + "reasonMessage": "Endereço válido para entrega", + "deliveryMode": "KM", + "distance": 1.3, + "deliveryFee": 8, + "deliveryTime": "35 min", + "sameCity": false, + "matchedKmRange": { "from": 1, "to": 2, "price": 8, "time": 35 }, + "matchedNeighborhood": null + } +} +``` + +**Regras:** +- `KM`: aprova somente até o limite da última faixa + `0.5 km` de tolerância. +- `NEIGHBORHOOD`: reprova se a cidade do endereço for diferente da cidade da loja. +- `NEIGHBORHOOD`: aprova com bairro configurado ou com fallback de mesma cidade. + ### 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", @@ -367,23 +489,152 @@ Este endpoint é utilizado pelo front-end de checkout (Pedi Foods) para criar o "name": "Pizza", "price": 50.00, "qty": 1, - "image": "..." + "image": "...", + "addons": [ + { + "addonId": "addon_bacon", + "name": "Bacon", + "price": 4.00, + "qty": 2 + } + ] } ] } ``` -**Resposta Sucesso:** +**Regras para `items[].addons`:** +- `qty` do adicional é suportado (ex.: 2x bacon/ovo). +- Se `qty` não for enviado ou vier inválido, a API assume `1`. +- `qty` mínimo efetivo é `1`. +**Resposta Sucesso:** ```json { "error": false, "result": { "id": "order_guid", "shortId": "1234", - "status": "PENDING", + "status": "PAYMENT_PENDING", "paymentStatus": "PENDING", - "paymentPayload": "..." // Se PIX (Big String Base64 ou Copia e Cola) + "paymentMethod": "PIX", + "paymentPayload": { + "copyPaste": "000201010212...", + "qrCodeImage": "iVBORw0KGgoAAAANSUhEUgAA...", + "expirationDate": "2026-02-14T22:30:00.000Z" + }, + "payment": { + "method": "PIX", + "status": "PENDING", + "pix": { + "copyPaste": "000201010212...", + "qrCodeImage": "iVBORw0KGgoAAAANSUhEUgAA...", + "expirationDate": "2026-02-14T22:30:00.000Z" + } + } } } ``` + +**Observação de segurança:** o Atomenta não retorna links de checkout/fatura do Asaas no response de criação de pedido. + +### 10.1 Realtime de Status de Pedido (Socket.IO) +Para atualização em tempo real do checkout/app (sem depender de push), usar Socket.IO. + +**Socket endpoint:** +- `ws://:/socket.io/` (usar `wss://` em produção com TLS) + +**Auth no handshake:** +- Enviar JWT no campo `auth.token`. +- Exemplo: `Bearer `. + +**Evento para o cliente:** +- `order_update` + +**Payload típico do evento:** +```json +{ + "id": "order_guid", + "shortId": "1234", + "storeId": "store_123456", + "userId": "user_guid", + "status": "CONFIRMED", + "paymentStatus": "CONFIRMED", + "updatedAt": "2026-02-15T20:10:00.000Z" +} +``` + +### 10.2 Fallback de Status (Polling) +Caso não haja conexão socket, consultar status por HTTP até estado final. + +**Endpoint:** +- `GET /api/public/orders/:orderId` + +**Recomendação de intervalo:** +- 0-60s: `3s` +- 60-180s: `5s` +- acima de 180s: `10s` +- parar em status final (`COMPLETED`, `CANCELED`, `REFUNDED`). + +### 11. Listar Lojas (Public App) +**GET** `/api/public/stores` + +Endpoint utilizado pelo aplicativo Pedi Foods para listar todas as lojas disponíveis para o cliente. + +**Resposta Sucesso:** +```json +{ + "error": false, + "result": [ + { + "id": "company_guid", + "storeId": "store_123456", + "name": "Pizzaria do Arantes", + "logo": "https://...", + "cover": "https://...", + "category": "Restaurante", + "isOpen": true, + "statusLabel": "Aberto Agora", + "rating": 4.8, + "deliveryTime": "30-45 min", + "deliveryFee": 5.00 + } + ] +} +``` + +--- + +## 📝 Gestão de Contratos (Store) + +### 12. Verificar Status do Contrato +**GET** `/api/store/:storeId/contract/status` +Retorna o ID e o status do contrato mais recente da loja. + +**Resposta Exemplo:** +```json +{ + "error": false, + "result": { + "id": "contract_guid", + "status": "draft" // ou "signed" + } +} +``` + +### 13. Assinar Contrato +**POST** `/api/store/:storeId/contract/sign` +Assina o contrato draft atual da loja. + +**Payload:** +```json +{ + "method": "typed", + "typedName": "João Silva", // Obrigatório se method === "typed" + "signatureDataUrl": "data:image/png;base64,..." // Obrigatório se method === "draw" +} +``` + +### 14. Visualizar PDF do Contrato +**GET** `/api/store/contract/:contractId/pdf` +Retorna o stream do arquivo PDF do contrato (draft ou assinado). diff --git a/logo/icon_pix.png b/logo/icon_pix.png new file mode 100644 index 0000000..d3d4824 Binary files /dev/null and b/logo/icon_pix.png differ diff --git a/logo/pedifoods.png b/logo/pedifoods.png new file mode 100644 index 0000000..45bec47 Binary files /dev/null and b/logo/pedifoods.png differ diff --git a/logo/pedifoods_dark.png b/logo/pedifoods_dark.png new file mode 100644 index 0000000..c94bd7e Binary files /dev/null and b/logo/pedifoods_dark.png differ diff --git a/pedi-foods/.gitea/workflows/ci.yml b/pedi-foods/.gitea/workflows/ci.yml new file mode 100644 index 0000000..a5f7ca7 --- /dev/null +++ b/pedi-foods/.gitea/workflows/ci.yml @@ -0,0 +1,45 @@ +name: CI + +on: + push: + branches: + - main + - master + - develop + - 'release/**' + workflow_dispatch: + +jobs: + build-and-test: + name: Build and Unit Tests + runs-on: + - self-hosted + - macos + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure private dependencies (optional) + if: ${{ secrets.GIT_LOVERDE_TOKEN != '' }} + shell: bash + run: | + git config --global url."https://oauth2:${{ secrets.GIT_LOVERDE_TOKEN }}@git.loverde.com.br/".insteadOf "https://git.loverde.com.br/" + + - name: Toolchain info + shell: bash + run: | + swift --version + xcodebuild -version || true + + - name: Resolve packages + shell: bash + run: swift package resolve + + - name: Build (with tests) + shell: bash + run: swift build --build-tests + + - name: Run unit tests + shell: bash + run: swift test --parallel diff --git a/pedi-foods/.gitea/workflows/pr-validation.yml b/pedi-foods/.gitea/workflows/pr-validation.yml new file mode 100644 index 0000000..d884c33 --- /dev/null +++ b/pedi-foods/.gitea/workflows/pr-validation.yml @@ -0,0 +1,42 @@ +name: PR Validation + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + workflow_dispatch: + +jobs: + pr-checks: + name: PR Build and Unit Tests + if: ${{ github.event.pull_request.draft == false || github.event_name == 'workflow_dispatch' }} + runs-on: + - self-hosted + - macos + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure private dependencies (optional) + if: ${{ secrets.GIT_LOVERDE_TOKEN != '' }} + shell: bash + run: | + git config --global url."https://oauth2:${{ secrets.GIT_LOVERDE_TOKEN }}@git.loverde.com.br/".insteadOf "https://git.loverde.com.br/" + + - name: Toolchain info + shell: bash + run: | + swift --version + xcodebuild -version || true + + - name: Resolve packages + shell: bash + run: swift package resolve + + - name: Build (with tests) + shell: bash + run: swift build --build-tests + + - name: Run unit tests + shell: bash + run: swift test --parallel diff --git a/pedi-foods/Android/app/src/main/AndroidManifest.xml b/pedi-foods/Android/app/src/main/AndroidManifest.xml index c6c7049..2253f6f 100644 --- a/pedi-foods/Android/app/src/main/AndroidManifest.xml +++ b/pedi-foods/Android/app/src/main/AndroidManifest.xml @@ -1,9 +1,8 @@ - - - + + diff --git a/pedi-foods/Android/app/src/main/kotlin/Main.kt b/pedi-foods/Android/app/src/main/kotlin/Main.kt index c2648b9..517e668 100644 --- a/pedi-foods/Android/app/src/main/kotlin/Main.kt +++ b/pedi-foods/Android/app/src/main/kotlin/Main.kt @@ -8,6 +8,9 @@ import skip.ui.* import android.Manifest import android.app.Application import android.graphics.Color as AndroidColor +import android.content.pm.PackageManager +import android.location.Location +import android.location.LocationManager import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.SystemBarStyle @@ -26,6 +29,7 @@ import androidx.compose.ui.graphics.luminance import androidx.compose.ui.platform.LocalContext import androidx.compose.material3.MaterialTheme import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat internal val logger: SkipLogger = SkipLogger(subsystem = "pedi.foods", category = "PediFoods") @@ -50,6 +54,11 @@ open class AndroidAppMain: Application { /// AndroidAppMain is initial `androidx.appcompat.app.AppCompatActivity`, and must match `activity android:name` in the AndroidMainfest.xml file. open class MainActivity: AppCompatActivity { + private val locationRequestCode = 4201 + private val locationPermissionDeniedKey = "location_permission_denied" + private val locationLatitudeKey = "last_location_lat" + private val locationLongitudeKey = "last_location_lng" + constructor() { } @@ -68,17 +77,7 @@ open class MainActivity: AppCompatActivity { } 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) + requestLocationPermissionsIfNeeded() } override fun onStart() { @@ -127,6 +126,65 @@ open class MainActivity: AppCompatActivity { override fun onRequestPermissionsResult(requestCode: Int, permissions: kotlin.Array, grantResults: IntArray) { super.onRequestPermissionsResult(requestCode, permissions, grantResults) logger.info("onRequestPermissionsResult: ${requestCode}") + if (requestCode == locationRequestCode) { + val granted = grantResults.isNotEmpty() && grantResults.all { it == PackageManager.PERMISSION_GRANTED } + val prefs = getSharedPreferences("${packageName}_preferences", MODE_PRIVATE) + prefs.edit().putBoolean(locationPermissionDeniedKey, !granted).apply() + if (granted) { + cacheLastKnownLocation() + } + } + } + + private fun requestLocationPermissionsIfNeeded() { + val fine = ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) + val coarse = ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) + val granted = fine == PackageManager.PERMISSION_GRANTED || coarse == PackageManager.PERMISSION_GRANTED + if (granted) { + val prefs = getSharedPreferences("${packageName}_preferences", MODE_PRIVATE) + prefs.edit().putBoolean(locationPermissionDeniedKey, false).apply() + cacheLastKnownLocation() + return + } + + ActivityCompat.requestPermissions( + this, + kotlin.arrayOf(Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION), + locationRequestCode + ) + } + + private fun cacheLastKnownLocation() { + val manager = getSystemService(LOCATION_SERVICE) as? LocationManager ?: return + val fineGranted = ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED + val coarseGranted = ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED + if (!fineGranted && !coarseGranted) { + return + } + + val providers = listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER) + var best: Location? = null + for (provider in providers) { + val candidate = try { + manager.getLastKnownLocation(provider) + } catch (_: SecurityException) { + null + } catch (_: Exception) { + null + } + if (candidate != null && (best == null || candidate.accuracy < best!!.accuracy)) { + best = candidate + } + } + + if (best != null) { + val prefs = getSharedPreferences("${packageName}_preferences", MODE_PRIVATE) + prefs.edit() + .putString(locationLatitudeKey, best.latitude.toString()) + .putString(locationLongitudeKey, best.longitude.toString()) + .putBoolean(locationPermissionDeniedKey, false) + .apply() + } } companion object { 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 index ff862e7..6dae27f 100644 Binary files a/pedi-foods/Android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/pedi-foods/Android/app/src/main/res/mipmap-hdpi/ic_launcher.png 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 index 0eeb46e..6dae27f 100644 Binary files a/pedi-foods/Android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png and b/pedi-foods/Android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png 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 index 3dac36a..b253f0b 100644 Binary files a/pedi-foods/Android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/pedi-foods/Android/app/src/main/res/mipmap-mdpi/ic_launcher.png 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 index 3cf8fb1..b253f0b 100644 Binary files a/pedi-foods/Android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png and b/pedi-foods/Android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png 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 index c9d77af..8d68de2 100644 Binary files a/pedi-foods/Android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/pedi-foods/Android/app/src/main/res/mipmap-xhdpi/ic_launcher.png 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 index beb10dc..8d68de2 100644 Binary files a/pedi-foods/Android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png and b/pedi-foods/Android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png 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 index b0bd6f9..19f92aa 100644 Binary files a/pedi-foods/Android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/pedi-foods/Android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png 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 index 7625d77..19f92aa 100644 Binary files a/pedi-foods/Android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png and b/pedi-foods/Android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png 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 index d80e6f6..4b9563a 100644 Binary files a/pedi-foods/Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/pedi-foods/Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png 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 index a0175d9..4b9563a 100644 Binary files a/pedi-foods/Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png and b/pedi-foods/Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/pedi-foods/Android/gradle.properties b/pedi-foods/Android/gradle.properties index 1b8d060..a175fe4 100644 --- a/pedi-foods/Android/gradle.properties +++ b/pedi-foods/Android/gradle.properties @@ -1,3 +1,6 @@ -org.gradle.jvmargs=-Xmx4g +org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 +org.gradle.workers.max=2 +org.gradle.parallel=false +kotlin.daemon.jvmargs=-Xmx1g android.useAndroidX=true kotlin.code.style=official 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 index 611a2bb..6035446 100644 --- a/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,122 +1,116 @@ { - "images" : [ + "images": [ { - "filename" : "AppIcon-20@2x.png", - "idiom" : "iphone", - "scale" : "2x", - "size" : "20x20" + "idiom": "iphone", + "size": "20x20", + "scale": "2x", + "filename": "icon_iphone_20pt@2x.png" }, { - "filename" : "AppIcon-20@3x.png", - "idiom" : "iphone", - "scale" : "3x", - "size" : "20x20" + "idiom": "iphone", + "size": "20x20", + "scale": "3x", + "filename": "icon_iphone_20pt@3x.png" }, { - "filename" : "AppIcon-29.png", - "idiom" : "iphone", - "scale" : "1x", - "size" : "29x29" + "idiom": "iphone", + "size": "29x29", + "scale": "2x", + "filename": "icon_iphone_29pt@2x.png" }, { - "filename" : "AppIcon-29@2x.png", - "idiom" : "iphone", - "scale" : "2x", - "size" : "29x29" + "idiom": "iphone", + "size": "29x29", + "scale": "3x", + "filename": "icon_iphone_29pt@3x.png" }, { - "filename" : "AppIcon-29@3x.png", - "idiom" : "iphone", - "scale" : "3x", - "size" : "29x29" + "idiom": "iphone", + "size": "40x40", + "scale": "2x", + "filename": "icon_iphone_40pt@2x.png" }, { - "filename" : "AppIcon-40@2x.png", - "idiom" : "iphone", - "scale" : "2x", - "size" : "40x40" + "idiom": "iphone", + "size": "40x40", + "scale": "3x", + "filename": "icon_iphone_40pt@3x.png" }, { - "filename" : "AppIcon-40@3x.png", - "idiom" : "iphone", - "scale" : "3x", - "size" : "40x40" + "idiom": "iphone", + "size": "60x60", + "scale": "2x", + "filename": "icon_iphone_60pt@2x.png" }, { - "filename" : "AppIcon@2x.png", - "idiom" : "iphone", - "scale" : "2x", - "size" : "60x60" + "idiom": "iphone", + "size": "60x60", + "scale": "3x", + "filename": "icon_iphone_60pt@3x.png" }, { - "filename" : "AppIcon@3x.png", - "idiom" : "iphone", - "scale" : "3x", - "size" : "60x60" + "idiom": "ipad", + "size": "20x20", + "scale": "1x", + "filename": "icon_ipad_20pt@1x.png" }, { - "filename" : "AppIcon-20~ipad.png", - "idiom" : "ipad", - "scale" : "1x", - "size" : "20x20" + "idiom": "ipad", + "size": "20x20", + "scale": "2x", + "filename": "icon_ipad_20pt@2x.png" }, { - "filename" : "AppIcon-20@2x~ipad.png", - "idiom" : "ipad", - "scale" : "2x", - "size" : "20x20" + "idiom": "ipad", + "size": "29x29", + "scale": "1x", + "filename": "icon_ipad_29pt@1x.png" }, { - "filename" : "AppIcon-29~ipad.png", - "idiom" : "ipad", - "scale" : "1x", - "size" : "29x29" + "idiom": "ipad", + "size": "29x29", + "scale": "2x", + "filename": "icon_ipad_29pt@2x.png" }, { - "filename" : "AppIcon-29@2x~ipad.png", - "idiom" : "ipad", - "scale" : "2x", - "size" : "29x29" + "idiom": "ipad", + "size": "40x40", + "scale": "1x", + "filename": "icon_ipad_40pt@1x.png" }, { - "filename" : "AppIcon-40~ipad.png", - "idiom" : "ipad", - "scale" : "1x", - "size" : "40x40" + "idiom": "ipad", + "size": "40x40", + "scale": "2x", + "filename": "icon_ipad_40pt@2x.png" }, { - "filename" : "AppIcon-40@2x~ipad.png", - "idiom" : "ipad", - "scale" : "2x", - "size" : "40x40" + "idiom": "ipad", + "size": "76x76", + "scale": "1x", + "filename": "icon_ipad_76pt@1x.png" }, { - "filename" : "AppIcon~ipad.png", - "idiom" : "ipad", - "scale" : "1x", - "size" : "76x76" + "idiom": "ipad", + "size": "76x76", + "scale": "2x", + "filename": "icon_ipad_76pt@2x.png" }, { - "filename" : "AppIcon@2x~ipad.png", - "idiom" : "ipad", - "scale" : "2x", - "size" : "76x76" + "idiom": "ipad", + "size": "83.5x83.5", + "scale": "2x", + "filename": "icon_ipad_83.5pt@2x.png" }, { - "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" + "idiom": "ios-marketing", + "size": "1024x1024", + "scale": "1x", + "filename": "icon_ios-marketing_1024pt@1x.png" } ], - "info" : { - "author" : "xcode", - "version" : 1 + "info": { + "version": 1, + "author": "xcode" } -} +} \ No newline at end of file diff --git a/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/icon_ios-marketing_1024pt@1x.png b/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/icon_ios-marketing_1024pt@1x.png new file mode 100644 index 0000000..2729adc Binary files /dev/null and b/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/icon_ios-marketing_1024pt@1x.png differ diff --git a/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/icon_ipad_20pt@1x.png b/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/icon_ipad_20pt@1x.png new file mode 100644 index 0000000..679e384 Binary files /dev/null and b/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/icon_ipad_20pt@1x.png differ diff --git a/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/icon_ipad_20pt@2x.png b/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/icon_ipad_20pt@2x.png new file mode 100644 index 0000000..b92757d Binary files /dev/null and b/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/icon_ipad_20pt@2x.png differ diff --git a/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/icon_ipad_29pt@1x.png b/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/icon_ipad_29pt@1x.png new file mode 100644 index 0000000..7ffb69f Binary files /dev/null and b/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/icon_ipad_29pt@1x.png differ diff --git a/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/icon_ipad_29pt@2x.png b/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/icon_ipad_29pt@2x.png new file mode 100644 index 0000000..bcf419f Binary files /dev/null and b/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/icon_ipad_29pt@2x.png differ diff --git a/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/icon_ipad_40pt@1x.png b/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/icon_ipad_40pt@1x.png new file mode 100644 index 0000000..b92757d Binary files /dev/null and b/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/icon_ipad_40pt@1x.png differ diff --git a/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/icon_ipad_40pt@2x.png b/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/icon_ipad_40pt@2x.png new file mode 100644 index 0000000..75c60b1 Binary files /dev/null and b/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/icon_ipad_40pt@2x.png differ diff --git a/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/icon_ipad_76pt@1x.png b/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/icon_ipad_76pt@1x.png new file mode 100644 index 0000000..e0e1cb7 Binary files /dev/null and b/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/icon_ipad_76pt@1x.png differ diff --git a/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/icon_ipad_76pt@2x.png b/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/icon_ipad_76pt@2x.png new file mode 100644 index 0000000..f81c408 Binary files /dev/null and b/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/icon_ipad_76pt@2x.png differ diff --git a/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/icon_ipad_83.5pt@2x.png b/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/icon_ipad_83.5pt@2x.png new file mode 100644 index 0000000..1d956de Binary files /dev/null and b/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/icon_ipad_83.5pt@2x.png differ diff --git a/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/icon_iphone_20pt@2x.png b/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/icon_iphone_20pt@2x.png new file mode 100644 index 0000000..b92757d Binary files /dev/null and b/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/icon_iphone_20pt@2x.png differ diff --git a/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/icon_iphone_20pt@3x.png b/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/icon_iphone_20pt@3x.png new file mode 100644 index 0000000..40d5a85 Binary files /dev/null and b/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/icon_iphone_20pt@3x.png differ diff --git a/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/icon_iphone_29pt@2x.png b/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/icon_iphone_29pt@2x.png new file mode 100644 index 0000000..bcf419f Binary files /dev/null and b/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/icon_iphone_29pt@2x.png differ diff --git a/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/icon_iphone_29pt@3x.png b/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/icon_iphone_29pt@3x.png new file mode 100644 index 0000000..ecf3804 Binary files /dev/null and b/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/icon_iphone_29pt@3x.png differ diff --git a/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/icon_iphone_40pt@2x.png b/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/icon_iphone_40pt@2x.png new file mode 100644 index 0000000..75c60b1 Binary files /dev/null and b/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/icon_iphone_40pt@2x.png differ diff --git a/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/icon_iphone_40pt@3x.png b/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/icon_iphone_40pt@3x.png new file mode 100644 index 0000000..2cf0dcc Binary files /dev/null and b/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/icon_iphone_40pt@3x.png differ diff --git a/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/icon_iphone_60pt@2x.png b/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/icon_iphone_60pt@2x.png new file mode 100644 index 0000000..2cf0dcc Binary files /dev/null and b/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/icon_iphone_60pt@2x.png differ diff --git a/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/icon_iphone_60pt@3x.png b/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/icon_iphone_60pt@3x.png new file mode 100644 index 0000000..720b6d1 Binary files /dev/null and b/pedi-foods/Darwin/Assets.xcassets/AppIcon.appiconset/icon_iphone_60pt@3x.png differ diff --git a/pedi-foods/Darwin/Assets.xcassets/LaunchBackground.colorset/Contents.json b/pedi-foods/Darwin/Assets.xcassets/LaunchBackground.colorset/Contents.json new file mode 100644 index 0000000..b7320fe --- /dev/null +++ b/pedi-foods/Darwin/Assets.xcassets/LaunchBackground.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "idiom" : "universal", + "color" : { + "color-space" : "srgb", + "components" : { + "red" : "0.9529411764705882", + "green" : "0.9607843137254902", + "blue" : "0.9686274509803922", + "alpha" : "1.0000000000000000" + } + } + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/pedi-foods/Darwin/Assets.xcassets/login_bg.imageset/Contents.json b/pedi-foods/Darwin/Assets.xcassets/login_bg.imageset/Contents.json new file mode 100644 index 0000000..6df8882 --- /dev/null +++ b/pedi-foods/Darwin/Assets.xcassets/login_bg.imageset/Contents.json @@ -0,0 +1,52 @@ +{ + "images": [ + { + "idiom": "universal", + "filename": "login_light.png", + "scale": "1x" + }, + { + "idiom": "universal", + "scale": "2x" + }, + { + "idiom": "universal", + "scale": "3x" + }, + { + "idiom": "universal", + "filename": "login_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/pedi-foods/Darwin/Assets.xcassets/login_bg.imageset/login_dark.png b/pedi-foods/Darwin/Assets.xcassets/login_bg.imageset/login_dark.png new file mode 100644 index 0000000..bf637d4 Binary files /dev/null and b/pedi-foods/Darwin/Assets.xcassets/login_bg.imageset/login_dark.png differ diff --git a/pedi-foods/Darwin/Assets.xcassets/login_bg.imageset/login_light.png b/pedi-foods/Darwin/Assets.xcassets/login_bg.imageset/login_light.png new file mode 100644 index 0000000..e7c5b09 Binary files /dev/null and b/pedi-foods/Darwin/Assets.xcassets/login_bg.imageset/login_light.png differ diff --git a/pedi-foods/Darwin/Assets.xcassets/pedifoods.imageset/Contents.json b/pedi-foods/Darwin/Assets.xcassets/pedifoods.imageset/Contents.json new file mode 100644 index 0000000..0ed1bd7 --- /dev/null +++ b/pedi-foods/Darwin/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/pedi-foods/Darwin/Assets.xcassets/pedifoods.imageset/pedifoods.png b/pedi-foods/Darwin/Assets.xcassets/pedifoods.imageset/pedifoods.png new file mode 100644 index 0000000..45bec47 Binary files /dev/null and b/pedi-foods/Darwin/Assets.xcassets/pedifoods.imageset/pedifoods.png differ diff --git a/pedi-foods/Darwin/Assets.xcassets/pedifoods.imageset/pedifoods_dark.png b/pedi-foods/Darwin/Assets.xcassets/pedifoods.imageset/pedifoods_dark.png new file mode 100644 index 0000000..c94bd7e Binary files /dev/null and b/pedi-foods/Darwin/Assets.xcassets/pedifoods.imageset/pedifoods_dark.png differ diff --git a/pedi-foods/Darwin/Assets.xcassets/pin_image_app.imageset/Contents.json b/pedi-foods/Darwin/Assets.xcassets/pin_image_app.imageset/Contents.json new file mode 100644 index 0000000..c6998a7 --- /dev/null +++ b/pedi-foods/Darwin/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/pedi-foods/Darwin/Assets.xcassets/pin_image_app.imageset/pin_image_app.png b/pedi-foods/Darwin/Assets.xcassets/pin_image_app.imageset/pin_image_app.png new file mode 100644 index 0000000..7ed510e Binary files /dev/null and b/pedi-foods/Darwin/Assets.xcassets/pin_image_app.imageset/pin_image_app.png differ diff --git a/pedi-foods/Darwin/Assets.xcassets/pin_image_app.imageset/pin_image_app_dark.png b/pedi-foods/Darwin/Assets.xcassets/pin_image_app.imageset/pin_image_app_dark.png new file mode 100644 index 0000000..450d7bc Binary files /dev/null and b/pedi-foods/Darwin/Assets.xcassets/pin_image_app.imageset/pin_image_app_dark.png differ diff --git a/pedi-foods/Darwin/Assets.xcassets/placeholder-product.imageset/Contents.json b/pedi-foods/Darwin/Assets.xcassets/placeholder-product.imageset/Contents.json new file mode 100644 index 0000000..7e07a38 --- /dev/null +++ b/pedi-foods/Darwin/Assets.xcassets/placeholder-product.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "placeholder-product.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/pedi-foods/Darwin/Assets.xcassets/placeholder-product.imageset/placeholder-product.png b/pedi-foods/Darwin/Assets.xcassets/placeholder-product.imageset/placeholder-product.png new file mode 100644 index 0000000..889fa6c Binary files /dev/null and b/pedi-foods/Darwin/Assets.xcassets/placeholder-product.imageset/placeholder-product.png differ diff --git a/pedi-foods/Darwin/Assets.xcassets/tracking-canceled.imageset/Contents.json b/pedi-foods/Darwin/Assets.xcassets/tracking-canceled.imageset/Contents.json new file mode 100644 index 0000000..bd8508b --- /dev/null +++ b/pedi-foods/Darwin/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/pedi-foods/Darwin/Assets.xcassets/tracking-canceled.imageset/tracking-canceled.png b/pedi-foods/Darwin/Assets.xcassets/tracking-canceled.imageset/tracking-canceled.png new file mode 100644 index 0000000..6b0acbc Binary files /dev/null and b/pedi-foods/Darwin/Assets.xcassets/tracking-canceled.imageset/tracking-canceled.png differ diff --git a/pedi-foods/Darwin/Assets.xcassets/tracking-completed.imageset/Contents.json b/pedi-foods/Darwin/Assets.xcassets/tracking-completed.imageset/Contents.json new file mode 100644 index 0000000..210ff36 --- /dev/null +++ b/pedi-foods/Darwin/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/pedi-foods/Darwin/Assets.xcassets/tracking-completed.imageset/tracking-completed.png b/pedi-foods/Darwin/Assets.xcassets/tracking-completed.imageset/tracking-completed.png new file mode 100644 index 0000000..fee3d7f Binary files /dev/null and b/pedi-foods/Darwin/Assets.xcassets/tracking-completed.imageset/tracking-completed.png differ diff --git a/pedi-foods/Darwin/Assets.xcassets/tracking-delivering.imageset/Contents.json b/pedi-foods/Darwin/Assets.xcassets/tracking-delivering.imageset/Contents.json new file mode 100644 index 0000000..427443e --- /dev/null +++ b/pedi-foods/Darwin/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/pedi-foods/Darwin/Assets.xcassets/tracking-delivering.imageset/tracking-delivering.png b/pedi-foods/Darwin/Assets.xcassets/tracking-delivering.imageset/tracking-delivering.png new file mode 100644 index 0000000..2d996ae Binary files /dev/null and b/pedi-foods/Darwin/Assets.xcassets/tracking-delivering.imageset/tracking-delivering.png differ diff --git a/pedi-foods/Darwin/Assets.xcassets/tracking-pending.imageset/Contents.json b/pedi-foods/Darwin/Assets.xcassets/tracking-pending.imageset/Contents.json new file mode 100644 index 0000000..eb65c04 --- /dev/null +++ b/pedi-foods/Darwin/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/pedi-foods/Darwin/Assets.xcassets/tracking-pending.imageset/tracking-pending.png b/pedi-foods/Darwin/Assets.xcassets/tracking-pending.imageset/tracking-pending.png new file mode 100644 index 0000000..a4a9344 Binary files /dev/null and b/pedi-foods/Darwin/Assets.xcassets/tracking-pending.imageset/tracking-pending.png differ diff --git a/pedi-foods/Darwin/Assets.xcassets/tracking-preparing.imageset/Contents.json b/pedi-foods/Darwin/Assets.xcassets/tracking-preparing.imageset/Contents.json new file mode 100644 index 0000000..e0a94c8 --- /dev/null +++ b/pedi-foods/Darwin/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/pedi-foods/Darwin/Assets.xcassets/tracking-preparing.imageset/tracking-preparing.png b/pedi-foods/Darwin/Assets.xcassets/tracking-preparing.imageset/tracking-preparing.png new file mode 100644 index 0000000..23fc9a7 Binary files /dev/null and b/pedi-foods/Darwin/Assets.xcassets/tracking-preparing.imageset/tracking-preparing.png differ diff --git a/pedi-foods/Darwin/Assets.xcassets/tracking-ready.imageset/Contents.json b/pedi-foods/Darwin/Assets.xcassets/tracking-ready.imageset/Contents.json new file mode 100644 index 0000000..f5e3c56 --- /dev/null +++ b/pedi-foods/Darwin/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/pedi-foods/Darwin/Assets.xcassets/tracking-ready.imageset/tracking-ready.png b/pedi-foods/Darwin/Assets.xcassets/tracking-ready.imageset/tracking-ready.png new file mode 100644 index 0000000..fe2f0f2 Binary files /dev/null and b/pedi-foods/Darwin/Assets.xcassets/tracking-ready.imageset/tracking-ready.png differ diff --git a/pedi-foods/Darwin/PediFoods.xcconfig b/pedi-foods/Darwin/PediFoods.xcconfig index 6b94844..d45fdb1 100644 --- a/pedi-foods/Darwin/PediFoods.xcconfig +++ b/pedi-foods/Darwin/PediFoods.xcconfig @@ -22,7 +22,8 @@ GENERATE_INFOPLIST_FILE = YES // 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_UILaunchScreen_Generation[sdk=iphone*] = NO +INFOPLIST_KEY_UILaunchStoryboardName[sdk=iphone*] = LaunchScreen INFOPLIST_KEY_UIStatusBarStyle[sdk=iphone*] = UIStatusBarStyleDefault INFOPLIST_KEY_UISupportedInterfaceOrientations[sdk=iphone*] = UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown diff --git a/pedi-foods/Darwin/PediFoods.xcodeproj/project.pbxproj b/pedi-foods/Darwin/PediFoods.xcodeproj/project.pbxproj index 312c83d..c2e96d7 100644 --- a/pedi-foods/Darwin/PediFoods.xcodeproj/project.pbxproj +++ b/pedi-foods/Darwin/PediFoods.xcodeproj/project.pbxproj @@ -12,6 +12,8 @@ 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 */; }; + B185A1FD2FB67A12001C7771 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = B185A1FC2FB67A12001C7771 /* LaunchScreen.storyboard */; }; + B13CABF22F36437300469FD6 /* LCEssentials in Frameworks */ = {isa = PBXBuildFile; productRef = B13CABF12F36437300469FD6 /* LCEssentials */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -40,6 +42,7 @@ 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 = ""; }; + B185A1FC2FB67A12001C7771 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = Sources/LaunchScreen.storyboard; sourceTree = SOURCE_ROOT; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -47,6 +50,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + B13CABF22F36437300469FD6 /* LCEssentials in Frameworks */, 491F27822DA55B72004926EE /* PediFoods in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -88,6 +92,7 @@ isa = PBXGroup; children = ( 49F90C2B2A52156200F06D93 /* Main.swift */, + B185A1FC2FB67A12001C7771 /* LaunchScreen.storyboard */, 49F90C2F2A52156300F06D93 /* Assets.xcassets */, 49F90C312A52156300F06D93 /* Entitlements.plist */, 4900101C2BACEA710000DE33 /* Info.plist */, @@ -115,6 +120,7 @@ name = "PediFoods App"; packageProductDependencies = ( 491F27812DA55B72004926EE /* PediFoods */, + B13CABF12F36437300469FD6 /* LCEssentials */, ); productName = App; productReference = 496BDBEB2B89A47800C09264 /* PediFoods.app */; @@ -143,6 +149,7 @@ ); mainGroup = 49F90C1F2A52156200F06D93; packageReferences = ( + B13CABF02F36437300469FD6 /* XCRemoteSwiftPackageReference "LCEssentials" */, ); productRefGroup = 496BDBEC2B89A47800C09264 /* Products */; projectDirPath = ""; @@ -158,6 +165,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + B185A1FD2FB67A12001C7771 /* LaunchScreen.storyboard in Resources */, 499CD4402AC5B799001AE8D8 /* Assets.xcassets in Resources */, 496BDBEE2B8A7E9C00C09264 /* Localizable.xcstrings in Resources */, ); @@ -183,7 +191,7 @@ ); 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"; + 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 [ \"${PLATFORM_NAME}\" = \"iphonesimulator\" ]; then\n echo \"note: skipping skip gradle for iOS Simulator\"\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 */ @@ -203,7 +211,9 @@ isa = XCBuildConfiguration; baseConfigurationReference = 496EB72F2A6AE4DE00C1253B /* PediFoods.xcconfig */; buildSettings = { + DEVELOPMENT_TEAM = K4E5BZMM4V; ENABLE_PREVIEWS = YES; + INFOPLIST_KEY_CFBundleDisplayName = "Pedi Foods"; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; }; @@ -213,7 +223,9 @@ isa = XCBuildConfiguration; baseConfigurationReference = 496EB72F2A6AE4DE00C1253B /* PediFoods.xcconfig */; buildSettings = { + DEVELOPMENT_TEAM = K4E5BZMM4V; ENABLE_PREVIEWS = YES; + INFOPLIST_KEY_CFBundleDisplayName = "Pedi Foods"; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; }; @@ -244,7 +256,7 @@ ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf-with-dsym; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -278,11 +290,27 @@ }; /* End XCConfigurationList section */ +/* Begin XCRemoteSwiftPackageReference section */ + B13CABF02F36437300469FD6 /* XCRemoteSwiftPackageReference "LCEssentials" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://git.loverde.com.br/Loverde-Company-LTDA/LCEssentials.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.0.5; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + /* Begin XCSwiftPackageProductDependency section */ 491F27812DA55B72004926EE /* PediFoods */ = { isa = XCSwiftPackageProductDependency; productName = PediFoods; }; + B13CABF12F36437300469FD6 /* LCEssentials */ = { + isa = XCSwiftPackageProductDependency; + package = B13CABF02F36437300469FD6 /* XCRemoteSwiftPackageReference "LCEssentials" */; + productName = LCEssentials; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 49F90C202A52156200F06D93 /* Project object */; diff --git a/pedi-foods/Darwin/Sources/LaunchScreen.storyboard b/pedi-foods/Darwin/Sources/LaunchScreen.storyboard new file mode 100644 index 0000000..bd2b472 --- /dev/null +++ b/pedi-foods/Darwin/Sources/LaunchScreen.storyboard @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pedi-foods/PROFILE_PLAN.md b/pedi-foods/PROFILE_PLAN.md new file mode 100644 index 0000000..adc64da --- /dev/null +++ b/pedi-foods/PROFILE_PLAN.md @@ -0,0 +1,30 @@ +# Plano - Tela de Perfil + +Legenda: +- 🟢 Concluído +- 🟡 Em validação +- 🔴 Pendente + +Última atualização: 2026-02-13 + +## Entregas + +- 🟢 Redesign do conteúdo da tela de perfil no estilo do modelo (header + cards de menu) +- 🟢 Header ajustada para encostar no topo e nas laterais +- 🟢 Redução de escala dos itens da lista (ícones, fonte, altura e paddings) +- 🟢 Faixa de proteção no rodapé para não deixar conteúdo por trás da tabbar +- 🟢 Confirmação com alert padrão ao tocar em "Sair da Conta" +- 🟢 Fluxo de editar endereço reutilizando a tela de novo endereço +- 🟢 Swipe para excluir endereço com compatibilidade Android (gesto custom) +- 🟢 Polimento visual do swipe/delete (sem vazamento de fundo vermelho) + +## Pendências + +- 🟡 Validação visual final no device (iOS e Android) com screenshots finais +- 🔴 Definir destinos reais dos itens ainda placeholder: + - Meus Cartões + - Minhas Avaliações + - Cupons de Desconto + - Configurações/Ajuda (confirmar nomenclatura final) +- 🔴 Corrigir build global do projeto (dependência `LCEssentials` ausente em `StoreDetailView.swift`) + diff --git a/pedi-foods/PROJECT_ANALYSIS_SKIP.md b/pedi-foods/PROJECT_ANALYSIS_SKIP.md new file mode 100644 index 0000000..b7640e3 --- /dev/null +++ b/pedi-foods/PROJECT_ANALYSIS_SKIP.md @@ -0,0 +1,51 @@ +# Análise Técnica — Arquitetura Skip (PediFoods) + +## Entendimento confirmado + +O projeto usa **Skip com fonte principal em Swift/SwiftUI** e geração/transpilação para Android. + +Diretriz operacional validada: + +1. **iOS é a plataforma prioritária** de desenvolvimento e validação. +2. **Android entra depois**, com adaptações isoladas por compilação condicional. +3. Toda alteração deve preservar comportamento de iOS e evitar regressão cross-platform. + +## Como o projeto está estruturado + +1. Código compartilhado principal: `Sources/PediFoods/*` (Swift/SwiftUI). +2. Entry point iOS: `Darwin/Sources/Main.swift`. +3. Entry point Android: `Android/app/src/main/kotlin/Main.kt`. +4. Configuração Skip: `Sources/PediFoods/Skip/skip.yml` (`mode: native`). +5. Build/transpilação Android via plugin Skip (`skipstone`) no target Swift Package. + +## Evidências no código (padrão já em uso) + +1. Uso extensivo de condicionais: + - `#if os(iOS)` + - `#if os(Android)` + - `#if canImport(UIKit)` +2. Dependência iOS-only no `Package.swift`: + - `LCEssentials` com `condition: .when(platforms: [.iOS])` +3. Exemplo claro de adaptação segura: + - `ContentView.swift`: `SnackbarCenter` com implementação distinta para Android e iOS. + +## Regra prática para próximas tarefas + +1. Implementar primeiro para iOS. +2. Validar iOS (build/execução/comportamento). +3. Só depois adaptar Android, sempre em bloco condicional separado. +4. Nunca alterar caminho iOS por causa de ajuste Android. +5. Em mudanças de UI de inicialização (ex.: splash), tratar iOS e Android em fluxos independentes. + +## Nota específica para Splash + +Estado atual identificado: + +1. iOS com Launch Screen gerado automaticamente (`INFOPLIST_KEY_UILaunchScreen_Generation = YES`). +2. Android sem implementação custom de splash dedicada no `res/values` + tema de launch. + +Implicação: + +1. Mudança de splash para iOS pode ser feita sem tocar Android. +2. Quando Android for tratado, deve entrar em camada própria (tema/recursos Android), sem impactar o fluxo iOS. + diff --git a/pedi-foods/Package.swift b/pedi-foods/Package.swift index 678e73e..3d1ebe1 100644 --- a/pedi-foods/Package.swift +++ b/pedi-foods/Package.swift @@ -11,11 +11,14 @@ let package = Package( ], 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") + .package(url: "https://source.skip.tools/skip-fuse-ui.git", from: "1.0.0"), + .package(url: "https://git.loverde.com.br/Loverde-Company-LTDA/LCEssentials.git", from: "1.0.5") ], targets: [ .target(name: "PediFoods", dependencies: [ - .product(name: "SkipFuseUI", package: "skip-fuse-ui") + .product(name: "SkipFuseUI", package: "skip-fuse-ui"), + .product(name: "LCEssentials", package: "lcessentials", condition: .when(platforms: [.iOS])) ], resources: [.process("Resources")], plugins: [.plugin(name: "skipstone", package: "skip")]), + .testTarget(name: "PediFoodsTests", dependencies: ["PediFoods"]), ] ) diff --git a/pedi-foods/Skip.env b/pedi-foods/Skip.env index 97f1b74..0ea893e 100644 --- a/pedi-foods/Skip.env +++ b/pedi-foods/Skip.env @@ -21,3 +21,6 @@ ANDROID_PACKAGE_NAME = pedi.foods // If your Android appId is different from the iOS Bundle Identifer, specify it here // ANDROID_APPLICATION_ID = com.br.pedifoods.app + +// Atomenta endpoint +ATOMENTA_API_URL = "https://atomenta.com.br" diff --git a/pedi-foods/Sources/PediFoods/Components/Buttons.swift b/pedi-foods/Sources/PediFoods/Components/Buttons.swift new file mode 100644 index 0000000..ba1a964 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Components/Buttons.swift @@ -0,0 +1,69 @@ +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)) + } +} + +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.textInverse) + .frame(maxWidth: fullWidth ? .infinity : nil) + .frame(height: 56) + .background(AppColors.secondary) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + .buttonStyle(.plain) + } +} diff --git a/pedi-foods/Sources/PediFoods/Components/CachedRemoteImage.swift b/pedi-foods/Sources/PediFoods/Components/CachedRemoteImage.swift new file mode 100644 index 0000000..9cec3ae --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Components/CachedRemoteImage.swift @@ -0,0 +1,176 @@ +import Foundation +import SwiftUI +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif + +struct CachedRemoteImage: View { + let imageURL: String? + let ttl: TimeInterval + let placeholder: Placeholder + + @StateObject var loader = CachedRemoteImageLoader() + + init( + imageURL: String?, + ttl: TimeInterval = AppCacheTTL.twoHours, + @ViewBuilder placeholder: () -> Placeholder + ) { + self.imageURL = imageURL + self.ttl = ttl + self.placeholder = placeholder() + } + + var body: some View { + Group { +#if canImport(UIKit) + if let uiImage = loader.uiImage { + Image(uiImage: uiImage) + .resizable() + .scaledToFill() + } else { + placeholder + } +#elseif canImport(AppKit) + if let nsImage = loader.nsImage { + Image(nsImage: nsImage) + .resizable() + .scaledToFill() + } else { + placeholder + } +#else + if let imageURL, + let url = URL(string: imageURL) { + AsyncImage(url: url) { phase in + switch phase { + case .success(let image): + image + .resizable() + .scaledToFill() + default: + placeholder + } + } + } else { + placeholder + } +#endif + } + .onAppear { + loader.load(imageURL: imageURL, ttl: ttl) + } + .onChange(of: imageURL) { _, newValue in + loader.load(imageURL: newValue, ttl: ttl) + } + } +} + +@MainActor +final class CachedRemoteImageLoader: ObservableObject { +#if canImport(UIKit) + @Published var uiImage: UIImage? +#elseif canImport(AppKit) + @Published var nsImage: NSImage? +#endif + + private var currentKey: String? + private var task: Task? + + 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() + +#if canImport(UIKit) + uiImage = nil +#elseif canImport(AppKit) + nsImage = nil +#endif + + guard let normalized, normalized.isEmpty == false else { return } + + #if canImport(UIKit) || canImport(AppKit) + let dataCacheKey = Self.dataURLCacheKey(normalized) + if let cachedDataImage: PlatformImage = AppContentCache.shared.value(for: dataCacheKey, as: PlatformImage.self) { +#if canImport(UIKit) + uiImage = cachedDataImage +#elseif canImport(AppKit) + nsImage = cachedDataImage +#endif + return + } + + if let image = Self.imageFromDataURL(normalized) { + AppContentCache.shared.set(image, for: dataCacheKey, ttl: ttl) +#if canImport(UIKit) + uiImage = image +#elseif canImport(AppKit) + nsImage = image +#endif + return + } + #endif + + guard let url = URL(string: normalized) else { return } + + task = Task { [weak self] in +#if canImport(UIKit) || canImport(AppKit) + let image = await AppImageCache.shared.image(for: url, ttl: ttl) + guard Task.isCancelled == false else { return } + await MainActor.run { +#if canImport(UIKit) + self?.uiImage = image +#elseif canImport(AppKit) + self?.nsImage = image +#endif + } +#endif + } + } + + 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)" + } + + #if canImport(UIKit) || canImport(AppKit) + private static func imageFromDataURL(_ source: String) -> PlatformImage? { + let lower = source.lowercased() + guard lower.hasPrefix("data:image"), let commaIndex = source.firstIndex(of: ",") else { return nil } + + let header = String(source[.. 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/pedi-foods/Sources/PediFoods/ContentView.swift b/pedi-foods/Sources/PediFoods/ContentView.swift index f7afb09..318683c 100644 --- a/pedi-foods/Sources/PediFoods/ContentView.swift +++ b/pedi-foods/Sources/PediFoods/ContentView.swift @@ -1,177 +1,366 @@ 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) - } -} + @State var root: RootFlow = DefaultTokenStore().jwt == nil ? .auth : .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 var shouldPrepareAuthEntryAnimation = DefaultTokenStore().jwt == nil + @State var authEntryAnimationToken = 0 + #if os(iOS) + @State private var sessionExpiredObserver: NSObjectProtocol? + #endif +#if os(Android) + @State var snackbarCenter = SnackbarCenter.shared +#else + @StateObject var snackbarCenter = SnackbarCenter.shared #endif + + var body: some View { + ZStack(alignment: .top) { + Group { + switch root { + case .auth: + AuthFlowView( + root: $root, + selectedTab: $selectedTab, + tokenStore: tokenStore, + appState: $appState, + shouldPrepareLoginEntry: shouldPrepareAuthEntryAnimation, + authEntryAnimationToken: authEntryAnimationToken + ) + case .main: + if isBootstrappingSession { + sessionBootstrapLoadingView + } else { + MainTabView(selectedTab: $selectedTab, root: $root, tokenStore: tokenStore, appState: $appState) + } + } + } + + SnackbarOverlay(center: snackbarCenter) + + if showLaunchSplash { + LaunchSplashView(shouldPulse: shouldPulseLaunchSplash) + .transition(.opacity) + } + } + .sheet(item: $appState.activeModal) { modal in + switch modal { + case .addressPicker: + AddressPickerModalView(appState: $appState, selectedTab: $selectedTab) + case .filters: + FiltersModalView() + } + } + .onChange(of: appState.address.display) { _, _ in + dismissAddressPickerIfAddressExists() + } + .onChange(of: appState.address.selectedId) { _, _ in + dismissAddressPickerIfAddressExists() + } + .onChange(of: appState.address.latitude) { _, _ in + dismissAddressPickerIfAddressExists() + } + .onChange(of: appState.address.longitude) { _, _ in + dismissAddressPickerIfAddressExists() + } + .onReceive(NotificationCenter.default.publisher(for: .cartDidReset)) { _ in + appState.cart = CartState() + } + .task { + await performInitialLaunchBootstrap() + } + .onChange(of: root) { _, newValue in + if newValue == .main { + Task { + await bootstrapSessionStateIfNeeded() + } + } + } + .onAppear { + #if os(iOS) + attachSessionExpiredObserverIfNeeded() + #endif + } + .onDisappear { + #if os(iOS) + detachSessionExpiredObserver() + #endif + } + } + + private var sessionBootstrapLoadingView: some View { + VStack(spacing: 12) { + ProgressView() + Text("Carregando sua sessão...") + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(AppColors.backgroundLight) + } + + @MainActor + private func 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 { + shouldPrepareAuthEntryAnimation = true + shouldPulseLaunchSplash = false + try? await Task.sleep(nanoseconds: 180_000_000) + withAnimation(.easeInOut(duration: 0.34)) { + showLaunchSplash = false + } + try? await Task.sleep(nanoseconds: 360_000_000) + authEntryAnimationToken += 1 + scheduleDisableAuthEntryPreparation() + 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 + } + + // Always refresh profile when authenticated. + // This keeps profile/address/cart scope consistent after relogin + // and avoids stale local state during checkout payload generation. + do { + let response = try await ApiService().profile() + if response.error == false, let customer = response.result { + hydrateAppState(with: customer) + } + } catch let error as ApiServiceError { + if case .sessionExpired = error { + forceLogoutToStart() + } + } catch { + // Keep local state when backend refresh fails transiently. + } + + isBootstrappingSession = false + } + + @MainActor + private func hydrateAppState(with customer: CustomerProfile) { + appState.profile.id = customer.id + appState.profile.name = customer.name + appState.profile.email = customer.email + appState.profile.phone = customer.phoneNumber ?? "" + appState.profile.profilePicture = customer.profilePicture ?? "" + 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 + shouldPrepareAuthEntryAnimation = true + root = .auth + authEntryAnimationToken += 1 + scheduleDisableAuthEntryPreparation() + } + + private func scheduleDisableAuthEntryPreparation() { + Task { @MainActor in + try? await Task.sleep(nanoseconds: 900_000_000) + shouldPrepareAuthEntryAnimation = 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" + } + + @MainActor + private func dismissAddressPickerIfAddressExists() { + guard appState.activeModal == .addressPicker else { return } + if hasConfiguredAddress() { + appState.activeModal = nil + } + } + + #if os(iOS) + private func attachSessionExpiredObserverIfNeeded() { + guard sessionExpiredObserver == nil else { return } + sessionExpiredObserver = NotificationCenter.default.addObserver( + forName: .sessionExpired, + object: nil, + queue: .main + ) { _ in + Task { @MainActor in + forceLogoutToStart() + } + } + } + + private func detachSessionExpiredObserver() { + guard let observer = sessionExpiredObserver else { return } + NotificationCenter.default.removeObserver(observer) + sessionExpiredObserver = nil + } + #endif +} + +struct AddressPickerModalView: View { + @Binding var appState: AppState + @Binding var selectedTab: MainTab + + var body: some View { + NavigationStack { + AddressesView( + message: appState.address.onboardingMessage, + appState: $appState, + selectionMode: true + ) + .onAppear { + appState.address.onboardingMessage = nil + } + } + } +} + +struct FiltersModalView: View { + var body: some View { + NavigationStack { + VStack(spacing: 12) { + Text("Filtros") + .font(AppTypography.heading2) + Text("Filtros de busca serao ligados na integracao real da Home com API.") + .font(AppTypography.body) + .multilineTextAlignment(.center) + .foregroundStyle(AppColors.textMuted) + } + .padding(24) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(AppColors.backgroundLight) + } + } +} diff --git a/pedi-foods/Sources/PediFoods/Coordinators/AppCoordinator.swift b/pedi-foods/Sources/PediFoods/Coordinators/AppCoordinator.swift new file mode 100644 index 0000000..50ee526 --- /dev/null +++ b/pedi-foods/Sources/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/pedi-foods/Sources/PediFoods/DesignSystem/DesignSystem.swift b/pedi-foods/Sources/PediFoods/DesignSystem/DesignSystem.swift new file mode 100644 index 0000000..a644015 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/DesignSystem/DesignSystem.swift @@ -0,0 +1,104 @@ +import SwiftUI + +// MARK: - Colors + +enum AppColors { + static let primary = Color(hex: "#1E6B43") + static let secondary = Color(hex: "#2F7C4B") + static let tertiary = Color(hex: "#8CFF2E") + + static let brandDark = Color(hex: "#1B5C3A") + static let brandSoft = Color(hex: "#E8F2EC") + + static let backgroundLight = Color(hex: "#F3F5F7") + static let backgroundDark = Color(hex: "#18230F") + static let surface = Color(hex: "#FFFFFF") + static let textPrimary = Color(hex: "#1C1F23") + static let textInverse = Color(hex: "#FFFFFF") + static let textMuted = Color(hex: "#7B8794") +} + +enum AppDarkColors { + static let background = Color(hex: "#18230F") + static let surface = Color(hex: "#FFFFFF", alpha: 0.05) + static let textPrimary = Color(hex: "#FFFFFF") + static let textSecondary = Color(hex: "#A3A3A3") + static let primary = AppColors.primary + static let secondary = AppColors.secondary + static let tertiary = AppColors.tertiary +} + +// MARK: - Typography + +enum AppTypography { + static let fontFamily = "Plus Jakarta Sans" + + // Avoid applying dynamic weight on custom font descriptors to prevent + // SwiftUI runtime warnings on some platforms/toolchains. + static let heading1 = Font.custom(fontFamily, size: 28) + static let heading25 = Font.custom(fontFamily, size: 25) + static let heading2 = Font.custom(fontFamily, size: 20) + static let heading3 = Font.custom(fontFamily, size: 16) + static let body = Font.custom(fontFamily, size: 16) + static let button = Font.custom(fontFamily, size: 14) + static let caption = Font.custom(fontFamily, size: 10) + static let overline = Font.custom(fontFamily, size: 11) + + static let bodyLineHeight: CGFloat = 1.6 + static let buttonLetterSpacing: CGFloat = 0.08 + static let captionLetterSpacing: CGFloat = 0.12 +} + +// MARK: - Layout + +enum AppLayout { + 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 +} + +enum AppShadow { + 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 + Scanner(string: cleaned).scanHexInt64(&int) + + 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/Sources/PediFoods/Resources/Localizable.xcstrings b/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings index 779b2ac..891c3f9 100644 --- a/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings +++ b/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings @@ -1,8 +1,35 @@ { "sourceLanguage" : "en", "strings" : { + "" : { + + }, + " " : { + "comment" : "A placeholder text used to maintain vertical spacing in the UI.", + "isCommentAutoGenerated" : true + }, + "- %@" : { + "comment" : "A text element displaying a negative monetary value, indicating a discount.", + "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" : { @@ -30,8 +57,60 @@ } } }, + "%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 + }, + "1. Tamanho" : { + "comment" : "A heading for the size selection step in the pizza product detail sheet.", + "isCommentAutoGenerated" : true + }, + "2. Massa" : { + + }, + "3. Borda" : { + + }, + "4. Sabores (%lld/%lld)" : { + "comment" : "A heading that describes the step where the user selects pizza flavors. The number in the parentheses indicates the maximum number of flavors that can be selected.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "4. Sabores (%1$lld/%2$lld)" + } + } + } + }, + "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" : { @@ -59,8 +138,49 @@ } } }, + "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 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 + }, + "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 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 + }, + "Apenas Pagar" : { + "comment" : "A button that allows the user to pay without saving the payment information.", + "isCommentAutoGenerated" : true + }, + "Aplicar" : { + "comment" : "A button that applies a coupon code to a cart.", + "isCommentAutoGenerated" : true + }, "Appearance" : { "comment" : "Settings select label for the interface style of the controls (light, dark, or default)", + "extractionState" : "stale", "localizations" : { "es" : { "stringUnit" : { @@ -88,8 +208,42 @@ } } }, + "Atualizando status do pedido..." : { + + }, + "Atualize os dados do endereço abaixo." : { + "comment" : "A description below the fields in the \"Editar endereço\" form, instructing the user to update their address details.", + "isCommentAutoGenerated" : true + }, + "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" : { @@ -117,8 +271,92 @@ } } }, + "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 pedido..." : { + + }, + "Carregando sua sessão..." : { + "comment" : "A loading message displayed while bootstrapping the user's session.", + "isCommentAutoGenerated" : true + }, + "Carrinho" : { + "comment" : "A label for the cart section of the app.", + "isCommentAutoGenerated" : true + }, + "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 + }, + "Concluir" : { + "comment" : "The text for a button that confirms and closes a sheet.", + "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 + }, + "Conteúdo da política de privacidade..." : { + "comment" : "A placeholder text describing the content of the privacy policy.", + "isCommentAutoGenerated" : true + }, + "Conteúdo dos termos de uso..." : { + "comment" : "A placeholder text describing the content of the terms of use.", + "isCommentAutoGenerated" : true + }, + "Criar conta" : { + "comment" : "A link that navigates to the registration screen.", + "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 + }, + "Dados do Cartão" : { + "comment" : "The header text for the section that collects payment card information.", + "isCommentAutoGenerated" : true + }, "Dark" : { "comment" : "Menu item indicating that the appearance should be in dark mode", + "extractionState" : "stale", "localizations" : { "es" : { "stringUnit" : { @@ -148,6 +386,7 @@ }, "Date" : { "comment" : "Item editor form label for the Date field", + "extractionState" : "stale", "localizations" : { "es" : { "stringUnit" : { @@ -175,8 +414,74 @@ } } }, + "DELIVERY LOCATION" : { + "comment" : "A label describing the delivery location section.", + "isCommentAutoGenerated" : true + }, + "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" : { + + }, + "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 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 + }, + "Entrega" : { + "comment" : "A text describing delivery mode.", + "isCommentAutoGenerated" : true + }, + "Enviar Avaliação" : { + + }, + "Enviaremos um código de verificação por SMS \nou E-mail para confirmar seu acesso." : { + "comment" : "A description below the login fields, explaining that a verification code will be sent via SMS or email to confirm access.", + "isCommentAutoGenerated" : true + }, + "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 + }, + "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 + }, "Favorite" : { "comment" : "Item editor title label for marking the item as a favorite", + "extractionState" : "stale", "localizations" : { "es" : { "stringUnit" : { @@ -204,8 +509,25 @@ } } }, + "Fechar" : { + "comment" : "A button to close the current view.", + "isCommentAutoGenerated" : true + }, + "Filtros" : { + "comment" : "A label for the filter options in the modal.", + "isCommentAutoGenerated" : true + }, + "Filtros de busca serao ligados na integracao real da Home com API." : { + "comment" : "A description of the filters feature that will be added to the home screen.", + "isCommentAutoGenerated" : true + }, + "Finalizar Pedido" : { + "comment" : "The title of the checkout screen.", + "isCommentAutoGenerated" : true + }, "Hello [%@](https://skip.tools)!" : { "comment" : "Welcome tab contents", + "extractionState" : "stale", "localizations" : { "es" : { "stringUnit" : { @@ -262,8 +584,32 @@ } } }, + "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 + }, "Light" : { "comment" : "Menu item indicating that the appearance should be in light mode", + "extractionState" : "stale", "localizations" : { "es" : { "stringUnit" : { @@ -290,9 +636,47 @@ } } } + }, + "Limpar carrinho e adicionar" : { + "comment" : "A button that clears the current cart and adds the selected product.", + "isCommentAutoGenerated" : true + }, + "Loja não informou métodos presenciais." : { + + }, + "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 Endereços" : { + "comment" : "A label displayed at the top of the view.", + "isCommentAutoGenerated" : true + }, + "Meus Pedidos" : { + + }, + "Minhas Avaliações" : { + "comment" : "A button that links to their reviews.", + "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" : { + }, "Name" : { "comment" : "Placeholder title for the Name field in a form", + "extractionState" : "stale", "localizations" : { "es" : { "stringUnit" : { @@ -320,8 +704,35 @@ } } }, + "Não" : { + "comment" : "The label of a button in an alert that says \"No\".", + "isCommentAutoGenerated" : true + }, + "Não recebeu o código?" : { + "comment" : "A question displayed below the button to re-send the OTP.", + "isCommentAutoGenerated" : true + }, + "Não tem conta ainda?" : { + + }, + "Nenhum endereço cadastrado" : { + "comment" : "A message displayed when a user has no addresses saved.", + "isCommentAutoGenerated" : true + }, + "Nenhum estabelecimento encontrado próximo à sua localização." : { + "comment" : "A message displayed when no nearby stores are found.", + "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" : { @@ -349,8 +760,92 @@ } } }, + "Novo endereço" : { + "comment" : "A label for a form to add a new address.", + "isCommentAutoGenerated" : true + }, + "Novo por aqui?" : { + "comment" : "A text that appears at the bottom of the screen, inviting users to create an account.", + "isCommentAutoGenerated" : true + }, + "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 vai querer pedir hoje?\n " : { + + }, + "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 + }, + "Pagamento" : { + "comment" : "The title of the screen where a user can enter and save payment information.", + "isCommentAutoGenerated" : true + }, + "Pagamento via PIX" : { + "comment" : "The navigation title for the PaymentPixView.", + "isCommentAutoGenerated" : true + }, + "para %@" : { + "comment" : "A text label displaying the email address to which the one-time password (OTP) was sent. The text is truncated to fit within one line.", + "isCommentAutoGenerated" : true + }, + "Pedido #%@" : { + + }, + "Pedido #%@ • %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Pedido #%1$@ • %2$@" + } + } + } + }, + "Pedido %@" : { + + }, + "PediFoods" : { + "comment" : "The name of the app.", + "isCommentAutoGenerated" : true + }, + "Pedir Novamente" : { + "comment" : "A button that allows a user to order the same items again.", + "isCommentAutoGenerated" : true + }, + "Perfil" : { + "comment" : "A label for the \"Profile\" tab in the main tab view.", + "isCommentAutoGenerated" : true + }, + "Pizza de varios sabores" : { + "comment" : "A title displayed above the main content of the view.", + "isCommentAutoGenerated" : true + }, + "Política de Privacidade" : { + "comment" : "The title of the privacy policy section.", + "isCommentAutoGenerated" : true + }, "Powered by [Skip](https://skip.tools)" : { "comment" : "Link markdown text for the Powered by… label", + "extractionState" : "stale", "localizations" : { "es" : { "stringUnit" : { @@ -378,8 +873,84 @@ } } }, + "Precisa de ajuda com esse pedido?" : { + + }, + "Preencha os dados abaixo para adicionar um endereço." : { + "comment" : "A description below the form to add a new address, instructing the user to fill in the required information.", + "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" : { + "comment" : "A label indicating that a particular address is the user's primary address.", + "isCommentAutoGenerated" : true + }, + "Privacidade" : { + "comment" : "The title of the privacy policy screen.", + "isCommentAutoGenerated" : true + }, + "Progresso do Pedido" : { + + }, + "Reavaliar" : { + "comment" : "The text for a button that allows a user to re-evaluate an order.", + "isCommentAutoGenerated" : true + }, + "Receber Código" : { + "comment" : "A button label that says \"Receive Code\".", + "isCommentAutoGenerated" : true + }, + "Reenviar código" : { + "comment" : "A button that allows a user to request a new OTP code.", + "isCommentAutoGenerated" : true + }, + "Remover" : { + "comment" : "A button that deletes the user's profile picture.", + "isCommentAutoGenerated" : true + }, + "Resposta da loja" : { + "comment" : "A heading for the reply from the store in an order review.", + "isCommentAutoGenerated" : true + }, + "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 + }, "Save" : { "comment" : "Button title indicating that the current contents should be saved", + "extractionState" : "stale", "localizations" : { "es" : { "stringUnit" : { @@ -406,9 +977,13 @@ } } } + }, + "Sem adicionais" : { + }, "Settings" : { "comment" : "Tab bar item title for the Settings tab", + "extractionState" : "stale", "localizations" : { "es" : { "stringUnit" : { @@ -435,9 +1010,36 @@ } } } + }, + "Seu carrinho está vazio" : { + "comment" : "A message indicating that the user's cart is empty.", + "isCommentAutoGenerated" : true + }, + "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 + }, + "Seu pedido está em andamento" : { + + }, + "Sim" : { + "comment" : "The text for a button that confirms an action. In this case, it confirms the user's choice to continue with the order despite the address not being served by the store.", + "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 + }, + "Subtotal" : { + }, "System" : { "comment" : "Menu item indicating that the appearance should be in the default system mode", + "extractionState" : "stale", "localizations" : { "es" : { "stringUnit" : { @@ -465,8 +1067,28 @@ } } }, + "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" : { @@ -494,8 +1116,62 @@ } } }, + "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 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 + }, + "UPLOAD" : { + "comment" : "A button label that says \"UPLOAD\".", + "isCommentAutoGenerated" : true + }, + "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 + }, + "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" : { @@ -529,8 +1205,16 @@ } } }, + "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" : { @@ -559,5 +1243,5 @@ } } }, - "version" : "1.0" -} + "version" : "1.1" +} \ No newline at end of file diff --git a/pedi-foods/Sources/PediFoods/Resources/login_dark.png b/pedi-foods/Sources/PediFoods/Resources/login_dark.png new file mode 100644 index 0000000..bf637d4 Binary files /dev/null and b/pedi-foods/Sources/PediFoods/Resources/login_dark.png differ diff --git a/pedi-foods/Sources/PediFoods/Resources/login_light.png b/pedi-foods/Sources/PediFoods/Resources/login_light.png new file mode 100644 index 0000000..e7c5b09 Binary files /dev/null and b/pedi-foods/Sources/PediFoods/Resources/login_light.png differ diff --git a/pedi-foods/Sources/PediFoods/Resources/pedifoods.icon/Assets/pedifoods_big.png b/pedi-foods/Sources/PediFoods/Resources/pedifoods.icon/Assets/pedifoods_big.png new file mode 100644 index 0000000..bd1c864 Binary files /dev/null and b/pedi-foods/Sources/PediFoods/Resources/pedifoods.icon/Assets/pedifoods_big.png differ diff --git a/pedi-foods/Sources/PediFoods/Resources/pedifoods.icon/icon.json b/pedi-foods/Sources/PediFoods/Resources/pedifoods.icon/icon.json new file mode 100644 index 0000000..2dfb142 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Resources/pedifoods.icon/icon.json @@ -0,0 +1,94 @@ +{ + "fill-specializations" : [ + { + "value" : { + "automatic-gradient" : "srgb:0.99020,0.98039,1.00000,1.00000" + } + }, + { + "appearance" : "dark", + "value" : { + "solid" : "srgb:0.00000,0.00000,0.00000,1.00000" + } + } + ], + "groups" : [ + { + "layers" : [ + + ], + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "translucency" : { + "enabled" : true, + "value" : 0.5 + } + }, + { + "layers" : [ + { + "blend-mode-specializations" : [ + { + "value" : "normal" + }, + { + "appearance" : "dark", + "value" : "normal" + } + ], + "fill-specializations" : [ + { + "value" : "none" + }, + { + "appearance" : "dark", + "value" : { + "solid" : "srgb:0.00000,0.50000,0.04260,1.00000" + } + } + ], + "glass" : false, + "hidden" : false, + "image-name" : "pedifoods_big.png", + "name" : "pedifoods_big", + "position-specializations" : [ + { + "value" : { + "scale" : 1, + "translation-in-points" : [ + 137.390625, + 11.984375 + ] + } + }, + { + "idiom" : "square", + "value" : { + "scale" : 0.35, + "translation-in-points" : [ + 8, + 0 + ] + } + } + ] + } + ], + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "translucency" : { + "enabled" : true, + "value" : 0.5 + } + } + ], + "supported-platforms" : { + "squares" : [ + "iOS" + ] + } +} \ No newline at end of file diff --git a/pedi-foods/Sources/PediFoods/Resources/pedifoods.png b/pedi-foods/Sources/PediFoods/Resources/pedifoods.png new file mode 100644 index 0000000..45bec47 Binary files /dev/null and b/pedi-foods/Sources/PediFoods/Resources/pedifoods.png differ diff --git a/pedi-foods/Sources/PediFoods/Resources/pedifoods_dark.png b/pedi-foods/Sources/PediFoods/Resources/pedifoods_dark.png new file mode 100644 index 0000000..c94bd7e Binary files /dev/null and b/pedi-foods/Sources/PediFoods/Resources/pedifoods_dark.png differ diff --git a/pedi-foods/Sources/PediFoods/Resources/pin_image_app.png b/pedi-foods/Sources/PediFoods/Resources/pin_image_app.png new file mode 100644 index 0000000..7ed510e Binary files /dev/null and b/pedi-foods/Sources/PediFoods/Resources/pin_image_app.png differ diff --git a/pedi-foods/Sources/PediFoods/Resources/pin_image_app_dark.png b/pedi-foods/Sources/PediFoods/Resources/pin_image_app_dark.png new file mode 100644 index 0000000..450d7bc Binary files /dev/null and b/pedi-foods/Sources/PediFoods/Resources/pin_image_app_dark.png differ diff --git a/pedi-foods/Sources/PediFoods/Resources/placeholder-product.png b/pedi-foods/Sources/PediFoods/Resources/placeholder-product.png new file mode 100644 index 0000000..889fa6c Binary files /dev/null and b/pedi-foods/Sources/PediFoods/Resources/placeholder-product.png differ diff --git a/pedi-foods/Sources/PediFoods/Resources/tracking-canceled.png b/pedi-foods/Sources/PediFoods/Resources/tracking-canceled.png new file mode 100644 index 0000000..6b0acbc Binary files /dev/null and b/pedi-foods/Sources/PediFoods/Resources/tracking-canceled.png differ diff --git a/pedi-foods/Sources/PediFoods/Resources/tracking-completed.png b/pedi-foods/Sources/PediFoods/Resources/tracking-completed.png new file mode 100644 index 0000000..fee3d7f Binary files /dev/null and b/pedi-foods/Sources/PediFoods/Resources/tracking-completed.png differ diff --git a/pedi-foods/Sources/PediFoods/Resources/tracking-delivering.png b/pedi-foods/Sources/PediFoods/Resources/tracking-delivering.png new file mode 100644 index 0000000..2d996ae Binary files /dev/null and b/pedi-foods/Sources/PediFoods/Resources/tracking-delivering.png differ diff --git a/pedi-foods/Sources/PediFoods/Resources/tracking-pending.png b/pedi-foods/Sources/PediFoods/Resources/tracking-pending.png new file mode 100644 index 0000000..a4a9344 Binary files /dev/null and b/pedi-foods/Sources/PediFoods/Resources/tracking-pending.png differ diff --git a/pedi-foods/Sources/PediFoods/Resources/tracking-preparing.png b/pedi-foods/Sources/PediFoods/Resources/tracking-preparing.png new file mode 100644 index 0000000..23fc9a7 Binary files /dev/null and b/pedi-foods/Sources/PediFoods/Resources/tracking-preparing.png differ diff --git a/pedi-foods/Sources/PediFoods/Resources/tracking-ready.png b/pedi-foods/Sources/PediFoods/Resources/tracking-ready.png new file mode 100644 index 0000000..fe2f0f2 Binary files /dev/null and b/pedi-foods/Sources/PediFoods/Resources/tracking-ready.png differ diff --git a/pedi-foods/Sources/PediFoods/Services/ApiClient.swift b/pedi-foods/Sources/PediFoods/Services/ApiClient.swift new file mode 100644 index 0000000..a74256c --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Services/ApiClient.swift @@ -0,0 +1,387 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +#if canImport(LCEssentials) && os(iOS) +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 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 .transportError(let message): + return "Erro de rede: \(message)" + } + } +} + +private 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? + + init(path: String, + method: String = "GET", + module: ApiModule = .none, + requiresAuth: Bool = true, + queryItems: [URLQueryItem] = [], + body: Data? = nil) { + self.path = path + self.method = method + self.module = module + self.requiresAuth = requiresAuth + self.queryItems = queryItems + self.body = body + } +} + +final class ApiClient { + 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 { + +#if canImport(LCEssentials) && os(iOS) + return try await sendWithLCEssentials(request) +#else + return try await sendWithURLSession(request) +#endif + } +} + +private extension ApiClient { +#if canImport(LCEssentials) && os(iOS) + func sendWithLCEssentials(_ request: ApiRequest) async throws -> T { + let urlString = try buildURL(path: request.path, query: request.queryItems).absoluteString + let method = request.method + let headers = buildHeaders(for: request) + let params = request.body + + var attempt = 1 + while attempt <= maxAttempts { + do { + let responseString = try await Self.performLCERequest( + url: urlString, + params: params, + method: method, + headers: headers + ) + guard let data = responseString.data(using: .utf8) else { + throw NetworkError.decodeError("Resposta nao UTF-8") + } + do { + return try JSONDecoder().decode(T.self, from: data) + } catch { + throw NetworkError.decodeError(String(data: data, encoding: .utf8)) + } + } catch { + let mapped = mapError(error) + guard shouldRetry(mapped), attempt < maxAttempts else { + throw mapped + } + try await Task.sleep(nanoseconds: backoff(for: attempt, error: mapped)) + attempt += 1 + } + } + throw NetworkError.invalidResponse + } + + @MainActor + static func performLCERequest( + url: String, + params: Data?, + method: String, + headers: [String: String] + ) async throws -> String { + let httpMethod = toHTTPMethod(method) + return try await API.shared.request( + url: url, + params: params, + method: httpMethod, + headers: headers, + jsonEncoding: true, + debug: true + ) + } + + static func toHTTPMethod(_ method: String) -> httpMethod { + switch method.uppercased() { + case "POST": return .post + case "PUT": return .put + case "DELETE": return .delete + default: return .get + } + } + + func mapError(_ error: Error) -> NetworkError { + 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) + 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 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(String(data: data, encoding: .utf8)) + } + } + + func buildHeaders(for request: ApiRequest) -> [String: String] { + var headers: [String: String] = [ + "Accept": "application/json", + "Content-Type": "application/json" + ] + + if let token = ApiConfig.token(for: request.module) { + headers["Atomenta-Token"] = token + } + if request.requiresAuth, let jwt = tokenStore.jwt { + headers["Authorization"] = "Bearer \(jwt)" + } + return headers + } + + func shouldRetry(_ error: NetworkError) -> Bool { + switch error { + case .rateLimited, .transportError: + return true + case .httpError(let statusCode, _): + return statusCode >= 500 + case .invalidURL, .invalidResponse, .decodeError, .unauthorized, .cancelled: + 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)" + } + return String(data: data, encoding: .utf8) + } + + 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 code = object["code"] as? String + let message = object["message"] as? String + if 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) && os(iOS) + func serverMessage(from error: NSError) -> String? { + if let reason = error.userInfo[NSLocalizedFailureReasonErrorKey] as? String, + !reason.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + if let data = reason.data(using: .utf8), + let parsed = serverMessage(from: data), + !parsed.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return parsed + } + return reason + } + + if let description = error.userInfo[NSLocalizedDescriptionKey] as? String, + !description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + !description.lowercased().contains("nsurlerrordomain") { + return description + } + + return nil + } + + func serverPayload(from error: NSError) -> ApiErrorDescriptor? { + if let reason = error.userInfo[NSLocalizedFailureReasonErrorKey] as? String, + let data = reason.data(using: .utf8), + let payload = serverPayload(from: data) { + return payload + } + return nil + } +#endif + + func buildURL(path: String, query: [URLQueryItem]) throws -> URL { + guard var components = URLComponents(url: ApiConfig.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/pedi-foods/Sources/PediFoods/Services/ApiConfig.swift b/pedi-foods/Sources/PediFoods/Services/ApiConfig.swift new file mode 100644 index 0000000..1fc39db --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Services/ApiConfig.swift @@ -0,0 +1,30 @@ +import Foundation + +enum ApiModule: Sendable { + case app + case customer + case store + case resource + case none +} + +enum ApiConfig { + 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")! + } + + // 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 + } + } +} diff --git a/pedi-foods/Sources/PediFoods/Services/ApiCustomerPayloadModels.swift b/pedi-foods/Sources/PediFoods/Services/ApiCustomerPayloadModels.swift new file mode 100644 index 0000000..a8ff063 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Services/ApiCustomerPayloadModels.swift @@ -0,0 +1,82 @@ +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? + let addressBook: [CustomerAddressPayload] + + enum CodingKeys: String, CodingKey { + case name + case email + case phoneNumber + case phone + case profilePicture + case profile_picture + case addressBook = "address_book" + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(name, forKey: .name) + try container.encode(email, forKey: .email) + try container.encode(phoneNumber, forKey: .phoneNumber) + try container.encode(phoneNumber, forKey: .phone) + try container.encode(addressBook, forKey: .addressBook) + if let profilePicture, profilePicture.isEmpty == false { + try container.encode(profilePicture, forKey: .profilePicture) + try container.encode(profilePicture, forKey: .profile_picture) + } + } +} + +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/pedi-foods/Sources/PediFoods/Services/ApiModels.swift b/pedi-foods/Sources/PediFoods/Services/ApiModels.swift new file mode 100644 index 0000000..c3f3843 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Services/ApiModels.swift @@ -0,0 +1,528 @@ +import Foundation + +// MARK: - DTOs + +struct EmptyResult: Decodable {} + +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 addressBook: [CustomerAddress]? + + enum CodingKeys: String, CodingKey { + case id + case name + case email + case phoneNumber + case profilePicture + case addressBook = "address_book" + } +} + +struct CustomerAddress: Decodable { + let id: String? + let label: String? + let address: String? + let number: String? + let complement: String? + let neighborhood: String? + let city: String? + let state: String? + let zipCode: String? + let latLong: [Double]? + + enum CodingKeys: String, CodingKey { + case id + case label + case address + case number + case complement + case neighborhood + case city + case state + case zipCode + case latLong = "lat_long" + } +} + +struct StoreSummary: Decodable { + 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 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 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) + 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/pedi-foods/Sources/PediFoods/Services/ApiOrderModels.swift b/pedi-foods/Sources/PediFoods/Services/ApiOrderModels.swift new file mode 100644 index 0000000..2e255d3 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Services/ApiOrderModels.swift @@ -0,0 +1,155 @@ +import Foundation + +struct CreateOrderPayload: Encodable { + let customer: CreateOrderCustomerPayload + let items: [CreateOrderItemPayload] + let total: Double + let paymentMethod: String + let deliveryType: String + let address: CreateOrderAddressPayload? +} + +struct CreateOrderCustomerPayload: Encodable { + let name: String + let phone: String + let email: String + let asaasId: String? +} + +struct CreateOrderItemPayload: Encodable { + let productId: String + let name: String + let qty: Int + let price: Double + let addons: [CreateOrderAddonPayload] +} + +struct CreateOrderAddonPayload: Encodable { + 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? +} diff --git a/pedi-foods/Sources/PediFoods/Services/ApiOrderTrackingModels.swift b/pedi-foods/Sources/PediFoods/Services/ApiOrderTrackingModels.swift new file mode 100644 index 0000000..f72d51d --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Services/ApiOrderTrackingModels.swift @@ -0,0 +1,675 @@ +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 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 storeLogo + case store_logo + case logo + 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]) + storeLogoURL = ApiService.decodeFlexibleString(from: container, keys: [.storeLogo, .store_logo, .logo]) + 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 storeLogoURL: String? + let createdAt: String? + let updatedAt: String? + let otp: String? + let customerOtp: String? + let confirmOtp: String? + let cancellationReason: 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 storeLogo + case store_logo + case logo + case createdAt + case updatedAt + case otp + case customerOtp + case confirmOtp + case cancellationReason + case address + case deliveryAddress + case delivery_address + case customerAddress + case customer_address + case review + case orderReview + case items + case timeline + } + + 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, + storeLogoURL: String? = nil, + createdAt: String? = nil, + updatedAt: String? = nil, + otp: String? = nil, + customerOtp: String? = nil, + confirmOtp: String? = nil, + cancellationReason: 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.storeLogoURL = storeLogoURL + self.createdAt = createdAt + self.updatedAt = updatedAt + self.otp = otp + self.customerOtp = customerOtp + self.confirmOtp = confirmOtp + self.cancellationReason = cancellationReason + 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]) + storeLogoURL = ApiService.decodeFlexibleString(from: container, keys: [.storeLogo, .store_logo, .logo]) + createdAt = ApiService.decodeFlexibleString(from: container, keys: [.createdAt]) + 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]) + 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)) ?? [] + } + + 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(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(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 name: String? + let qty: Int? + let price: Double? + + enum CodingKeys: String, CodingKey { + case id + case name + case qty + case quantity + case price + } + + init(id: String = UUID().uuidString, name: String?, qty: Int?, price: Double?) { + self.id = id + 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 + 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(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? + + enum CodingKeys: String, CodingKey { + case id + case status + case label + case active + case completed + case message + case time + case createdAt + case updatedAt + } + + init( + id: String = UUID().uuidString, + status: String?, + label: String? = nil, + active: Bool? = nil, + completed: Bool? = nil, + message: String?, + time: String? + ) { + self.id = id + self.status = status + self.label = label + self.active = active + self.completed = completed + self.message = message + self.time = time + } + + 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) + time = (try? container.decode(String.self, forKey: .time)) + ?? (try? container.decode(String.self, forKey: .createdAt)) + ?? (try? container.decode(String.self, forKey: .updatedAt)) + } + + 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) + } +} + +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/pedi-foods/Sources/PediFoods/Services/ApiPizzaModels.swift b/pedi-foods/Sources/PediFoods/Services/ApiPizzaModels.swift new file mode 100644 index 0000000..56c0b75 --- /dev/null +++ b/pedi-foods/Sources/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/pedi-foods/Sources/PediFoods/Services/ApiReviewModels.swift b/pedi-foods/Sources/PediFoods/Services/ApiReviewModels.swift new file mode 100644 index 0000000..1a356f0 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Services/ApiReviewModels.swift @@ -0,0 +1,270 @@ +import Foundation + +enum ReviewPlatform: String, Encodable { + case ios + case android + case web + + static var current: ReviewPlatform { + #if os(iOS) + return .ios + #elseif os(Android) + return .android + #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/pedi-foods/Sources/PediFoods/Services/ApiService.swift b/pedi-foods/Sources/PediFoods/Services/ApiService.swift new file mode 100644 index 0000000..da0e965 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Services/ApiService.swift @@ -0,0 +1,445 @@ +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 { + 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 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 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 isSessionExpiredEnvelope(envelope) { + expireSession(envelope.message) + throw ApiServiceError.sessionExpired(envelope.message) + } + return envelope + } + + private func isSessionExpiredEnvelope(_ envelope: ApiEnvelope) -> Bool { + guard envelope.error else { return false } + let code = (envelope.code ?? "").lowercased() + let message = (envelope.message ?? "").lowercased() + if code.contains("auth") || code.contains("token") || code.contains("unauthorized") { + return true + } + if message.contains("token") && (message.contains("expir") || message.contains("invalid") || message.contains("sess")) { + return true + } + return false + } + + private func expireSession(_ message: String?) { + tokenStore.clear() + AppContentCache.shared.invalidate(prefix: profileCachePrefix) + AppContentCache.shared.invalidate(prefix: ordersCachePrefix) + AppContentCache.shared.invalidate(prefix: publicCategoriesCacheKey) + NotificationCenter.default.post(name: .sessionExpired, object: message) + } + + 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 + } + 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 -> ApiEnvelope { + let currentProfile = try await profile(forceRefresh: true) + guard currentProfile.error == false, let customer = currentProfile.result else { + throw NetworkError.invalidResponse + } + + let payload = CustomerIdentityUpdatePayload( + name: name, + email: email, + phoneNumber: phoneNumber, + profilePicture: profilePicture, + addressBook: (customer.addressBook ?? []).map(CustomerAddressPayload.init(from:)) + ) + 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) + let scopedProfileKey = "\(profileCachePrefix)\(scopedCacheSuffix())" + if envelope.error == false, envelope.result != nil { + AppContentCache.shared.set(envelope, for: scopedProfileKey, ttl: AppCacheTTL.twoHours) + } else { + AppContentCache.shared.invalidate(prefix: profileCachePrefix) + } + return envelope + } + + 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 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) + let scopedProfileKey = "\(profileCachePrefix)\(scopedCacheSuffix())" + if envelope.error == false, envelope.result != nil { + AppContentCache.shared.set(envelope, for: scopedProfileKey, ttl: AppCacheTTL.twoHours) + } else { + AppContentCache.shared.invalidate(prefix: profileCachePrefix) + } + return envelope + } + + 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 + } + + let req = ApiRequest(path: "/api/public/categories", method: "GET", module: .none, requiresAuth: false) + 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) + } +} +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/pedi-foods/Sources/PediFoods/Services/AppCache.swift b/pedi-foods/Sources/PediFoods/Services/AppCache.swift new file mode 100644 index 0000000..b990e60 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Services/AppCache.swift @@ -0,0 +1,154 @@ +import Foundation +#if canImport(UIKit) +import UIKit +typealias PlatformImage = UIImage +#elseif canImport(AppKit) +import AppKit +typealias PlatformImage = NSImage +#endif + +enum AppCacheTTL { + static let twoHours: TimeInterval = 2 * 60 * 60 + 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) + } + } + } +} + +#if canImport(UIKit) || canImport(AppKit) +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? { +#if canImport(UIKit) + return UIImage(data: data) +#elseif canImport(AppKit) + return NSImage(data: data) +#else + return nil +#endif + } +} +#endif + +#if !(canImport(UIKit) || canImport(AppKit)) +final class AppImageCache: @unchecked Sendable { + static let shared = AppImageCache() + + private init() {} + + func invalidateAll() {} +} +#endif diff --git a/pedi-foods/Sources/PediFoods/Services/ImageSourceResolver.swift b/pedi-foods/Sources/PediFoods/Services/ImageSourceResolver.swift new file mode 100644 index 0000000..54dbdfa --- /dev/null +++ b/pedi-foods/Sources/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/pedi-foods/Sources/PediFoods/Services/LocationService.swift b/pedi-foods/Sources/PediFoods/Services/LocationService.swift new file mode 100644 index 0000000..11cd212 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Services/LocationService.swift @@ -0,0 +1,136 @@ +import Foundation + +#if os(iOS) +import CoreLocation +#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 = CLLocationManager() + private var completion: ((LocationResult) -> Void)? +#endif + + override init() { + super.init() +#if os(iOS) + manager.delegate = self + manager.desiredAccuracy = kCLLocationAccuracyHundredMeters +#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 + } + + 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/pedi-foods/Sources/PediFoods/Services/OrderRealtimeTracker.swift b/pedi-foods/Sources/PediFoods/Services/OrderRealtimeTracker.swift new file mode 100644 index 0000000..81548f9 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Services/OrderRealtimeTracker.swift @@ -0,0 +1,253 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +@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, privacy: .public)") + 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, privacy: .public) message=\((response.message ?? "unknown"), privacy: .public)") + return nil + } + clearPendingCartIfNeeded(for: order) + logger.info("OrderTracking poll success orderId=\(orderId, privacy: .public) status=\((order.status ?? "nil"), privacy: .public) paymentStatus=\((order.paymentStatus ?? "nil"), privacy: .public)") + onOrderUpdated?(order) + return order + } catch { + logger.error("OrderTracking poll failure orderId=\(orderId, privacy: .public) error=\(error.localizedDescription, privacy: .public)") + 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/pedi-foods/Sources/PediFoods/Services/SessionEvents.swift b/pedi-foods/Sources/PediFoods/Services/SessionEvents.swift new file mode 100644 index 0000000..4746314 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Services/SessionEvents.swift @@ -0,0 +1,7 @@ +import Foundation + +extension Notification.Name { + static let sessionExpired = Notification.Name("SessionExpiredNotification") + static let cartDidReset = Notification.Name("CartDidResetNotification") + static let orderReviewDidSave = Notification.Name("OrderReviewDidSaveNotification") +} diff --git a/pedi-foods/Sources/PediFoods/Services/SessionStateStore.swift b/pedi-foods/Sources/PediFoods/Services/SessionStateStore.swift new file mode 100644 index 0000000..b1457da --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Services/SessionStateStore.swift @@ -0,0 +1,441 @@ +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 { + 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." + + static func makeUserKey(profileId: String?, email: String?) -> String? { + let id = (profileId ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + if id.isEmpty == false { + return "id:\(id)" + } + + let mail = (email ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + if mail.isEmpty == false { + return "email:\(mail)" + } + + return nil + } + + static func setActiveUserKey(_ userKey: String?) { + let trimmed = (userKey ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + UserDefaults.standard.removeObject(forKey: activeUserKey) + } else { + UserDefaults.standard.set(trimmed, forKey: activeUserKey) + } + } + + static func loadActiveUserKey() -> String? { + let value = UserDefaults.standard.string(forKey: activeUserKey)? + .trimmingCharacters(in: .whitespacesAndNewlines) + if let value, value.isEmpty == false { + return value + } + return nil + } + + private static func addressStorageKey(for userKey: String?) -> String { + let key = (userKey ?? loadActiveUserKey() ?? "anonymous") + .trimmingCharacters(in: .whitespacesAndNewlines) + let safe = key.replacingOccurrences(of: " ", with: "_") + return addressKeyPrefix + safe + } + + static func loadAddress() -> AddressState? { + let defaults = UserDefaults.standard + let activeKey = loadActiveUserKey() + let scopedKey = addressStorageKey(for: activeKey) + + if let data = defaults.data(forKey: scopedKey), + let decoded = try? JSONDecoder().decode(PersistedAddressState.self, from: data) { + return AddressState( + selectedId: decoded.selectedId, + display: decoded.display.isEmpty ? "Defina seu endereco" : decoded.display, + latitude: decoded.latitude, + longitude: decoded.longitude, + onboardingMessage: nil + ) + } + + // Backward-compatible fallback for data persisted before user scoping. + let anonymousKey = addressStorageKey(for: "anonymous") + if let data = defaults.data(forKey: anonymousKey), + let decoded = try? JSONDecoder().decode(PersistedAddressState.self, from: data) { + let recovered = AddressState( + selectedId: decoded.selectedId, + display: decoded.display.isEmpty ? "Defina seu endereco" : decoded.display, + latitude: decoded.latitude, + longitude: decoded.longitude, + onboardingMessage: nil + ) + + // Migrate anonymous cache into the current active user namespace. + if let activeKey, activeKey.isEmpty == false { + let payload = PersistedAddressState( + selectedId: recovered.selectedId, + display: recovered.display, + latitude: recovered.latitude, + longitude: recovered.longitude + ) + if let migratedData = try? JSONEncoder().encode(payload) { + defaults.set(migratedData, forKey: scopedKey) + } + } + + return recovered + } + + guard let data = defaults.data(forKey: legacyAddressKey), + let decoded = try? JSONDecoder().decode(PersistedAddressState.self, from: data) else { + return nil + } + + let migrated = AddressState( + selectedId: decoded.selectedId, + display: decoded.display.isEmpty ? "Defina seu endereco" : decoded.display, + latitude: decoded.latitude, + longitude: decoded.longitude, + onboardingMessage: nil + ) + saveAddress(migrated) + defaults.removeObject(forKey: legacyAddressKey) + return migrated + } + + static func saveAddress(_ state: AddressState) { + let payload = PersistedAddressState( + selectedId: state.selectedId, + display: state.display, + latitude: state.latitude, + longitude: state.longitude + ) + guard let data = try? JSONEncoder().encode(payload) else { return } + UserDefaults.standard.set(data, forKey: addressStorageKey(for: nil)) + } + + static func clearAddress() { + let defaults = UserDefaults.standard + defaults.removeObject(forKey: addressStorageKey(for: nil)) + defaults.removeObject(forKey: legacyAddressKey) + } + + static func clearActiveUser() { + UserDefaults.standard.removeObject(forKey: activeUserKey) + } + + 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 defaults = UserDefaults.standard + 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 } + UserDefaults.standard.set(data, forKey: cartStorageKey(for: nil)) + } + + static func clearCart() { + UserDefaults.standard.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 defaults = UserDefaults.standard + let key = trackedOrdersStorageKey(for: nil) + guard let data = defaults.data(forKey: key), + let decoded = try? JSONDecoder().decode(PersistedTrackedOrdersState.self, from: data) else { + 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 } + UserDefaults.standard.set(data, forKey: trackedOrdersStorageKey(for: nil)) + } + + static func clearTrackedOrders() { + UserDefaults.standard.removeObject(forKey: trackedOrdersStorageKey(for: nil)) + } + + static func savePendingCartOrderId(_ orderId: String) { + let clean = orderId.trimmingCharacters(in: .whitespacesAndNewlines) + guard clean.isEmpty == false else { + clearPendingCartOrder() + return + } + UserDefaults.standard.set(clean, forKey: pendingCartOrderStorageKey(for: nil)) + } + + static func loadPendingCartOrderId() -> String? { + let value = UserDefaults.standard.string(forKey: pendingCartOrderStorageKey(for: nil))? + .trimmingCharacters(in: .whitespacesAndNewlines) + if let value, value.isEmpty == false { + return value + } + return nil + } + + static func clearPendingCartOrder() { + UserDefaults.standard.removeObject(forKey: pendingCartOrderStorageKey(for: nil)) + } + + static func loadOrderReviews() -> [OrderReviewRecord] { + let key = orderReviewsStorageKey(for: nil) + guard let data = UserDefaults.standard.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 } + UserDefaults.standard.set(data, forKey: orderReviewsStorageKey(for: nil)) + } + + static func loadOrderReviewDraft(orderId: String) -> OrderReviewDraftState? { + let key = orderReviewDraftStorageKey(for: orderId, userKey: nil) + guard let data = UserDefaults.standard.data(forKey: key), + 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 } + UserDefaults.standard.set(data, forKey: key) + } + + static func clearOrderReviewDraft(orderId: String) { + let key = orderReviewDraftStorageKey(for: orderId, userKey: nil) + UserDefaults.standard.removeObject(forKey: key) + } +} diff --git a/pedi-foods/Sources/PediFoods/Services/TokenStore.swift b/pedi-foods/Sources/PediFoods/Services/TokenStore.swift new file mode 100644 index 0000000..7813a31 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Services/TokenStore.swift @@ -0,0 +1,91 @@ +import Foundation +#if os(iOS) +import Security +#endif + +protocol TokenStore: AnyObject { + var jwt: String? { get set } + func clear() +} + +final class DefaultTokenStore: TokenStore { + private let key = "auth_jwt" + private let defaults = UserDefaults.standard + + var jwt: String? { + get { +#if os(iOS) + if let keychainValue = loadKeychainValue(for: key) { + return keychainValue + } +#endif + return defaults.string(forKey: key) + } + set { +#if os(iOS) + if let newValue { + saveKeychainValue(newValue, for: key) + } else { + deleteKeychainValue(for: key) + } +#endif + defaults.set(newValue, forKey: key) + } + } + + func clear() { +#if os(iOS) + deleteKeychainValue(for: key) +#endif + defaults.removeObject(forKey: key) + } + +#if os(iOS) + private var serviceName: String { "com.br.pedifoods.app.auth" } + + private func saveKeychainValue(_ value: String, for key: String) { + guard let data = value.data(using: .utf8) else { return } + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecAttrAccount as String: key + ] + + SecItemDelete(query as CFDictionary) + let attributes: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecAttrAccount as String: key, + kSecValueData as String: data + ] + SecItemAdd(attributes as CFDictionary, nil) + } + + private func loadKeychainValue(for key: String) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + 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 + } + + private func deleteKeychainValue(for key: String) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecAttrAccount as String: key + ] + SecItemDelete(query as CFDictionary) + } +#endif +} diff --git a/pedi-foods/Sources/PediFoods/State/AppState.swift b/pedi-foods/Sources/PediFoods/State/AppState.swift new file mode 100644 index 0000000..1b3de3d --- /dev/null +++ b/pedi-foods/Sources/PediFoods/State/AppState.swift @@ -0,0 +1,165 @@ +import Foundation + +struct AppState { + var session = SessionState() + var profile = ProfileState() + var cart = CartState() + var address = AddressState() + var favorites = FavoritesState() + var activeModal: AppModal? = nil +} + +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 = "" +} + +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 = [] +} + +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 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 + ) + } + ) + } + } +} diff --git a/pedi-foods/Sources/PediFoods/State/SnackbarCenter.swift b/pedi-foods/Sources/PediFoods/State/SnackbarCenter.swift new file mode 100644 index 0000000..093ed8e --- /dev/null +++ b/pedi-foods/Sources/PediFoods/State/SnackbarCenter.swift @@ -0,0 +1,99 @@ +import Foundation +import SwiftUI + +#if os(Android) +@MainActor +final class SnackbarCenter { + static let shared = SnackbarCenter() + var current: SnackbarMessage? + + func show( + title: String, + style: SnackbarStyle = .info, + icon: String? = nil, + duration: TimeInterval = 3.5, + action: (() -> Void)? = nil + ) { + action?() + } + + func handleTap() {} + func dismiss(animated: Bool) {} +} +#else +@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, + action: (() -> Void)? = nil + ) { + dismissTask?.cancel() + current = SnackbarMessage( + title: title, + style: style, + iconSystemName: icon, + duration: duration, + action: action + ) + + 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() { + 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 + } + } +} +#endif + +enum SnackbarStyle: Sendable { + 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 action: (() -> Void)? +} diff --git a/pedi-foods/Sources/PediFoods/Views/Auth/AuthFlowView.swift b/pedi-foods/Sources/PediFoods/Views/Auth/AuthFlowView.swift new file mode 100644 index 0000000..1dc0aa9 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Auth/AuthFlowView.swift @@ -0,0 +1,47 @@ +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 + var shouldPrepareLoginEntry: Bool = false + var authEntryAnimationToken: Int = 0 + + @State var path: [Route] = [] + + var body: some View { + NavigationStack(path: $path) { + LoginView( + root: $root, + selectedTab: $selectedTab, + tokenStore: tokenStore, + appState: $appState, + shouldPrepareEntryAnimation: shouldPrepareLoginEntry, + authEntryAnimationToken: authEntryAnimationToken + ) { 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/pedi-foods/Sources/PediFoods/Views/Auth/AuthFormatters.swift b/pedi-foods/Sources/PediFoods/Views/Auth/AuthFormatters.swift new file mode 100644 index 0000000..cf300fa --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Auth/AuthFormatters.swift @@ -0,0 +1,86 @@ +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" + } + } + + return "Não foi possível concluir a operação. Tente novamente." +} diff --git a/pedi-foods/Sources/PediFoods/Views/Auth/LegalViews.swift b/pedi-foods/Sources/PediFoods/Views/Auth/LegalViews.swift new file mode 100644 index 0000000..d875f39 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Auth/LegalViews.swift @@ -0,0 +1,47 @@ +import SwiftUI + +struct TermsOfUseView: View { + @Environment(\.colorScheme) var colorScheme + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + Text("Termos de Uso") + .font(AppTypography.heading1) + .foregroundStyle(AppColors.textPrimary) + Text("Conteúdo dos termos de uso...") + .font(AppTypography.body) + .foregroundStyle(AppColors.textPrimary) + } + .padding(24) + } + .background((colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea()) + .navigationTitle("Termos de Uso") +#if os(iOS) + .navigationBarTitleDisplayMode(.inline) +#endif + } +} + +struct PrivacyPolicyView: View { + @Environment(\.colorScheme) var colorScheme + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + Text("Política de Privacidade") + .font(AppTypography.heading1) + .foregroundStyle(AppColors.textPrimary) + Text("Conteúdo da política de privacidade...") + .font(AppTypography.body) + .foregroundStyle(AppColors.textPrimary) + } + .padding(24) + } + .background((colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea()) + .navigationTitle("Privacidade") +#if os(iOS) + .navigationBarTitleDisplayMode(.inline) +#endif + } +} diff --git a/pedi-foods/Sources/PediFoods/Views/Auth/LoginEmailView.swift b/pedi-foods/Sources/PediFoods/Views/Auth/LoginEmailView.swift new file mode 100644 index 0000000..f48022f --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Auth/LoginEmailView.swift @@ -0,0 +1,148 @@ +import SwiftUI + +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 + @Environment(\.colorScheme) var colorScheme + + @ViewBuilder private var logoImage: some View { +#if os(Android) + SwiftUI.Image("pedifoods") + .resizable() +#else + SwiftUI.Image("pedifoods") + .resizable() +#endif + } + + var body: some View { + ZStack { + (colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea() + + VStack(spacing: 0) { + logoImage + .scaledToFit() + .frame(width: 120, height: 120) + + Text("Boas-vindas!") + .font(AppTypography.heading1) + .foregroundStyle(colorScheme == .dark ? Color.white : 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", text: $email) + + LoginField(icon: "phone", placeholder: "(00) 00000-0000", 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 \nou 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(colorScheme == .dark ? Color.white.opacity(0.8) : Color.gray) + Text("Crie sua conta") + .foregroundStyle(AppColors.primary) + .onTapGesture { + dismiss() + } + } + .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 + @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) + } + .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/pedi-foods/Sources/PediFoods/Views/Auth/LoginView.swift b/pedi-foods/Sources/PediFoods/Views/Auth/LoginView.swift new file mode 100644 index 0000000..5988f7a --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Auth/LoginView.swift @@ -0,0 +1,158 @@ +import SwiftUI + +struct LoginView: View { + @Binding var root: RootFlow + @Binding var selectedTab: MainTab + let tokenStore: TokenStore + @Binding var appState: AppState + @Environment(\.colorScheme) var colorScheme + var shouldPrepareEntryAnimation: Bool = false + var authEntryAnimationToken: Int = 0 + let navigate: (Route) -> Void + @State var heroVisible = true + @State var textVisible = true + @State var buttonVisible = true + @State var lastAnimatedToken = 0 + + @ViewBuilder private var logoImage: some View { +#if os(Android) + SwiftUI.Image("pedifoods") + .resizable() +#else + SwiftUI.Image("pedifoods") + .resizable() +#endif + } + + @ViewBuilder private var pinHeroImage: some View { +#if os(Android) + SwiftUI.Image(colorScheme == .dark ? "pin_image_app_dark" : "pin_image_app") + .resizable() +#else + SwiftUI.Image("pin_image_app") + .resizable() +#endif + } + + var body: some View { + GeometryReader { geo in + let heroHeight = max(360, geo.size.height * 0.44) + let logoTopInset = max(0, (geo.size.height - 180) / 2) + + ZStack(alignment: .top) { + (colorScheme == .dark ? Color.black : AppColors.backgroundLight) + .ignoresSafeArea() + + pinHeroImage + .scaledToFill() + .frame(height: heroHeight + 80) + .offset(y: heroVisible ? -60 : -(heroHeight + 220)) + .mask( + LinearGradient( + colors: [.black, .black, .black.opacity(0.0)], + startPoint: .top, + endPoint: .bottom + ) + ) + + VStack(spacing: 18) { + Spacer().frame(height: logoTopInset) + + logoImage + .scaledToFit() + .frame(height: 180) + + Text("Sua vontade, no seu tempo.\nTudo o que você precisa, em um toque.") + .font(AppTypography.heading25) + .foregroundStyle(colorScheme == .dark ? AppColors.textInverse : AppColors.textPrimary) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 28) + .opacity(textVisible ? 1.0 : 0.0) + .offset(y: textVisible ? 0 : 24) + + Spacer().frame(height: 40) + + Button { + navigate(.loginEmail) + } label: { + PrimaryButtonLabel(title: "ENTRAR") + } + .padding(.horizontal, 28) + .tint(AppColors.tertiary) + .offset(y: buttonVisible ? 0 : 140) + .opacity(buttonVisible ? 1.0 : 0.0) + + HStack(spacing: 6) { + Text("Não tem conta ainda?") + .foregroundStyle(colorScheme == .dark ? AppColors.textInverse.opacity(0.9) : AppColors.textPrimary) + Button("Criar conta") { + navigate(.registration) + } + .foregroundStyle(AppColors.primary) + } + .font(AppTypography.body) + .opacity(textVisible ? 1.0 : 0.0) + .offset(y: textVisible ? 0 : 24) + + Spacer().frame(height: 12) + } + } + .ignoresSafeArea() + .onAppear { + if shouldPrepareEntryAnimation { + applyHiddenStateWithoutAnimation() + } else { + showFinalStateWithoutAnimation() + } + } + .task(id: authEntryAnimationToken) { + await runEntryAnimationIfNeeded(for: authEntryAnimationToken) + } + } + } + + @MainActor + private func applyHiddenStateWithoutAnimation() { + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + heroVisible = false + textVisible = false + buttonVisible = false + } + } + + @MainActor + private func showFinalStateWithoutAnimation() { + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + heroVisible = true + textVisible = true + buttonVisible = true + } + } + + @MainActor + private func runEntryAnimationIfNeeded(for token: Int) async { + guard token > 0 else { return } + guard token != lastAnimatedToken else { return } + lastAnimatedToken = token + + applyHiddenStateWithoutAnimation() + try? await Task.sleep(nanoseconds: 40_000_000) + + withAnimation(.spring(response: 0.64, dampingFraction: 0.9)) { + heroVisible = true + } + try? await Task.sleep(nanoseconds: 160_000_000) + withAnimation(.easeOut(duration: 0.42)) { + textVisible = true + } + try? await Task.sleep(nanoseconds: 150_000_000) + withAnimation(.spring(response: 0.52, dampingFraction: 0.86)) { + buttonVisible = true + } + } +} diff --git a/pedi-foods/Sources/PediFoods/Views/Auth/OtpView.swift b/pedi-foods/Sources/PediFoods/Views/Auth/OtpView.swift new file mode 100644 index 0000000..c47c965 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Auth/OtpView.swift @@ -0,0 +1,369 @@ +import SwiftUI +#if canImport(UIKit) +import UIKit +#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 { + ScrollView { + VStack(spacing: 0) { + SwiftUI.Image("pedifoods") + .resizable() + .scaledToFit() + .frame(width: 74, height: 74) + .padding(.top, 140) + + 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("para \(email)") + .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 + } + } + } + .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() + } + .font(AppTypography.heading3) + .foregroundStyle(AppColors.primary) + .disabled(isResending || !canResend) + } + .padding(.top, 22) + + Button("Colar código") { + pasteOtpFromClipboard() + } + .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) + } + } + .background(AppColors.backgroundLight) + .ignoresSafeArea() + .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() { +#if canImport(UIKit) + let raw = UIPasteboard.general.string ?? "" + let digits = raw.filter(\.isNumber) + let trimmed = String(digits.prefix(8)) + if trimmed.isEmpty == false { + otp = trimmed + } +#elseif canImport(AppKit) + let raw = NSPasteboard.general.string(forType: .string) ?? "" + let digits = raw.filter(\.isNumber) + let trimmed = String(digits.prefix(8)) + if trimmed.isEmpty == false { + otp = trimmed + } +#endif + } + + private func autoFillOtpFromClipboardIfAvailable() { +#if canImport(UIKit) + guard otp.isEmpty else { return } + let raw = UIPasteboard.general.string ?? "" + let digits = raw.filter(\.isNumber) + guard digits.count >= 8 else { return } + otp = String(digits.prefix(8)) +#endif + } + + 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) + } + } 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 ?? "" + 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 + 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/pedi-foods/Sources/PediFoods/Views/Auth/RegistrationView.swift b/pedi-foods/Sources/PediFoods/Views/Auth/RegistrationView.swift new file mode 100644 index 0000000..58abc04 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Auth/RegistrationView.swift @@ -0,0 +1,173 @@ +import SwiftUI + +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 + @Environment(\.colorScheme) var colorScheme + 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 { +#if os(Android) + SwiftUI.Image("pedifoods") + .resizable() +#else + SwiftUI.Image("pedifoods") + .resizable() +#endif + } + + var body: some View { + ZStack { + (colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea() + + ScrollView { + VStack(spacing: 0) { + logoImage + .scaledToFit() + .frame(width: 120, height: 120) + + Text("Crie sua conta") + .font(AppTypography.heading1) + .foregroundStyle(colorScheme == .dark ? Color.white : AppColors.textPrimary) + + Text("Preencha os dados abaixo para começar.") + .font(AppTypography.body) + .foregroundStyle(colorScheme == .dark ? Color.white : AppColors.textPrimary) + .padding(.top, 8) + .padding(.bottom, 20) + + VStack(spacing: 16) { + VStack(alignment: .leading, spacing: 16) { + LoginField(icon: "person", placeholder: "Ex: Maria Silva", text: $name) + LoginField(icon: "envelope", placeholder: "seu@email.com", text: $email) + LoginField(icon: "phone", placeholder: "(00) 00000-0000", 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(colorScheme == .dark ? Color.white : 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(colorScheme == .dark ? Color.white.opacity(0.8) : 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) + } + } + .padding(.top, -40) + } + } + + 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/pedi-foods/Sources/PediFoods/Views/Launch/LaunchSplashView.swift b/pedi-foods/Sources/PediFoods/Views/Launch/LaunchSplashView.swift new file mode 100644 index 0000000..3b076d1 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Launch/LaunchSplashView.swift @@ -0,0 +1,47 @@ +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 { +#if os(Android) + SwiftUI.Image("pedifoods") + .resizable() +#else + SwiftUI.Image("pedifoods") + .resizable() +#endif + } +} diff --git a/pedi-foods/Sources/PediFoods/Views/Main/AddAddressFormView.swift b/pedi-foods/Sources/PediFoods/Views/Main/AddAddressFormView.swift new file mode 100644 index 0000000..a077f12 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Main/AddAddressFormView.swift @@ -0,0 +1,251 @@ +import SwiftUI + +struct AddAddressFormView: View { + @Environment(\.dismiss) var dismiss + @Environment(\.colorScheme) var colorScheme + @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 + } + + @ViewBuilder private var logoImage: some View { +#if os(Android) + SwiftUI.Image("pedifoods") + .resizable() +#else + SwiftUI.Image("pedifoods") + .resizable() +#endif + } + + var body: some View { + ZStack { + (colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea() + + ScrollView { + VStack(spacing: 0) { + logoImage + .scaledToFit() + .frame(width: 120, height: 120) + + Text(existingAddress == nil ? "Novo endereço" : "Editar endereço") + .font(AppTypography.heading1) + .foregroundStyle(colorScheme == .dark ? Color.white : AppColors.textPrimary) + + Text(existingAddress == nil ? "Preencha os dados abaixo para adicionar um endereço." : "Atualize os dados do endereço abaixo.") + .font(AppTypography.body) + .foregroundStyle(colorScheme == .dark ? Color.white : AppColors.textPrimary) + .multilineTextAlignment(.center) + .padding(.top, 8) + .padding(.bottom, 20) + + VStack(alignment: .leading, spacing: 16) { + LoginField(icon: "tag", placeholder: "Ex: Casa, Trabalho", text: $label) + LoginField(icon: "mail", placeholder: "CEP", 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", text: $address) + LoginField(icon: "number", placeholder: "Número", text: $number) + LoginField(icon: "plus.app", placeholder: "Complemento (opcional)", text: $complement) + LoginField(icon: "square.grid.2x2", placeholder: "Bairro", text: $neighborhood) + LoginField(icon: "building.2", placeholder: "Cidade", text: $city) + LoginField(icon: "map", placeholder: "Estado (UF)", 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, 0) + } + .navigationBarBackButtonHidden(true) + .toolbar(.hidden, for: .navigationBar) + .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 + ) + 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/pedi-foods/Sources/PediFoods/Views/Main/AddressComponents.swift b/pedi-foods/Sources/PediFoods/Views/Main/AddressComponents.swift new file mode 100644 index 0000000..07766a4 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Main/AddressComponents.swift @@ -0,0 +1,203 @@ +import SwiftUI +#if canImport(UIKit) +import UIKit +#endif + +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() { +#if canImport(UIKit) + UIImpactFeedbackGenerator(style: .light).impactOccurred() +#endif +} + +func triggerSelectionHaptic() { +#if canImport(UIKit) + UISelectionFeedbackGenerator().selectionChanged() +#endif +} + +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 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) + .truncationMode(.tail) + .minimumScaleFactor(0.9) + + if item.isPrimary { + Text("PRINCIPAL") + .font(AppTypography.overline) + .foregroundStyle(AppColors.textPrimary) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + .padding(.horizontal, 9) + .padding(.vertical, 5) + .background(AppColors.tertiary) + .clipShape(Capsule()) + } + } + + Text(item.detail) + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + .lineLimit(2) + } + + Spacer(minLength: 8) + + if onEdit != nil || onDelete != nil { + Rectangle() + .fill(AppColors.backgroundLight) + .frame(width: 1, height: 96) + } + + VStack(spacing: 24) { + if let onEdit { + Button(action: onEdit) { + Image(systemName: "pencil") + .font(.system(size: 22)) + .foregroundStyle(AppColors.textMuted) + } + } + + if let onDelete { + Button(action: onDelete) { + Image(systemName: "trash") + .font(.system(size: 22)) + .foregroundStyle(AppColors.textMuted) + } + } + } + .frame(width: onEdit != nil || onDelete != nil ? 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/pedi-foods/Sources/PediFoods/Views/Main/AddressesView.swift b/pedi-foods/Sources/PediFoods/Views/Main/AddressesView.swift new file mode 100644 index 0000000..f710b00 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Main/AddressesView.swift @@ -0,0 +1,375 @@ +import SwiftUI + +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 + + let tabBarClearance: CGFloat = 96 + + var body: some View { + ZStack { + AppColors.backgroundLight + .ignoresSafeArea() + + ScrollView(showsIndicators: false) { + VStack(spacing: 20) { + header + + 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 isSelected: Bool = { + if let selectedId = appState.address.selectedId { + return address.id == selectedId + } + return index == 0 + }() + + if selectionMode { + Button { + selectAddress(address) + } label: { + AddressCard(item: addressToListItem(address, isPrimary: isSelected)) + } + .buttonStyle(.plain) + } else { + SwipeToDeleteAddressRow( + rowId: rowId, + openRowId: $openSwipeRowId, + isDeleting: deletingRowId == rowId, + onDelete: { deleteAddress(address, rowId: rowId) } + ) { + AddressCard( + item: addressToListItem(address, isPrimary: isSelected), + onEdit: { beginEditing(address) } + ) + .contentShape(Rectangle()) + .onTapGesture { + if openSwipeRowId == rowId { + openSwipeRowId = nil + } + } + } + .id(rowId) + .opacity(deletingRowId == rowId ? 0.6 : 1.0) + .disabled(deletingRowId != nil) + } + } + } + } + } + .padding(.horizontal, 20) + .padding(.top, 18) + } + + VStack { + Spacer() + bottomOverlay + .padding(.bottom, tabBarClearance) + } + + } + .navigationBarBackButtonHidden(true) + .toolbar(.hidden, for: .navigationBar) + .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 header: some View { + ZStack { + Text("Meus Endereços") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + + HStack { + 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) + } + + Spacer() + } + } + } + + 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 deleteAddress(_ address: CustomerAddress, rowId: String) { + guard 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() + 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 ?? "" + 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/pedi-foods/Sources/PediFoods/Views/Main/CartView.swift b/pedi-foods/Sources/PediFoods/Views/Main/CartView.swift new file mode 100644 index 0000000..ffd02e2 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Main/CartView.swift @@ -0,0 +1,341 @@ +import Foundation +import SwiftUI + +struct CartView: View { + @Binding var appState: AppState + @State var openCheckout = false + @State var couponCode = "" + @State var appliedCouponCode: String? = nil + @State var discountValue: Double = 0 + @State var deliveryFee: Double? = nil + @State var selectedCustomerAddress: CustomerAddress? = nil + @State var isLoadingDeliveryFee = false + + var body: some View { + ScrollView(showsIndicators: false) { + VStack(spacing: 16) { + Text("Meu Carrinho") + .font(AppTypography.heading1) + .foregroundStyle(AppColors.textPrimary) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.top, 20) + + 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) + } + .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) - discountValue) + } + + 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: "-\(formatCurrency(discountValue))", valueColor: Color.red) + + Divider() + + summaryRow(title: "Total", value: formatCurrency(totalValue), highlighted: true) + + Button { + 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 + discountValue = 0 + return + } + + if normalized == "DESCONTO10" { + appliedCouponCode = normalized + discountValue = min(subtotalValue, subtotalValue * 0.1) + return + } + + appliedCouponCode = nil + discountValue = 0 + } + + 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() + let addresses = profileResponse.result?.addressBook ?? [] + + if let selectedId = appState.address.selectedId, selectedId.isEmpty == false { + selectedCustomerAddress = addresses.first(where: { $0.id == selectedId }) + } else { + selectedCustomerAddress = nil + } + + 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.selectedId = selected.id + let label = (selected.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if label.isEmpty == false { + appState.address.display = label + } + if let lat = selected.latLong?.first, let lng = selected.latLong?.dropFirst().first { + appState.address.latitude = lat + appState.address.longitude = lng + } + SessionStateStore.saveAddress(appState.address) + } + + let payloadLat = selectedCustomerAddress?.latLong?.first ?? appState.address.latitude + let payloadLng = selectedCustomerAddress?.latLong?.dropFirst().first ?? appState.address.longitude + + 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/pedi-foods/Sources/PediFoods/Views/Main/CheckoutTypes.swift b/pedi-foods/Sources/PediFoods/Views/Main/CheckoutTypes.swift new file mode 100644 index 0000000..d86090b --- /dev/null +++ b/pedi-foods/Sources/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/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView+Logic.swift b/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView+Logic.swift new file mode 100644 index 0000000..250dcb3 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView+Logic.swift @@ -0,0 +1,377 @@ +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 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 + 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() + 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 ?? "" + 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 + } + + 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, + 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 { + selectedCustomerAddress = addresses.first + } + + if let selected = selectedCustomerAddress { + appState.address.selectedId = selected.id + let label = (selected.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if label.isEmpty == false { + appState.address.display = label + } + 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 { + 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 + + 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 + } + + let payloadBuildResult = buildCreateOrderPayload(paymentMethod: effectivePaymentMethod) + 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) + 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 + let isInAppMethod = availableInAppPaymentMethods.contains(effectivePaymentMethod) + if useInAppPayment == false || isInAppMethod == false { + appState.cart.clear() + SessionStateStore.clearPendingCartOrder() + orderTrackingContext = OrderTrackingContext(orderId: orderId, shortId: result.shortId) + return + } + + if orderSnapshot.isPaymentConfirmed { + appState.cart.clear() + SessionStateStore.clearPendingCartOrder() + orderTrackingContext = OrderTrackingContext(orderId: orderId, shortId: result.shortId) + return + } + + SessionStateStore.savePendingCartOrderId(orderId) + + if effectivePaymentMethod == .creditCard { + cardPaymentContext = CardPaymentContext( + orderId: orderId, + shortId: result.shortId, + total: totalValue + ) + return + } + + 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 + + guard let copyPaste, copyPaste.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else { + SnackbarCenter.shared.show(title: "Código PIX não retornado pela API.", style: .error, icon: "xmark.octagon.fill", duration: 3.0) + return + } + + pixPaymentContext = PixPaymentContext( + id: orderId, + orderId: orderId, + shortId: result.shortId, + copyPaste: copyPaste, + qrCodeImageBase64: qrCodeImage, + expirationDate: expirationDate + ) + } 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) -> 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 + ) + ) + } +} diff --git a/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView.swift b/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView.swift new file mode 100644 index 0000000..d9f8ec3 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView.swift @@ -0,0 +1,894 @@ +import SwiftUI +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif + +struct CheckoutView: View { + @Binding var appState: AppState + + @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 + + 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 + } + + 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 canConfirmPayment: Bool { + if isDeliveryMode { + if addressValidationBlocked { return false } + if baseDeliveryFee == nil { return false } + if isValidatingAddress { return false } + } + if useInAppPayment == false && availableStoreMachineMethods.isEmpty { return false } + return true + } + + var body: 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) + .navigationTitle("Finalizar Pedido") + .navigationBarTitleDisplayMode(.inline) + .safeAreaInset(edge: .bottom) { + bottomBar + } + .task { + await loadStoreInfoIfNeeded() + await refreshSelectedCustomerAddress() + normalizeSelectedOptions() + await validateDeliveryAddressIfNeeded() + } + .onChange(of: checkoutAddressWatchKey) { _, _ in + if isRestoringAddress { return } + Task { + await refreshSelectedCustomerAddress() + await validateDeliveryAddressIfNeeded() + } + } + .onChange(of: deliveryType) { _, _ in + Task { + await validateDeliveryAddressIfNeeded() + } + } + .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, + 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, + onPaymentConfirmed: { + appState.cart.clear() + SessionStateStore.clearPendingCartOrder() + } + ) { + appState.cart.clear() + SessionStateStore.clearPendingCartOrder() + orderTrackingContext = OrderTrackingContext(orderId: context.orderId, shortId: context.shortId) + } + } + .navigationDestination(item: $orderTrackingContext) { context in + OrderTrackingView(orderId: context.orderId, initialShortId: context.shortId) + } + } + + 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 ? "Casa" : (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: 12) { + paymentGroupCard( + title: "Pagar Pelo App", + subtitle: "Mais rápido e seguro", + isSelected: useInAppPayment + ) { + VStack(spacing: 0) { + ForEach(availableInAppPaymentMethods, id: \.rawValue) { method in + paymentRow(method, isInAppGroup: true) + if method != availableInAppPaymentMethods.last { + Divider() + } + } + } + } onTap: { + useInAppPayment = true + if availableInAppPaymentMethods.contains(paymentMethod) == false { + paymentMethod = .pix + } + } + + paymentGroupCard( + title: "Pagar Na Maquininha Da Loja", + subtitle: "Pague na entrega/retirada com os métodos aceitos pela loja", + isSelected: useInAppPayment == false + ) { + if availableStoreMachineMethods.isEmpty { + Text("Loja não informou métodos presenciais.") + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + .padding(.horizontal, 14) + .padding(.bottom, 14) + } else { + VStack(spacing: 0) { + ForEach(availableStoreMachineMethods, id: \.rawValue) { method in + paymentRow(method, isInAppGroup: false) + if method != availableStoreMachineMethods.last { + Divider() + } + } + } + } + } onTap: { + guard availableStoreMachineMethods.isEmpty == false else { return } + useInAppPayment = false + if availableStoreMachineMethods.contains(paymentMethod) == false, + let first = availableStoreMachineMethods.first { + paymentMethod = first + } + } + } + } + } + + 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( + Image(systemName: method.iconName) + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(method == .pix ? Color.green : 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 func paymentGroupCard( + title: String, + subtitle: String, + isSelected: Bool, + @ViewBuilder content: () -> Content, + onTap: @escaping () -> Void + ) -> some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + Text(subtitle) + .font(.caption) + .foregroundStyle(AppColors.textMuted) + } + Spacer() + Circle() + .stroke(isSelected ? AppColors.tertiary : AppColors.textMuted.opacity(0.45), lineWidth: 2) + .frame(width: 22, height: 22) + .background( + Circle() + .fill(isSelected ? AppColors.tertiary : Color.clear) + ) + } + .padding(.horizontal, 14) + .padding(.top, 14) + .contentShape(Rectangle()) + .onTapGesture(perform: onTap) + + content() + .allowsHitTesting(isSelected) + .opacity(isSelected ? 1 : 0.82) + } + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + + 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 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 copyPaste: String + let qrCodeImageBase64: String? + let expirationDate: String? +} + +struct OrderTrackingContext: Identifiable, Hashable { + var id: String { orderId } + let orderId: String + let shortId: String? +} + +struct CardPaymentContext: Identifiable, Hashable { + var id: String { orderId } + let orderId: String + let shortId: String? + let total: Double +} + +struct PaymentPixView: View { + let context: PixPaymentContext + 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() + + private var qrImageSource: String? { + guard let raw = context.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 { + 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(context.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 = context.expirationDate, expirationDate.isEmpty == false { + Text(expirationLabel) + .font(.caption) + .foregroundStyle(isPixExpired ? Color.red : AppColors.textMuted) + } + + PrimaryButton(title: "Copiar Código PIX") { + if isPixExpired { + showPixExpiredSnackbar() + return + } + copyToClipboard(context.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) + .padding(.top, 10) + } + .padding(20) + } + .background(AppColors.backgroundLight) + .navigationTitle("Pagamento via PIX") + .navigationBarTitleDisplayMode(.inline) + .task { + tracker.onOrderUpdated = { updated in + latestOrder = updated + if updated.isPaymentConfirmed { + onPaymentConfirmed?() + openTrackingOnce() + } + } + tracker.start(orderId: context.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 func openTrackingOnce() { + guard hasOpenedTracking == false else { return } + hasOpenedTracking = true + onOpenTracking?() + } + + private func copyToClipboard(_ value: String) { +#if canImport(UIKit) + UIPasteboard.general.string = value +#elseif canImport(AppKit) + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(value, forType: .string) +#endif + } + + private var parsedExpirationDate: Date? { + let raw = (context.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 + ) + } +} + +struct PaymentCardView: View { + let context: CardPaymentContext + var onPaymentConfirmed: (() -> Void)? = nil + var onOpenTracking: (() -> Void)? = nil + @Environment(\.dismiss) var dismiss + @State var cardHolderName = "" + @State var cardNumber = "" + @State var expiry = "" + @State var cvv = "" + @State var tracker = OrderRealtimeTracker() + @State var latestOrder: PublicOrderResult? = nil + @State var hasOpenedTracking = false + + var body: some View { + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 14) { + RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous) + .fill(AppColors.surface) + .overlay( + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Total do Pedido") + .font(AppTypography.caption) + .foregroundStyle(AppColors.textMuted) + Text(formatCurrency(context.total)) + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + } + Spacer() + } + .padding(14) + ) + .frame(height: 88) + + Text("Dados do Cartão") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + + VStack(spacing: 10) { + labeledField("Número do Cartão", placeholder: "0000 0000 0000 0000", text: $cardNumber) + labeledField("Nome no Cartão", placeholder: "Como impresso no cartão", text: $cardHolderName) + HStack(spacing: 10) { + labeledField("Validade", placeholder: "MM/AA", text: $expiry) + labeledField("CVV", placeholder: "•••", text: $cvv) + } + } + .padding(14) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + + PrimaryButton(title: "Salvar e Pagar") { + if latestOrder?.isPaymentConfirmed == true { + onPaymentConfirmed?() + openTrackingOnce() + } else { + SnackbarCenter.shared.show(title: "Pagamento ainda não confirmado.", style: .info, icon: "clock.fill", duration: 2.0) + } + } + + Button("Apenas Pagar") { + if latestOrder?.isPaymentConfirmed == true { + onPaymentConfirmed?() + openTrackingOnce() + } else { + SnackbarCenter.shared.show(title: "Aguardando confirmação do pagamento.", style: .info, icon: "clock.fill", duration: 2.0) + } + } + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textMuted) + .frame(maxWidth: .infinity, minHeight: 48) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + .buttonStyle(.plain) + } + .padding(20) + } + .background(AppColors.backgroundLight) + .navigationTitle("Pagamento") + .navigationBarTitleDisplayMode(.inline) + .task { + tracker.onOrderUpdated = { updated in + latestOrder = updated + if updated.isPaymentConfirmed { + onPaymentConfirmed?() + openTrackingOnce() + } + } + tracker.start(orderId: context.orderId, jwt: DefaultTokenStore().jwt) + } + .onDisappear { + tracker.stop() + } + } + + private func openTrackingOnce() { + guard hasOpenedTracking == false else { return } + hasOpenedTracking = true + onOpenTracking?() + } + + private func labeledField(_ label: String, placeholder: String, text: Binding) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(label) + .font(AppTypography.caption) + .foregroundStyle(AppColors.textMuted) + TextField(placeholder, text: text) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .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: ",") + } +} diff --git a/pedi-foods/Sources/PediFoods/Views/Main/HomeScrollOffsetObserver.swift b/pedi-foods/Sources/PediFoods/Views/Main/HomeScrollOffsetObserver.swift new file mode 100644 index 0000000..4489f2b --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Main/HomeScrollOffsetObserver.swift @@ -0,0 +1,76 @@ +import SwiftUI +#if canImport(UIKit) +import UIKit +#endif + +#if os(iOS) +@MainActor +struct ScrollOffsetObserver: UIViewRepresentable { + let onOffsetChange: (CGFloat) -> Void + + func makeUIView(context: Context) -> ScrollOffsetProbeView { + let view = ScrollOffsetProbeView() + view.onOffsetChange = onOffsetChange + return view + } + + func updateUIView(_ uiView: ScrollOffsetProbeView, context: Context) { + uiView.onOffsetChange = onOffsetChange + uiView.attachIfNeeded() + } +} + +@MainActor +final class ScrollOffsetProbeView: UIView { + var onOffsetChange: (CGFloat) -> Void = { _ in } + + private weak var observedScrollView: UIScrollView? + private var observation: NSKeyValueObservation? + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear + isUserInteractionEnabled = false + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + } + + deinit { + observation?.invalidate() + } + + override func didMoveToSuperview() { + super.didMoveToSuperview() + attachIfNeeded() + } + + override func didMoveToWindow() { + super.didMoveToWindow() + attachIfNeeded() + } + + func attachIfNeeded() { + guard let scrollView = findEnclosingScrollView() else { return } + guard scrollView !== observedScrollView else { return } + + observation?.invalidate() + observedScrollView = scrollView + observation = scrollView.observe(\.contentOffset, options: [.initial, .new]) { [weak self] sv, _ in + self?.onOffsetChange(sv.contentOffset.y) + } + } + + private func findEnclosingScrollView() -> UIScrollView? { + var view: UIView? = self + while let current = view { + if let scrollView = current as? UIScrollView { + return scrollView + } + view = current.superview + } + return nil + } +} +#endif diff --git a/pedi-foods/Sources/PediFoods/Views/Main/HomeView+Data.swift b/pedi-foods/Sources/PediFoods/Views/Main/HomeView+Data.swift new file mode 100644 index 0000000..355fa8b --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Main/HomeView+Data.swift @@ -0,0 +1,138 @@ +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." + } +} diff --git a/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift b/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift new file mode 100644 index 0000000..03e6441 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift @@ -0,0 +1,525 @@ +import Foundation +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 collapseBaseOffset: CGFloat = 0 + @State var collapseDragStartOffset: CGFloat? = nil + @State var hasRequestedLocation = false + @State var isLoadingStores = false + @State var storesError: String? = nil + @State var stores: [StoreSummary] = [] + + 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 = 18 + 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 { + await bootstrapStoresFlow( + forceLocationRefresh: true, + category: selectedCategory == "all" ? nil : selectedCategory, + refreshCategories: true + ) + } + .background(scrollOffsetObserver) + .simultaneousGesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + let range = headerExpandedHeight - headerCollapsedHeight + if collapseDragStartOffset == nil { + collapseDragStartOffset = collapseBaseOffset + } + let start = collapseDragStartOffset ?? collapseBaseOffset + let candidate = start - value.translation.height + scrollOffset = clamp(value: candidate, lower: 0, upper: range) + } + .onEnded { _ in + collapseBaseOffset = scrollOffset + collapseDragStartOffset = nil + } + ) + + header(collapseProgress: collapseProgress, height: headerHeight) + .frame(maxWidth: .infinity, alignment: .top) + } + .background(AppColors.backgroundLight) + .ignoresSafeArea(edges: .top) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .onAppear { + if hasRequestedLocation == false { + hasRequestedLocation = true + Task { + await bootstrapStoresFlow(refreshCategories: true) + } + } + collapseBaseOffset = scrollOffset + } + .onChange(of: addressCacheScope) { _, _ in + guard hasRequestedLocation else { return } + Task { + await bootstrapStoresFlow( + forceLocationRefresh: true, + category: selectedCategory == "all" ? nil : selectedCategory, + refreshCategories: true + ) + } + } + } + + private var contentStack: some View { + VStack(spacing: 24) { + categoriesSection + + section(title: "Featured") { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 16) { + ForEach(featuredStoresCards) { store in + NavigationLink { + StoreDetailView( + storeId: store.id, + storeName: store.name, + storeCoverURL: store.coverURL, + storeLogoURL: store.logoURL, + storeCategory: store.category, + storeRating: store.rating, + storeDistance: store.distance, + appState: $appState + ) + } label: { + FeaturedStoreCard(store: store) + .frame(width: 190) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 20) + } + } + + 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ê") { + 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: selectedCategory == "all" ? nil : selectedCategory, + refreshCategories: true + ) + } + } + .font(AppTypography.heading3) + .foregroundStyle(AppColors.primary) + } + .padding(.horizontal, 20) + } else if nearbyStoreCards.isEmpty { + Text("Nenhum estabelecimento encontrado próximo à sua localização.") + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + .padding(.horizontal, 20) + } else { + VStack(spacing: 16) { + ForEach(nearbyStoreCards) { store in + NavigationLink { + StoreDetailView( + storeId: store.id, + storeName: store.name, + storeCoverURL: store.coverURL, + storeLogoURL: store.logoURL, + storeCategory: store.category, + storeRating: store.rating, + storeDistance: store.distance, + appState: $appState + ) + } label: { + FeaturedStoreCard(store: store) + } + .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.brandDark) + .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 pedir hoje?\n ") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textInverse) + .opacity(titleOpacity) + .offset(y: collapseProgress * -20) + } + + SearchBar(placeholder: "Search menu, restaurant or craving", text: $searchText) { + appState.activeModal = .filters + } + .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 == "all" ? nil : category.id) + } + } + } + } + .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) + } + } + + private var storesByPositiveReviews: [StoreSummary] { + stores.sorted { lhs, rhs in + let lhsPositive = lhs.positiveReviews ?? lhs.reviewsCount ?? 0 + let rhsPositive = rhs.positiveReviews ?? rhs.reviewsCount ?? 0 + if lhsPositive != rhsPositive { + return lhsPositive > rhsPositive + } + + let lhsRating = lhs.rating ?? 0 + let rhsRating = rhs.rating ?? 0 + if lhsRating != rhsRating { + return lhsRating > rhsRating + } + + return (lhs.distance ?? .greatestFiniteMagnitude) < (rhs.distance ?? .greatestFiniteMagnitude) + } + } + + private var featuredStoresCards: [FeaturedStoreCardModel] { + Array(storesByPositiveReviews.prefix(5)).map(mapStoreToCard) + } + + private var nearbyStoreCards: [FeaturedStoreCardModel] { + let featuredIds = Set(storesByPositiveReviews.prefix(5).map(\.id)) + let remaining = storesByPositiveReviews.filter { featuredIds.contains($0.id) == false } + return Array(remaining.prefix(20)).map(mapStoreToCard) + } + + private 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), + category: store.category ?? "Loja", + promoText: nil, + isFavorite: appState.favorites.storeIds.contains(store.id), + iconName: "storefront", + imageURL: coverURL ?? logoURL, + logoURL: logoURL, + coverURL: coverURL, + isOpen: store.isOpen ?? true, + statusLabel: store.statusLabel + ) + } + + private func resolveStoreMediaURL(_ raw: String?) -> String? { + ImageSourceResolver.resolve(raw) + } + + private var profilePictureURL: String? { + resolveStoreMediaURL(appState.profile.profilePicture) + } + + private func formatDistance(_ distance: Double?) -> String { + guard let distance else { return "Distância indisponível" } + if distance >= 1 { + return String(format: "%.1f km", distance) + } + return "\(Int(distance * 1000)) m" + } + + @MainActor + private func bootstrapStoresFlow( + forceLocationRefresh: Bool = false, + category: String? = nil, + refreshCategories: Bool = false + ) async { + isLoadingStores = true + storesError = nil + + 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, + 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) + 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 { + stores = [] + storesError = response.message ?? "Não foi possível carregar os estabelecimentos." + 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) + if categories.contains(where: { $0.id == selectedCategory }) == false { + selectedCategory = "all" + } + } + storesError = nil + } catch { + isLoadingStores = false + stores = [] + storesError = storesUserMessage(error) + } + } + + @ViewBuilder + private var scrollOffsetObserver: some View { +#if os(iOS) + ScrollOffsetObserver { y in + // Use only upward displacement for collapse and ignore top bounce. + let normalized = max(0, y) + scrollOffset = normalized + collapseBaseOffset = normalized + } + .frame(width: 0, height: 0) +#else + EmptyView() +#endif + } + + 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 { String(format: "%.5f", $0) } ?? "nil" + let lng = appState.address.longitude.map { String(format: "%.5f", $0) } ?? "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 { String(format: "%.4f", $0) } ?? "nil" + let lngKey = lng.map { String(format: "%.4f", $0) } ?? "nil" + return "stores:\(latKey):\(lngKey):\(normalizedCategory)" + } +} diff --git a/pedi-foods/Sources/PediFoods/Views/Main/HomeViewComponents.swift b/pedi-foods/Sources/PediFoods/Views/Main/HomeViewComponents.swift new file mode 100644 index 0000000..495ce35 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Main/HomeViewComponents.swift @@ -0,0 +1,62 @@ +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.brandDark : 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() + Spacer() + Button(action: onFilterTap) { + Image(systemName: "slider.horizontal.3") + .foregroundStyle(AppColors.textMuted) + } + } + .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/pedi-foods/Sources/PediFoods/Views/Main/MainTabView.swift b/pedi-foods/Sources/PediFoods/Views/Main/MainTabView.swift new file mode 100644 index 0000000..728d850 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Main/MainTabView.swift @@ -0,0 +1,85 @@ +import SwiftUI + +struct MainTabView: View { + @Binding var selectedTab: MainTab + @Binding var root: RootFlow + let tokenStore: TokenStore + @Binding var appState: AppState + + var body: some View { + ZStack(alignment: .bottom) { + Group { + switch selectedTab { + case .home: + NavigationStack { + HomeView(appState: $appState, selectedTab: $selectedTab) + } + case .cart: + NavigationStack { + CartView(appState: $appState) + } + case .profile: + NavigationStack { + ProfileView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState) + } + } + } + + 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.primary : AppColors.textPrimary) + .padding(.horizontal, 18) + .padding(.vertical, 10) + .background( + Capsule() + .fill(isActive ? AppColors.brandSoft : Color.clear) + ) + } + .buttonStyle(.plain) + } +} diff --git a/pedi-foods/Sources/PediFoods/Views/Main/OrderDetailsView.swift b/pedi-foods/Sources/PediFoods/Views/Main/OrderDetailsView.swift new file mode 100644 index 0000000..1494bab --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Main/OrderDetailsView.swift @@ -0,0 +1,376 @@ +import SwiftUI + +struct OrderDetailsView: View { + let order: PublicOrderResult + let orderId: String + let initialShortId: String? + + var body: 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) + .navigationTitle("Detalhes do Pedido") + .navigationBarTitleDisplayMode(.inline) + .safeAreaInset(edge: .bottom) { + 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) + } + + Spacer(minLength: 0) + } + .padding(16) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + + private var storeCard: some View { + HStack(spacing: 12) { + AsyncStoreImage(imageURL: resolvedMediaURL(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) + + Text("Ver loja") + .font(AppTypography.body) + .foregroundStyle(Color(hex: "#A5D645")) + } + .padding(16) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + + 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") { + SnackbarCenter.shared.show( + title: "Recompra será integrada com o catálogo em breve.", + style: .info, + icon: "cart.badge.plus", + duration: 2.0 + ) + } + .font(AppTypography.heading2) + .foregroundStyle(Color(hex: "#0E1A06")) + .frame(maxWidth: .infinity, minHeight: 56) + .background(Color(hex: "#C8F06E")) + .clipShape(Capsule()) + .buttonStyle(.plain) + } + + private var helpFooter: some View { + Text("Precisa de ajuda com esse pedido?") + .font(AppTypography.body) + .foregroundStyle(Color(hex: "#A5D645")) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, 8) + } + + 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 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 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 formatDate(_ 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 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/pedi-foods/Sources/PediFoods/Views/Main/OrderTrackingView.swift b/pedi-foods/Sources/PediFoods/Views/Main/OrderTrackingView.swift new file mode 100644 index 0000000..38129b3 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Main/OrderTrackingView.swift @@ -0,0 +1,769 @@ +import SwiftUI + +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? + + @State var isLoading = true + @State var errorMessage: String? = nil + @State var order: PublicOrderResult? = nil + @State var tracker = OrderRealtimeTracker() + @State var showCancellationReason = false + @State var reviewDraft: ReviewDraft? = nil + @State var didSaveReviewForCurrentOrder = false + + var body: 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) + .navigationTitle("Pedido \(displayOrderTitle)") + .navigationBarTitleDisplayMode(.inline) + .alert("Motivo do cancelamento", isPresented: $showCancellationReason) { + Button("Fechar", role: .cancel) {} + } message: { + Text(cancellationReasonText) + } + .task { + await loadInitialOrder() + tracker.onOrderUpdated = { updated in + order = updated + isLoading = false + errorMessage = nil + } + tracker.start(orderId: orderId, jwt: DefaultTokenStore().jwt) + } + .onDisappear { + tracker.stop() + } + .navigationDestination(item: $reviewDraft) { draft in + MyReviewsView(initialOrder: draft) + } + .onReceive(NotificationCenter.default.publisher(for: .orderReviewDidSave)) { 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 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") {} + .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 { + let exts = ["png", "jpg", "jpeg", "webp"] + for ext in exts { + if Bundle.main.url(forResource: name, withExtension: ext) != nil { + return true + } + if Bundle.module.url(forResource: name, withExtension: ext) != nil { + return true + } + } + return false + } + + 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, privacy: .public)") + + 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, privacy: .public) message=\((response.message ?? "unknown"), privacy: .public)") + } else if let result = response.result { + order = result + errorMessage = nil + logger.info("OrderTracking initial fetch success orderId=\(orderId, privacy: .public) status=\((result.status ?? "nil"), privacy: .public) paymentStatus=\((result.paymentStatus ?? "nil"), privacy: .public)") + } + } catch { + errorMessage = "Não foi possível carregar o pedido." + logger.error("OrderTracking initial fetch failure orderId=\(orderId, privacy: .public) error=\(error.localizedDescription, privacy: .public)") + } + + isLoading = false + } +} diff --git a/pedi-foods/Sources/PediFoods/Views/Main/OrdersView.swift b/pedi-foods/Sources/PediFoods/Views/Main/OrdersView.swift new file mode 100644 index 0000000..607f064 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Main/OrdersView.swift @@ -0,0 +1,838 @@ +import SwiftUI + +struct OrdersView: View { + @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 selectedOrderRoute: OrderRouteContext? = nil + + var body: 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) + .padding(.top, 24) + } else { + ForEach(orders) { order in + orderCard(order) + } + } + } + .padding(.horizontal, 20) + .padding(.bottom, UIDevice.bottomNotch + 18) + } + .background(AppColors.backgroundLight) + .navigationTitle("Meus Pedidos") + .navigationBarTitleDisplayMode(.inline) + .task { + await loadOrdersIfNeeded() + await refreshStoreRatings() + } + .refreshable { + await loadOrders(force: true) + await refreshStoreRatings() + } + .navigationDestination(item: $selectedOrderRoute) { context in + OrderEntryDestinationView( + orderId: context.orderId, + initialShortId: context.shortId, + fallbackPaymentMethod: context.paymentMethod, + fallbackTotal: context.total, + routeIntent: context.intent + ) + } + } + + 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(order.storeLogoURL)) + .frame(width: 80, height: 80) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + .background(AppColors.brandSoft, in: 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) + .layoutPriority(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) + .layoutPriority(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] = [:] + for store in storeList { + guard let rating = store.rating, rating > 0 else { continue } + let storeId = normalizedOrderId(store.id) + if storeId.isEmpty == false { + byId[storeId] = rating + } + let nameKey = normalizedStoreName(store.name) + if nameKey.isEmpty == false { + byName[nameKey] = rating + } + } + + storeRatingByStoreId = byId + storeRatingByStoreName = byName + } + + 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 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, + 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?, + 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.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 + + @State var isResolvingRoute = true + @State var didResolve = false + @State var pixContext: PixPaymentContext? = nil + @State var cardContext: CardPaymentContext? = 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, + onPaymentConfirmed: { + orderTrackingContext = OrderTrackingContext(orderId: pixContext.orderId, shortId: pixContext.shortId) + }, + onOpenTracking: { + orderTrackingContext = OrderTrackingContext(orderId: pixContext.orderId, shortId: pixContext.shortId) + } + ) + } else if let cardContext { + PaymentCardView( + context: cardContext, + onPaymentConfirmed: { + orderTrackingContext = OrderTrackingContext(orderId: cardContext.orderId, shortId: cardContext.shortId) + }, + onOpenTracking: { + orderTrackingContext = OrderTrackingContext(orderId: cardContext.orderId, shortId: cardContext.shortId) + } + ) + } else if let orderDetails { + OrderDetailsView(order: orderDetails, orderId: orderId, initialShortId: initialShortId) + } 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 } + + if routeIntent == .tracking { + return + } + + let order = await fetchOrderForRouting() + guard let order else { return } + if routeIntent == .details { + orderDetails = order + return + } + if shouldOpenOrderDetails(for: order) { + orderDetails = order + return + } + guard shouldOpenPaymentScreen(for: order) else { return } + + 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 + + pixContext = PixPaymentContext( + id: order.id, + orderId: order.id, + shortId: order.shortId ?? initialShortId, + copyPaste: copyPaste?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + ? (copyPaste ?? "") + : "Código PIX indisponível no momento. Aguarde e tente novamente.", + qrCodeImageBase64: qrCodeImage, + expirationDate: expirationDate + ) + return + } + + if normalizedMethod == "CREDIT_CARD" || normalizedMethod == "DEBIT_CARD" { + cardContext = CardPaymentContext( + orderId: order.id, + shortId: order.shortId ?? initialShortId, + total: order.total ?? fallbackTotal ?? 0 + ) + return + } + } + + @MainActor + func fetchOrderForRouting() async -> PublicOrderResult? { + logger.info("OrderEntry fetch route orderId=\(orderId, privacy: .public)") + 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, privacy: .public) status=\((result.status ?? "nil"), privacy: .public) paymentStatus=\((result.paymentStatus ?? "nil"), privacy: .public)") + return result + } + logger.error("OrderEntry fetch route API error orderId=\(orderId, privacy: .public) message=\((response.message ?? "unknown"), privacy: .public)") + } catch { + logger.error("OrderEntry fetch route failure orderId=\(orderId, privacy: .public) error=\(error.localizedDescription, privacy: .public)") + } + 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/pedi-foods/Sources/PediFoods/Views/Main/PizzaFlavorAddonsSheet.swift b/pedi-foods/Sources/PediFoods/Views/Main/PizzaFlavorAddonsSheet.swift new file mode 100644 index 0000000..2a29537 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Main/PizzaFlavorAddonsSheet.swift @@ -0,0 +1,106 @@ +import Foundation +import SwiftUI + +struct PizzaFlavorAddonsSheet: View { + let flavor: StoreCatalogProduct + @Binding var quantities: [String: Int] + + @Environment(\.dismiss) var dismiss + + var body: 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()) + .navigationTitle("Adicionais") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Concluir") { dismiss() } + .foregroundStyle(AppColors.primary) + } + } + } + + 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/pedi-foods/Sources/PediFoods/Views/Main/PizzaProductDetailSheet+Flow.swift b/pedi-foods/Sources/PediFoods/Views/Main/PizzaProductDetailSheet+Flow.swift new file mode 100644 index 0000000..5999afe --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Main/PizzaProductDetailSheet+Flow.swift @@ -0,0 +1,229 @@ +import Foundation +import SwiftUI + +extension PizzaProductDetailSheet { + var stepSizes: some View { + let sizeItems = sizes + return VStack(alignment: .leading, spacing: 10) { + Text("1. Tamanho") + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + + ForEach(Array(sizeItems.indices), id: \.self) { index in + sizeRow(sizeItems[index]) + } + } + .padding(12) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + + var stepDoughs: some View { + VStack(alignment: .leading, spacing: 10) { + Text("2. Massa") + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + + 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 + } + } + } + } + .padding(12) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + + var stepCrusts: some View { + VStack(alignment: .leading, spacing: 10) { + Text("3. Borda") + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + + 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 + } + } + } + } + .padding(12) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + + var stepFlavors: some View { + let flavorItems = flavors + return VStack(alignment: .leading, spacing: 10) { + Text("4. Sabores (\(selectedFlavorIds.count)/\(maxFlavorsAllowed))") + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + + Text("Toque no sabor para escolher adicionais.") + .font(.caption) + .foregroundStyle(AppColors.textMuted) + + ForEach(Array(flavorItems.indices), id: \.self) { index in + let flavor = flavorItems[index] + let isSelected = selectedFlavorIds.contains(flavor.id) + let price = selectedSizeId.flatMap { flavor.pizzaPrices[$0] } ?? flavor.price + let maxReached = selectedFlavorIds.count >= maxFlavorsAllowed + let disableSwitch = isSelected == false && maxReached + + HStack(spacing: 10) { + 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(disableSwitch) + } + .padding(.vertical, 2) + .contentShape(Rectangle()) + .onTapGesture { + guard isSelected else { return } + guard flavor.addonGroups.contains(where: { $0.items.isEmpty == false }) else { return } + selectedFlavorForAddons = flavor + } + } + } + .padding(12) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + + func sizeRow(_ size: StorePizzaSize) -> some View { + let title = size.name ?? "Tamanho" + let subtitle = "Até \(max(1, size.maxFlavors ?? 1)) sabor(es)" + let selected = selectedSizeId == size.id + + return radioRow( + title: title, + subtitle: subtitle, + isSelected: selected + ) { + selectedSizeId = size.id + } + } + + 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() + } + .contentShape(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 + if modifier > 0 { + return "\(name) (+ \(formatCurrency(modifier)))" + } + return 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 } + let sorted = selectedFlavorIds.sorted() + selectedFlavorIds = Set(sorted.prefix(limit)) + } + + func addFlavor(_ flavorId: String) { + if selectedFlavorIds.contains(flavorId) { return } + if selectedFlavorIds.count >= maxFlavorsAllowed { 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/pedi-foods/Sources/PediFoods/Views/Main/PizzaProductDetailSheet.swift b/pedi-foods/Sources/PediFoods/Views/Main/PizzaProductDetailSheet.swift new file mode 100644 index 0000000..8ff0b03 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Main/PizzaProductDetailSheet.swift @@ -0,0 +1,319 @@ +import Foundation +import SwiftUI + +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 + + 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) + } + + private 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 + } + + private 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 { + selectedFlavorProducts + .map { flavor in + guard let selectedSizeId else { return flavor.price ?? 0 } + return flavor.pizzaPrices[selectedSizeId] ?? flavor.price ?? 0 + } + .max() ?? 0 + } + + 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 addButtonTitle: String { + if canConfirm == false { + return "Selecione as opções" + } + return "Adicionar • \(formatCurrency(totalPrice))" + } + + var body: some View { + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 16) { + AsyncStoreImage(imageURL: representativeImage) + .frame(height: 220) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous)) + + Text("Pizza de varios sabores") + .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) + } + .safeAreaInset(edge: .bottom) { + 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: "Pizza de varios sabores", + imageURL: representativeImage, + details: selectedDetailsText, + 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()) + .navigationTitle("Monte sua pizza") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Fechar") { dismiss() } + .foregroundStyle(AppColors.primary) + } + } + .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/pedi-foods/Sources/PediFoods/Views/Main/ProductDetailSheet.swift b/pedi-foods/Sources/PediFoods/Views/Main/ProductDetailSheet.swift new file mode 100644 index 0000000..cf4149d --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Main/ProductDetailSheet.swift @@ -0,0 +1,279 @@ +import Foundation +import SwiftUI + +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 { + 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) + } + .safeAreaInset(edge: .bottom) { + 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()) + .navigationTitle("Detalhes") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Fechar") { dismiss() } + .foregroundStyle(AppColors.primary) + } + } + .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/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift b/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift new file mode 100644 index 0000000..5d98c11 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift @@ -0,0 +1,291 @@ +import SwiftUI +import LCEssentials +#if canImport(UIKit) +import UIKit +#endif + +struct ProfileView: View { + @Binding var root: RootFlow + @Binding var selectedTab: MainTab + let tokenStore: TokenStore + @Binding var appState: AppState + @State var openAddressesOnboarding = false + @State var onboardingMessage: String? = nil + @State var showLogoutAlert = 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() + } 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 { +// Text("Meus Cartões") +// } label: { +// ProfileMenuRow(icon: "creditcard.fill", title: "Meus Cartões") +// } +// .buttonStyle(.plain) + + NavigationLink { + MyReviewsView() + } label: { + ProfileMenuRow(icon: "star.fill", title: "Minhas Avaliações") + } + .buttonStyle(.plain) + +// 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) + + Text("Versão 1.0b") + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(AppColors.textMuted) + .padding(.bottom, 12) + } + } + .ignoresSafeArea(edges: .top) + .safeAreaInset(edge: .bottom) { + 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?") + } + .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) + } + } + } + + 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() + root = .auth + } +} + +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/pedi-foods/Sources/PediFoods/Views/Main/ReviewsView.swift b/pedi-foods/Sources/PediFoods/Views/Main/ReviewsView.swift new file mode 100644 index 0000000..e606d3e --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Main/ReviewsView.swift @@ -0,0 +1,1566 @@ +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 + + init(initialOrder: ReviewDraft? = nil) { + self.initialOrder = initialOrder + } + + var body: 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) + .navigationTitle("Minhas Avaliações") + .navigationBarTitleDisplayMode(.inline) + .navigationDestination(item: $selectedDraft) { draft in + OrderReviewView(draft: draft) { + Task { await loadReviewsFromBackend(forceRefresh: true) } + } + } + .onAppear { + Task { await loadReviewsFromBackend(forceRefresh: true) } + } + .refreshable { + await loadReviewsFromBackend(forceRefresh: true) + } + .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 { + 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) + .navigationTitle("Avaliar Pedido") + .navigationBarTitleDisplayMode(.inline) + .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) + .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/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailPizzaSupport.swift b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailPizzaSupport.swift new file mode 100644 index 0000000..f8e1eb4 --- /dev/null +++ b/pedi-foods/Sources/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/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailSupport.swift b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailSupport.swift new file mode 100644 index 0000000..ba3b30c --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailSupport.swift @@ -0,0 +1,150 @@ +import SwiftUI +#if canImport(UIKit) +import UIKit +#endif + +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 }) + } +} + +struct ScrollOffsetReader: View { + @Binding var offsetY: CGFloat + + var body: some View { +#if canImport(UIKit) + ScrollOffsetReaderRepresentable(offsetY: $offsetY) +#else + Color.clear +#endif + } +} + +#if canImport(UIKit) +struct ScrollOffsetReaderRepresentable: UIViewRepresentable { + @Binding var offsetY: CGFloat + + func makeUIView(context: Context) -> OffsetProbeView { + let view = OffsetProbeView() + view.onOffsetChanged = { value in + if offsetY != value { + offsetY = value + } + } + return view + } + + func updateUIView(_ uiView: OffsetProbeView, context: Context) { + uiView.onOffsetChanged = { value in + if offsetY != value { + offsetY = value + } + } + } +} + +final class OffsetProbeView: UIView { + var onOffsetChanged: ((CGFloat) -> Void)? + private var observation: NSKeyValueObservation? + private weak var observedScrollView: UIScrollView? + + override func didMoveToWindow() { + super.didMoveToWindow() + attachIfNeeded() + } + + override func didMoveToSuperview() { + super.didMoveToSuperview() + attachIfNeeded() + } + + private func attachIfNeeded() { + guard observation == nil else { return } + guard observedScrollView == nil else { return } + + if let scrollView = enclosingScrollView() ?? findScrollViewInWindow() { + observe(scrollView) + return + } + + retryAttach() + } + + private func observe(_ scrollView: UIScrollView) { + observedScrollView = scrollView + observation = scrollView.observe(\.contentOffset, options: [.new, .initial]) { [weak self, weak scrollView] _, change in + guard let self, let scrollView, let y = change.newValue?.y else { return } + let adjusted = max(0, y + scrollView.adjustedContentInset.top) + DispatchQueue.main.async { + self.onOffsetChanged?(adjusted) + } + } + } + + private func retryAttach() { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in + self?.attachIfNeeded() + } + } + + private func enclosingScrollView() -> UIScrollView? { + var current: UIView? = self + while let view = current { + if let scrollView = view as? UIScrollView { + return scrollView + } + current = view.superview + } + return nil + } + + private func findScrollViewInWindow() -> UIScrollView? { + guard let window else { return nil } + let targetPoint = convert(CGPoint(x: bounds.midX, y: bounds.midY), to: window) + return findScrollView(in: window, containing: targetPoint) + } + + private func findScrollView(in root: UIView, containing point: CGPoint) -> UIScrollView? { + for subview in root.subviews.reversed() { + if let match = findScrollView(in: subview, containing: point) { + return match + } + } + + if let scrollView = root as? UIScrollView { + let rectInWindow = scrollView.convert(scrollView.bounds, to: window) + if rectInWindow.contains(point) { + return scrollView + } + } + + return nil + } + + deinit { + observation?.invalidate() + } +} +#endif + +struct AsyncStoreImage: View { + let imageURL: String? + + var body: some View { + CachedRemoteImage(imageURL: imageURL) { + fallback + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(AppColors.brandSoft) + .clipped() + } + + private var fallback: some View { + Image("placeholder-product") + .resizable() + .scaledToFill() + } +} diff --git a/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView+Logic.swift b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView+Logic.swift new file mode 100644 index 0000000..05fae25 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView+Logic.swift @@ -0,0 +1,353 @@ +import SwiftUI + +extension StoreDetailView { + func heroIconButton(icon: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Image(systemName: icon) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(Color.white) + .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) + } + if let storeDistance, storeDistance.isEmpty == false { + return storeDistance + } + return "R$ --" + } + + var isStoreOpen: Bool { + info?.isOpen ?? true + } + + 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 { + isLoading = true + 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) { + info = cachedInfo + categories = cachedCatalog + selectedCategoryId = cachedCatalog.first?.id + isLoading = false + return + } + + if forceRefresh { + AppContentCache.shared.invalidate(prefix: "store-info:\(storeId)") + AppContentCache.shared.invalidate(prefix: "store-catalog:\(storeId)") + } + + do { + async let infoRequest = ApiService().storeInfo(storeId: storeId) + async let catalogRequest = ApiService().storeCatalog(storeId: storeId) + let (infoResponse, catalogResponse) = try await (infoRequest, catalogRequest) + + if infoResponse.error { + errorMessage = infoResponse.message ?? "Não foi possível carregar a loja." + isLoading = false + return + } + if catalogResponse.error { + errorMessage = catalogResponse.message ?? "Não foi possível carregar o catálogo." + isLoading = false + return + } + + info = infoResponse.result + categories = catalogResponse.result ?? [] + selectedCategoryId = categories.first?.id + if let info = infoResponse.result { + AppContentCache.shared.set(info, for: infoCacheKey, ttl: AppCacheTTL.twoHours) + } + AppContentCache.shared.set(categories, for: catalogCacheKey, ttl: AppCacheTTL.twoHours) + isLoading = false + } catch { + isLoading = false + if let network = error as? NetworkError { + errorMessage = network.errorDescription ?? "Erro ao carregar loja." + return + } + if let service = error as? ApiServiceError { + errorMessage = service.errorDescription ?? "Erro ao carregar loja." + return + } + errorMessage = "Erro ao carregar loja." + } + } + + 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: "Pizza de varios sabores", + 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/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView.swift b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView.swift new file mode 100644 index 0000000..19f3732 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView.swift @@ -0,0 +1,499 @@ +import SwiftUI +import LCEssentials +#if canImport(UIKit) +import UIKit +#endif + +struct StoreDetailView: View { + let storeId: String + let storeName: String + let storeCoverURL: String? + let storeLogoURL: String? + let storeCategory: String? + let storeRating: Double? + let storeDistance: String? + @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 scrollOffsetY: CGFloat = 0 + + let cardTopInset: CGFloat = 168 + let summaryCardBaseHeight: CGFloat = 170 + let closedBannerHeight: CGFloat = 44 + let coverVisibleUntilY: CGFloat = 253 + let storeLogoSize: CGFloat = 84 + + var body: some View { + GeometryReader { geometry in + let safeTop = geometry.safeAreaInsets.top + ScrollViewReader { proxy in + + ZStack(alignment: .top) { + AppColors.backgroundLight.ignoresSafeArea() + + ScrollView(showsIndicators: false) { + LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { + topSection + + categoryTabs(proxy: proxy, safeTop: safeTop, isSticky: false) + + sectionedProducts + } + } + .refreshable { + await loadStoreData(forceRefresh: true) + } + .ignoresSafeArea(edges: .top) + .background(ScrollOffsetReader(offsetY: $scrollOffsetY)) + } + .ignoresSafeArea(edges: .top) + .overlay(alignment: .top) { + if isCategoryTabsPinned(safeTop: safeTop) { + categoryTabs(proxy: proxy, safeTop: safeTop, isSticky: true) + .transition(.opacity) + .zIndex(20) + } + } + .saturation(isStoreOpen ? 1 : 0) + } + } + .navigationBarBackButtonHidden(true) + .toolbar(.hidden, for: .navigationBar) + .toolbarBackground(.hidden, for: .navigationBar) + .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() + } + } + + private 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) + } + + private var heroSection: some View { + ZStack(alignment: .top) { + AsyncStoreImage(imageURL: resolvedURL(storeCoverURL)) + .frame(height: topSectionHeight) + .overlay( + LinearGradient( + colors: [Color.black.opacity(0.32), Color.black.opacity(0.05)], + startPoint: .top, + endPoint: .bottom + ) + ) + + VStack(spacing: 0) { + HStack { + heroIconButton(icon: "chevron.left") { + dismiss() + } + Spacer() + heroIconButton(icon: "magnifyingglass") {} + heroIconButton(icon: "heart") {} + } + .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) + } + } + } + + private 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) + } + + private 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) { + statItem(title: "TEMPO", value: info?.deliveryTime ?? "25-35 min") + Divider().frame(height: 34) + statItem(title: "ENTREGA", 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 + private var sectionedProducts: some View { + if isLoading { + ProgressView() + .frame(maxWidth: .infinity, alignment: .center) + .padding(.top, 26) + } 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 if categories.isEmpty { + Text("Cardápio indisponível no momento.") + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + .padding(.top, 16) + .padding(.horizontal, 16) + .padding(.bottom, 120) + } else { + ForEach(categories, id: \.id) { category in + Section { + VStack(spacing: 12) { + ForEach(listItems(for: category)) { item in + productCard(item, in: category) + } + } + .padding(.horizontal, 16) + .padding(.bottom, 8) + } header: { + 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] + ) + } + ) + } + } + Color.clear.frame(height: 120) + } + } + + private func categoryTabs(proxy: ScrollViewProxy, safeTop: CGFloat, isSticky: Bool) -> some View { + 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(.bottom, 10) + .padding(.top, isSticky ? categoryTabsPinnedInset(safeTop: safeTop) : 10) + } + .background(AppColors.backgroundLight) + } + + private func isCategoryTabsPinned(safeTop: CGFloat) -> Bool { + scrollOffsetY >= categoryTabsPinThreshold(safeTop: safeTop) + } + + private func categoryTabsPinThreshold(safeTop: CGFloat) -> CGFloat { + max(0, topSectionHeight + 10 - safeTop) + } + + private func categoryTabsPinnedInset(safeTop: CGFloat) -> CGFloat { + max(8, safeTop - 44) + } + + private 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) { + 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) + .contentShape(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)) + .contentShape(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/pedi-foods/Sources/PediFoods/Views/Main/UserProfileView.swift b/pedi-foods/Sources/PediFoods/Views/Main/UserProfileView.swift new file mode 100644 index 0000000..d7b49bc --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Main/UserProfileView.swift @@ -0,0 +1,269 @@ +import SwiftUI +#if canImport(PhotosUI) && os(iOS) +import PhotosUI +#endif +#if canImport(UIKit) +import UIKit +#endif + +struct UserProfileView: View { + @Binding var appState: AppState + @Environment(\.dismiss) var dismiss + + @State var name: String = "" + @State var email: String = "" + @State var phone: String = "" + @State var profilePicture: String = "" + @State var isSaving = false + +#if canImport(PhotosUI) && os(iOS) + @State var selectedPhotoItem: PhotosPickerItem? +#endif + + var body: some View { + ScrollView(showsIndicators: false) { + VStack(spacing: 22) { + avatarSection + formSection + saveButton + } + .padding(.horizontal, 20) + .padding(.top, 18) + .padding(.bottom, UIDevice.bottomNotch + 24) + } + .background(AppColors.backgroundLight) + .navigationTitle("Meu Perfil") + .navigationBarTitleDisplayMode(.inline) + .onAppear { + hydrateFromAppState() + } +#if canImport(PhotosUI) && 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 canImport(PhotosUI) && 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 + } + } + .appNoAutoCap() + } + .padding(16) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + + 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 + } + + @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) + + isSaving = true + defer { isSaving = false } + + do { + let response = try await ApiService().updateCustomerProfile( + name: cleanName, + email: cleanEmail, + phoneNumber: normalizedPhone, + profilePicture: cleanPhoto.isEmpty ? nil : cleanPhoto + ) + + 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 + } + + let customer = response.result + appState.profile.id = customer?.id ?? appState.profile.id + appState.profile.name = customer?.name ?? cleanName + appState.profile.email = customer?.email ?? cleanEmail + appState.profile.phone = customer?.phoneNumber ?? normalizedPhone + appState.profile.profilePicture = customer?.profilePicture ?? cleanPhoto + 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) + ) + } + } + +#if canImport(PhotosUI) && 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 } + #if canImport(UIKit) + guard let image = UIImage(data: data), + let jpegData = image.jpegData(compressionQuality: 0.82) else { return } + profilePicture = "data:image/jpeg;base64,\(jpegData.base64EncodedString())" + #else + profilePicture = "data:image/jpeg;base64,\(data.base64EncodedString())" + #endif + } catch { + SnackbarCenter.shared.show( + title: "Não foi possível carregar a foto selecionada.", + style: .warning, + icon: "photo", + duration: 2.5 + ) + } + } +#endif +} diff --git a/pedi-foods/Tests/PediFoodsTests/SmokeTests.swift b/pedi-foods/Tests/PediFoodsTests/SmokeTests.swift new file mode 100644 index 0000000..a7e04db --- /dev/null +++ b/pedi-foods/Tests/PediFoodsTests/SmokeTests.swift @@ -0,0 +1,7 @@ +import Testing +@testable import PediFoods + +@Test("Smoke") +func smoke() { + #expect(Bool(true)) +} diff --git a/pedi-foods/scripts/android-run.sh b/pedi-foods/scripts/android-run.sh new file mode 100755 index 0000000..0fda50f --- /dev/null +++ b/pedi-foods/scripts/android-run.sh @@ -0,0 +1,424 @@ +#!/usr/bin/env bash +set -euo pipefail + +PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +ANDROID_SDK_ROOT_DEFAULT="$HOME/Library/Android/sdk" +EMULATOR_BIN_DEFAULT="$ANDROID_SDK_ROOT_DEFAULT/emulator/emulator" +ADB_BIN_DEFAULT="$ANDROID_SDK_ROOT_DEFAULT/platform-tools/adb" + +AVD_NAME="${1:-auto}" +BOOT_TIMEOUT_SECONDS="${BOOT_TIMEOUT_SECONDS:-120}" +ADB_CONNECT_TIMEOUT_SECONDS="${ADB_CONNECT_TIMEOUT_SECONDS:-90}" +APP_ID_DEFAULT="com.br.pedifoods.app" +REQUIRED_BRIDGE_SO="${REQUIRED_BRIDGE_SO:-libPediFoods.so}" +SKIP_EXPORT_ARCH="${SKIP_EXPORT_ARCH:-aarch64}" +START_EMULATOR_WHEN_NO_DEVICE="${START_EMULATOR_WHEN_NO_DEVICE:-0}" +EMULATOR_HEADLESS="${EMULATOR_HEADLESS:-1}" +EMULATOR_MEMORY_MB="${EMULATOR_MEMORY_MB:-1536}" + +if [[ -n "${ANDROID_SDK_ROOT:-}" ]]; then + EMULATOR_BIN="$ANDROID_SDK_ROOT/emulator/emulator" + ADB_BIN="$ANDROID_SDK_ROOT/platform-tools/adb" +else + EMULATOR_BIN="$EMULATOR_BIN_DEFAULT" + ADB_BIN="$ADB_BIN_DEFAULT" +fi + +ANDROID_HOME="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$ANDROID_SDK_ROOT_DEFAULT}}" +ANDROID_SDK_ROOT="${ANDROID_SDK_ROOT:-$ANDROID_HOME}" +export ANDROID_HOME +export ANDROID_SDK_ROOT + +if [[ ! -x "$EMULATOR_BIN" ]]; then + echo "Erro: emulator não encontrado em '$EMULATOR_BIN'" + exit 1 +fi + +if [[ ! -x "$ADB_BIN" ]]; then + echo "Erro: adb não encontrado em '$ADB_BIN'" + exit 1 +fi + +if ! command -v skip >/dev/null 2>&1; then + echo "Erro: comando 'skip' não encontrado no PATH" + exit 1 +fi + +TARGET_SERIAL="" +EMULATOR_PID="" + +APP_ID="$(sed -nE 's/^[[:space:]]*PRODUCT_BUNDLE_IDENTIFIER[[:space:]]*=[[:space:]]*([^[:space:]]+).*/\1/p' "$PROJECT_DIR/Skip.env" | head -n1)" +APP_ID="${APP_ID:-$APP_ID_DEFAULT}" +REQUIRE_FRESH_APK="${REQUIRE_FRESH_APK:-1}" +ALLOW_INSTALLED_FALLBACK="${ALLOW_INSTALLED_FALLBACK:-1}" +RUN_STABILITY_SECONDS="${RUN_STABILITY_SECONDS:-8}" + +repair_skip_packages_links() { + local swift_dir="$PROJECT_DIR/.build/plugins/outputs/pedi-foods/PediFoods/destination/skipstone/PediFoods/src/main/swift" + local packages_dir="$swift_dir/Packages" + if [[ ! -d "$packages_dir" ]]; then + return 0 + fi + + for link_path in "$packages_dir"/*; do + [[ -e "$link_path" || -L "$link_path" ]] || continue + local pkg_name + pkg_name="$(basename "$link_path")" + local pkg_manifest="$link_path/Package.swift" + if [[ -f "$pkg_manifest" ]]; then + continue + fi + + local checkout_path="$PROJECT_DIR/.build/checkouts/$pkg_name" + if [[ -f "$checkout_path/Package.swift" ]]; then + echo "Reparando link de pacote: $pkg_name -> $checkout_path" + rm -rf "$link_path" + ln -s "$checkout_path" "$link_path" + fi + done +} + +clean_skip_generated_outputs() { + echo "Limpando artefatos gerados do Skip para recuperação..." + rm -rf \ + "$PROJECT_DIR/.build/plugins/outputs/skip-android-bridge" \ + "$PROJECT_DIR/.build/plugins/outputs/skip-bridge" \ + "$PROJECT_DIR/.build/plugins/outputs/skip-foundation" \ + "$PROJECT_DIR/.build/plugins/outputs/skip-fuse-ui" \ + "$PROJECT_DIR/.build/plugins/outputs/skip-lib" \ + "$PROJECT_DIR/.build/plugins/outputs/skip-model" \ + "$PROJECT_DIR/.build/plugins/outputs/skip-ui" \ + "$PROJECT_DIR/.build/plugins/outputs/skip-unit" +} + +run_skip_export() { + cd "$PROJECT_DIR" + echo "Executando skip export com arquitetura: $SKIP_EXPORT_ARCH" + if skip export --debug --no-ios --android --arch "$SKIP_EXPORT_ARCH"; then + repair_skip_packages_links + return 0 + fi + + echo "Aviso: skip export falhou na primeira tentativa." + clean_skip_generated_outputs + repair_skip_packages_links + + echo "Tentando skip export novamente após limpeza de cache..." + if skip export --debug --no-ios --android --arch "$SKIP_EXPORT_ARCH"; then + repair_skip_packages_links + return 0 + fi + + repair_skip_packages_links + return 1 +} + +get_avd_abi() { + local avd_name="$1" + local cfg="$HOME/.android/avd/${avd_name}.avd/config.ini" + if [[ ! -f "$cfg" ]]; then + return 1 + fi + sed -nE 's/^[[:space:]]*abi\.type[[:space:]]*=[[:space:]]*([^[:space:]]+).*/\1/p' "$cfg" | head -n1 +} + +apk_supports_abi() { + local apk="$1" + local abi="$2" + unzip -l "$apk" 2>/dev/null | grep "lib/${abi}/${REQUIRED_BRIDGE_SO}$" >/dev/null +} + +apk_contains_required_so() { + local apk="$1" + unzip -l "$apk" 2>/dev/null | grep "/${REQUIRED_BRIDGE_SO}$" >/dev/null +} + +first_connected_device() { + "$ADB_BIN" devices | awk 'NR>1 && $2=="device" {print $1; exit}' +} + +pick_connected_device_for_apk() { + local apk="$1" + local serial abi + while IFS= read -r serial; do + [[ -n "$serial" ]] || continue + abi="$("$ADB_BIN" -s "$serial" shell getprop ro.product.cpu.abi 2>/dev/null | tr -d '\r')" + if [[ -n "$abi" ]] && apk_supports_abi "$apk" "$abi"; then + echo "$serial" + return 0 + fi + done < <("$ADB_BIN" devices | awk 'NR>1 && $2=="device" {print $1}') + return 1 +} + +pick_compatible_avd() { + local apk="$1" + local preferred="$2" + local avd_list + avd_list="$("$EMULATOR_BIN" -list-avds)" + local host_arch + host_arch="$(uname -m 2>/dev/null || echo "")" + + if [[ "$preferred" != "auto" ]] && echo "$avd_list" | grep -qx "$preferred"; then + local preferred_abi + preferred_abi="$(get_avd_abi "$preferred" || true)" + if [[ -n "$preferred_abi" ]] && apk_supports_abi "$apk" "$preferred_abi"; then + echo "$preferred" + return 0 + fi + fi + + local avd abi + while IFS= read -r avd; do + [[ -n "$avd" ]] || continue + abi="$(get_avd_abi "$avd" || true)" + if [[ "$host_arch" == "arm64" || "$host_arch" == "aarch64" ]]; then + if [[ "$abi" == "x86_64" || "$abi" == "x86" ]]; then + continue + fi + fi + if [[ -n "$abi" ]] && apk_supports_abi "$apk" "$abi"; then + echo "$avd" + return 0 + fi + done <<< "$avd_list" + return 1 +} + +ensure_android_ready() { + local apk_path="${1:-}" + local avd_to_boot="$AVD_NAME" + + if [[ -n "$apk_path" && -f "$apk_path" ]]; then + if ! apk_contains_required_so "$apk_path"; then + echo "Erro: APK não contém ${REQUIRED_BRIDGE_SO}. Build inválido para app Skip." + echo "APK: $apk_path" + exit 1 + fi + local picked + picked="$(pick_compatible_avd "$apk_path" "$AVD_NAME" || true)" + if [[ -n "$picked" ]]; then + avd_to_boot="$picked" + fi + fi + + "$ADB_BIN" start-server >/dev/null 2>&1 || true + if [[ -n "$apk_path" && -f "$apk_path" ]]; then + TARGET_SERIAL="$(pick_connected_device_for_apk "$apk_path" || true)" + else + TARGET_SERIAL="$(first_connected_device)" + fi + + if [[ -z "$TARGET_SERIAL" ]]; then + if [[ "$START_EMULATOR_WHEN_NO_DEVICE" != "1" ]]; then + echo "Erro: nenhum dispositivo Android conectado." + echo "Para evitar consumo de memória, este script não inicia emulador por padrão." + echo "Conecte um celular com USB debugging e rode novamente." + echo "Se quiser forçar emulador: START_EMULATOR_WHEN_NO_DEVICE=1 ./scripts/android-run.sh" + exit 1 + fi + + if ! "$EMULATOR_BIN" -list-avds | grep -qx "$avd_to_boot"; then + echo "Erro: AVD '$avd_to_boot' não existe. Disponíveis:" + "$EMULATOR_BIN" -list-avds + exit 1 + fi + + echo "Iniciando emulador: $avd_to_boot" + local emulator_args=("$EMULATOR_BIN" "-avd" "$avd_to_boot" "-no-snapshot-load") + if [[ "$EMULATOR_HEADLESS" == "1" ]]; then + emulator_args+=("-no-window" "-gpu" "swiftshader_indirect" "-no-audio") + fi + if [[ -n "$EMULATOR_MEMORY_MB" ]]; then + emulator_args+=("-memory" "$EMULATOR_MEMORY_MB") + fi + + nohup "${emulator_args[@]}" >/tmp/skip-android-emulator.log 2>&1 & + EMULATOR_PID="$!" + sleep 2 + else + BOOTED="$("$ADB_BIN" -s "$TARGET_SERIAL" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" + if [[ "$BOOTED" == "1" ]]; then + echo "Dispositivo já pronto." + else + echo "Dispositivo conectado, aguardando finalização do boot." + fi + fi + + if [[ -z "$TARGET_SERIAL" ]]; then + echo "Aguardando ADB conectar..." + CONNECT_START_TS="$(date +%s)" + while true; do + if [[ -n "$apk_path" && -f "$apk_path" ]]; then + TARGET_SERIAL="$(pick_connected_device_for_apk "$apk_path" || true)" + else + TARGET_SERIAL="$(first_connected_device)" + fi + if [[ -n "$TARGET_SERIAL" ]]; then + break + fi + + NOW_TS="$(date +%s)" + if (( NOW_TS - CONNECT_START_TS >= ADB_CONNECT_TIMEOUT_SECONDS )); then + echo "Timeout aguardando ADB conectar (${ADB_CONNECT_TIMEOUT_SECONDS}s)." + echo "Estado atual do adb:" + "$ADB_BIN" devices || true + if [[ -n "$EMULATOR_PID" ]] && ! kill -0 "$EMULATOR_PID" 2>/dev/null; then + echo "Processo do emulador encerrou antes da conexão." + fi + echo "Últimas linhas do log do emulador (/tmp/skip-android-emulator.log):" + tail -n 80 /tmp/skip-android-emulator.log || true + exit 1 + fi + if [[ -n "$EMULATOR_PID" ]] && ! kill -0 "$EMULATOR_PID" 2>/dev/null; then + echo "Processo do emulador encerrou antes da conexão." + echo "Últimas linhas do log do emulador (/tmp/skip-android-emulator.log):" + tail -n 80 /tmp/skip-android-emulator.log || true + exit 1 + fi + sleep 2 + done + fi + + echo "Aguardando boot do Android..." + START_TS="$(date +%s)" + until [[ "$("$ADB_BIN" -s "$TARGET_SERIAL" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" == "1" ]]; do + NOW_TS="$(date +%s)" + if (( NOW_TS - START_TS >= BOOT_TIMEOUT_SECONDS )); then + echo "Timeout aguardando boot do Android (${BOOT_TIMEOUT_SECONDS}s) no dispositivo $TARGET_SERIAL." + "$ADB_BIN" devices || true + tail -n 80 /tmp/skip-android-emulator.log || true + exit 1 + fi + sleep 2 + done + + if [[ -n "$apk_path" && -f "$apk_path" ]]; then + local device_abi + device_abi="$("$ADB_BIN" -s "$TARGET_SERIAL" shell getprop ro.product.cpu.abi 2>/dev/null | tr -d '\r')" + if [[ -n "$device_abi" ]] && ! apk_supports_abi "$apk_path" "$device_abi"; then + echo "Erro: APK não possui ${REQUIRED_BRIDGE_SO} para ABI do dispositivo: $device_abi" + echo "Tente outro AVD: x86_64 para APK x86_64, arm64-v8a para APK arm64." + exit 1 + fi + fi +} + +repair_skip_packages_links + +find_apk_path() { + if [[ -f "$PROJECT_DIR/.build/skip-export/PediFoods-debug.apk" ]]; then + echo "$PROJECT_DIR/.build/skip-export/PediFoods-debug.apk" + return 0 + fi + local exported_apk + exported_apk="$(find "$PROJECT_DIR/.build/skip-export" -type f -name '*-debug.apk' 2>/dev/null | head -n1 || true)" + if [[ -n "$exported_apk" ]]; then + echo "$exported_apk" + return 0 + fi + if [[ -f "$PROJECT_DIR/.build/Android/app/outputs/apk/debug/app-debug.apk" ]]; then + echo "$PROJECT_DIR/.build/Android/app/outputs/apk/debug/app-debug.apk" + return 0 + fi + if [[ -f "$PROJECT_DIR/Android/app/build/outputs/apk/debug/app-debug.apk" ]]; then + echo "$PROJECT_DIR/Android/app/build/outputs/apk/debug/app-debug.apk" + return 0 + fi + find "$PROJECT_DIR/.build/Android" "$PROJECT_DIR/Android/app/build/outputs/apk/debug" -type f -name '*.apk' 2>/dev/null | head -n1 || true +} + +APK_PATH="$(find_apk_path)" + +SOURCE_MTIME="$(find "$PROJECT_DIR/Sources" -type f -name '*.swift' -exec stat -f '%m' {} \; 2>/dev/null | sort -nr | head -n1 || echo 0)" +SOURCE_MTIME="${SOURCE_MTIME:-0}" +APK_MTIME=0 +if [[ -n "$APK_PATH" && -f "$APK_PATH" ]]; then + APK_MTIME="$(stat -f '%m' "$APK_PATH" 2>/dev/null || echo 0)" +fi +APK_STALE=0 +if (( SOURCE_MTIME > APK_MTIME )); then + APK_STALE=1 +fi + +if [[ -z "$APK_PATH" || "${FORCE_EXPORT:-0}" == "1" || "$APK_STALE" == "1" ]]; then + if [[ "$APK_STALE" == "1" && "${FORCE_EXPORT:-0}" != "1" ]]; then + echo "APK desatualizado em relação ao código fonte. Tentando gerar novo APK..." + fi + echo "Gerando APK (skip export)..." + if ! run_skip_export; then + echo "Aviso: skip export falhou. Tentando usar último APK gerado..." + if [[ -d "$PROJECT_DIR/Android" ]]; then + echo "Tentando fallback via Gradle (:app:assembleDebug)..." + ( + cd "$PROJECT_DIR/Android" + ANDROID_SDK_ROOT="$ANDROID_SDK_ROOT" gradle :app:assembleDebug --no-daemon + ) || echo "Aviso: fallback Gradle também falhou." + fi + fi +fi + +APK_PATH="$(find_apk_path)" + +if [[ -z "$APK_PATH" ]]; then + if [[ "$ALLOW_INSTALLED_FALLBACK" == "1" ]]; then + ensure_android_ready + echo "Aviso: APK não encontrado. Tentando abrir app já instalado: $APP_ID" + "$ADB_BIN" -s "$TARGET_SERIAL" shell monkey -p "$APP_ID" -c android.intent.category.LAUNCHER 1 + echo "App aberto sem reinstalar (fallback)." + exit 0 + fi + echo "Erro: APK não encontrado." + exit 1 +fi + +ensure_android_ready "$APK_PATH" + +if [[ "$REQUIRE_FRESH_APK" == "1" ]]; then + APK_MTIME="$(stat -f '%m' "$APK_PATH" 2>/dev/null || echo 0)" + SOURCE_MTIME="$(find "$PROJECT_DIR/Sources" -type f -name '*.swift' -exec stat -f '%m' {} \; 2>/dev/null | sort -nr | head -n1 || echo 0)" + if (( SOURCE_MTIME > APK_MTIME )); then + if [[ "$ALLOW_INSTALLED_FALLBACK" == "1" ]]; then + echo "Aviso: APK continua desatualizado (build novo não gerado)." + echo "Abrindo app já instalado para não bloquear o fluxo." + "$ADB_BIN" -s "$TARGET_SERIAL" shell monkey -p "$APP_ID" -c android.intent.category.LAUNCHER 1 + echo "App aberto sem reinstalar (fallback)." + exit 0 + fi + echo "Erro: APK continua desatualizado (build novo não gerado)." + echo "Não vou abrir versão antiga para evitar teste enganoso." + echo "Dica: rode com REQUIRE_FRESH_APK=0 para abrir mesmo assim." + exit 1 + fi +fi + +echo "Instalando APK: $APK_PATH" +"$ADB_BIN" -s "$TARGET_SERIAL" install -r "$APK_PATH" + +LAUNCHABLE="$("$ADB_BIN" -s "$TARGET_SERIAL" shell cmd package resolve-activity --brief "$APP_ID" 2>/dev/null | tr -d '\r' | tail -n1)" +if [[ "$LAUNCHABLE" == */* ]]; then + echo "Abrindo app: $LAUNCHABLE" + "$ADB_BIN" -s "$TARGET_SERIAL" shell am start -n "$LAUNCHABLE" +else + echo "Abrindo app (fallback monkey): $APP_ID" + "$ADB_BIN" -s "$TARGET_SERIAL" shell monkey -p "$APP_ID" -c android.intent.category.LAUNCHER 1 +fi + +echo "Validando execução do app..." +sleep 3 +APP_PID="$("$ADB_BIN" -s "$TARGET_SERIAL" shell pidof "$APP_ID" 2>/dev/null | tr -d '\r')" +if [[ -z "$APP_PID" ]]; then + echo "Erro: app não permaneceu em execução após abrir." + "$ADB_BIN" -s "$TARGET_SERIAL" logcat -d | tail -n 120 || true + exit 1 +fi + +sleep "$RUN_STABILITY_SECONDS" +APP_PID_AFTER="$("$ADB_BIN" -s "$TARGET_SERIAL" shell pidof "$APP_ID" 2>/dev/null | tr -d '\r')" +if [[ -z "$APP_PID_AFTER" ]]; then + echo "Erro: app encerrou durante validação de estabilidade (${RUN_STABILITY_SECONDS}s)." + "$ADB_BIN" -s "$TARGET_SERIAL" logcat -d | tail -n 160 || true + exit 1 +fi + +echo "App instalado e aberto no Android." diff --git a/pedifoods_app_plan.md b/pedifoods_app_plan.md index d502cad..a2abe68 100644 --- a/pedifoods_app_plan.md +++ b/pedifoods_app_plan.md @@ -1,82 +1,133 @@ # Plano de implementacao do app PediFoods (Skip + SwiftUI) +## Legenda +- `[🟥]` Nao feito +- `[🟨]` Parcial +- `[🟩]` Completo + +## Estado atual (snapshot) +- `[🟩]` Base Skip + SwiftUI criada e compilando com fluxos `auth` e `main`. +- `[🟩]` Design system ativo com paleta, tipografia (Plus Jakarta Sans), raios e sombras. +- `[🟨]` Autenticacao com fluxo funcional de login por email/OTP (integracao inicial de API) e cadastro com navegacao pronta. +- `[🟨]` Home refeita com layout de referencia (header verde, categorias, cards, specials) e efeito de colapso em iOS. +- `[🟩]` Estado global (`AppState`) em uso para sessao, perfil, endereco, carrinho e favoritos. +- `[🟩]` API client/service com envelopes, retry/backoff, tratamento de erros/rate limit, politica de sessao expirada e persistencia segura de JWT no iOS. +- `[🟨]` Perfil com redesign visual alinhado a referencia, header no topo, lista de atalhos e confirmacao de logout. +- `[🟨]` Enderecos com lista real, criacao, edicao (reuso da tela de novo endereco) e exclusao por swipe compativel com Android. + ## 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 1 — Fundacao tecnica +- `[🟩]` Etapa geral +1. `[🟩]` Projeto Skip e estrutura base. +2. `[🟩]` Design system (cores/tipo/layout). +3. `[🟩]` Service base de API. +- `[🟩]` Feito: camada HTTP, headers padrao, tokens por modulo, tratamento de HTTP e 429, retry/backoff para falhas transientes, decode de erro com payload, politica de sessao expirada (`401/403`), limpeza de token em expiracao, persistencia de JWT em Keychain (iOS). +4. `[🟩]` Coordinator de navegacao. +- `[🟩]` Feito: fluxo `auth` -> `main`, tabbar com Home/Cart/Orders/Profile. +- `[🟩]` Feito: base de modais over-context (address picker e filtros) via `sheet` centralizado. +5. `[🟩]` State global (MVP). +6. `[🟩]` Permissions e GPS. +- `[🟩]` Feito: permissao/obter localizacao no iOS via `CoreLocation`, fallback de UX quando localizacao nao e obtida. +- `[🟩]` Feito: permissao de localizacao no Android (manifest + runtime request), cache de ultima localizacao valida e fallback para indisponivel/negado. ## 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 geral +1. `[🟩]` Tela Login (MVP). +2. `[🟩]` Tela Cadastro (MVP UI). +3. `[🟨]` Tela OTP (8 digitos). +- `[🟩]` Feito: fluxo OTP integrado no login por email e telefone. +- `[🟥]` Pendente: UX final de OTP em 8 digitos conforme referencia final. +4. `[🟨]` Fluxo pos-login. +- `[🟩]` Feito: login valida token e entra no fluxo principal. +- `[🟥]` Pendente: buscar perfil completo e sincronizar estado antes da Home. ## 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 geral +1. `[🟩]` Home com header, busca e categorias (MVP visual). +2. `[🟨]` Listagem de lojas via API `/api/app/stores`. +- `[🟩]` Feito: metodo de service pronto. +- `[🟥]` Pendente: bind real da Home com dados de API (atualmente usa mocks visuais). +3. `[🟨]` Filtros e busca. +- `[🟩]` Feito: UI base de busca/categorias e gatilho de modal de filtros. +- `[🟥]` Pendente: filtros reais + query server-side. +4. `[🟨]` Favoritos. +- `[🟩]` Feito: estado e affordance visual no card. +- `[🟥]` Pendente: persistencia/sync com backend. ## 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 geral +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 geral +1. `[🟥]` Tela de produto (detalhes + adicionais). +2. `[🟨]` Carrinho (Tab Cart). +- `[🟩]` Feito: tab e tela placeholder. +- `[🟥]` Pendente: itens reais, regra de negocio e estados. +3. `[🟥]` 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 geral +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 7 — Pedidos e rastreio +- `[🟥]` Etapa geral +1. `[🟨]` Lista de pedidos. +- `[🟨]` Feito: tab Orders criada + tela placeholder. +- `[🟥]` Pendente: lista real, detalhe e tracking. +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 8 — Perfil e enderecos +- `[🟨]` Etapa geral +1. `[🟨]` Perfil do usuario. +- `[🟩]` Feito: tela refeita com layout de referencia, header no topo, itens de menu, CTA de logout e confirmacao por alert. +- `[🟥]` Pendente: edicao completa de perfil e dados 100% vindos/sincronizados da API. +2. `[🟩]` Lista de enderecos. +- `[🟩]` Feito: carregamento de enderecos, selecao, estado principal e persistencia local da selecao. +3. `[🟩]` Criar/editar/excluir endereco. +- `[🟩]` Feito: criacao, edicao reutilizando a mesma tela e exclusao por swipe com compatibilidade Android. ## 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 geral +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 +- `[🟥]` Etapa geral +1. `[🟨]` Dark mode (presente em partes de auth). +2. `[🟨]` Estados vazios e erros. +3. `[🟥]` Performance e cache. +4. `[🟥]` Build iOS e Android (ha bloqueio atual de dependencia `LCEssentials` em `StoreDetailView.swift`). +5. `[🟥]` Export APK/AAB. +## Etapa 11 — Ajustes de UI (referencias) +- `[🟨]` Etapa geral +1. `[🟨]` Login (light/dark). +2. `[🟨]` Cadastro. +3. `[🟨]` OTP. +4. `[🟥]` Carrinho. +5. `[🟥]` Checkout. +6. `[🟨]` Perfil. +7. `[🟨]` Pedidos. + +## Proximos focos recomendados +1. `[🟨]` Conectar Home com API real (`stores`, busca, categoria, favoritos). +2. `[🟨]` Fechar fluxo auth pos-login (perfil + hidratacao de estado). +3. `[🟥]` Implementar carrinho real (itens, subtotal, edicao) para destravar checkout. +4. `[🟥]` Implementar mapa e tracking de pedidos. +5. `[🟥]` Corrigir bloqueio de build por dependencia (`LCEssentials`) para fechar validacao iOS/Android ponta a ponta. diff --git a/pedifoods_system_design.md b/pedifoods_system_design.md index 5b43f95..546c245 100644 --- a/pedifoods_system_design.md +++ b/pedifoods_system_design.md @@ -14,12 +14,11 @@ Este arquivo deve ser usado como **prompt base canônico** para qualquer IA, des ```ts colors: { - primary: "#8CFF2E", // ação principal, CTAs, destaque - secondary: "#388E3C", // ações secundárias, títulos + primary: "#388E3C", // ação principal, CTAs, destaque backgroundLight: "#F7F8F5", backgroundDark: "#18230F", - textPrimary: "#333333", - textInverse: "#FFFFFF" + textPrimary: "#FFFFFF", + textInverse: "#333333" } ``` @@ -31,21 +30,6 @@ Uso: --- -### 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 @@ -70,7 +54,7 @@ typography: { body: { size: "16px", weight: 400, - lineHeight: "1.6" + lineHeight: "0" }, button: { @@ -195,30 +179,3 @@ Icon { 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/reference_img/IMG_0442.PNG b/reference_img/IMG_0442.PNG new file mode 100644 index 0000000..36c8b33 Binary files /dev/null and b/reference_img/IMG_0442.PNG differ diff --git a/reference_img/IMG_0443.PNG b/reference_img/IMG_0443.PNG new file mode 100644 index 0000000..861e482 Binary files /dev/null and b/reference_img/IMG_0443.PNG differ diff --git a/reference_img/caracters/canceled_order.png b/reference_img/caracters/canceled_order.png new file mode 100644 index 0000000..3348e9e Binary files /dev/null and b/reference_img/caracters/canceled_order.png differ diff --git a/reference_img/caracters/converted/tracking-canceled.png b/reference_img/caracters/converted/tracking-canceled.png new file mode 100644 index 0000000..6b0acbc Binary files /dev/null and b/reference_img/caracters/converted/tracking-canceled.png differ diff --git a/reference_img/caracters/converted/tracking-completed.png b/reference_img/caracters/converted/tracking-completed.png new file mode 100644 index 0000000..fee3d7f Binary files /dev/null and b/reference_img/caracters/converted/tracking-completed.png differ diff --git a/reference_img/caracters/converted/tracking-delivering.png b/reference_img/caracters/converted/tracking-delivering.png new file mode 100644 index 0000000..2d996ae Binary files /dev/null and b/reference_img/caracters/converted/tracking-delivering.png differ diff --git a/reference_img/caracters/converted/tracking-pending.png b/reference_img/caracters/converted/tracking-pending.png new file mode 100644 index 0000000..a4a9344 Binary files /dev/null and b/reference_img/caracters/converted/tracking-pending.png differ diff --git a/reference_img/caracters/converted/tracking-preparing.png b/reference_img/caracters/converted/tracking-preparing.png new file mode 100644 index 0000000..23fc9a7 Binary files /dev/null and b/reference_img/caracters/converted/tracking-preparing.png differ diff --git a/reference_img/caracters/converted/tracking-ready.png b/reference_img/caracters/converted/tracking-ready.png new file mode 100644 index 0000000..fe2f0f2 Binary files /dev/null and b/reference_img/caracters/converted/tracking-ready.png differ diff --git a/reference_img/caracters/delivered_order.png b/reference_img/caracters/delivered_order.png new file mode 100644 index 0000000..0b5ad40 Binary files /dev/null and b/reference_img/caracters/delivered_order.png differ diff --git a/reference_img/caracters/delivering_order.png b/reference_img/caracters/delivering_order.png new file mode 100644 index 0000000..f3291ad Binary files /dev/null and b/reference_img/caracters/delivering_order.png differ diff --git a/reference_img/caracters/pending_order.png b/reference_img/caracters/pending_order.png new file mode 100644 index 0000000..2db91b0 Binary files /dev/null and b/reference_img/caracters/pending_order.png differ diff --git a/reference_img/caracters/preparing_order.png b/reference_img/caracters/preparing_order.png new file mode 100644 index 0000000..6e466b5 Binary files /dev/null and b/reference_img/caracters/preparing_order.png differ diff --git a/reference_img/caracters/ready_order.png b/reference_img/caracters/ready_order.png new file mode 100644 index 0000000..44b72eb Binary files /dev/null and b/reference_img/caracters/ready_order.png differ diff --git a/reference_img/login_dark.png b/reference_img/login_dark.png new file mode 100644 index 0000000..bf637d4 Binary files /dev/null and b/reference_img/login_dark.png differ diff --git a/reference_img/login_light.png b/reference_img/login_light.png new file mode 100644 index 0000000..e7c5b09 Binary files /dev/null and b/reference_img/login_light.png differ diff --git a/reference_img/payment_card.png b/reference_img/payment_card.png new file mode 100644 index 0000000..9ce174f Binary files /dev/null and b/reference_img/payment_card.png differ diff --git a/reference_img/payment_pix.png b/reference_img/payment_pix.png new file mode 100644 index 0000000..adeea2d Binary files /dev/null and b/reference_img/payment_pix.png differ diff --git a/reference_img/pin_image_app.png b/reference_img/pin_image_app.png new file mode 100644 index 0000000..7ed510e Binary files /dev/null and b/reference_img/pin_image_app.png differ diff --git a/reference_img/pin_image_app_dark.png b/reference_img/pin_image_app_dark.png new file mode 100644 index 0000000..450d7bc Binary files /dev/null and b/reference_img/pin_image_app_dark.png differ