Merge pull request 'feature/step-001' (#1) from feature/step-001 into main

Reviewed-on: Loverde-Company-LTDA/Pedi-Foods-Skip#1
This commit is contained in:
2026-03-10 12:21:36 +00:00
190 changed files with 18281 additions and 437 deletions

View File

@@ -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 <OTP>`.
**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://<host>:<port>/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 <Users_JWT_Token>"
}
}
```
**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 <Users_JWT_Token>` (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.

View File

@@ -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://<host>:<port>/socket.io/` (usar `wss://` em produção com TLS)
**Auth no handshake:**
- Enviar JWT no campo `auth.token`.
- Exemplo: `Bearer <JWT_TOKEN>`.
**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).

BIN
logo/icon_pix.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 835 B

BIN
logo/pedifoods.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
logo/pedifoods_dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -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

View File

@@ -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

View File

@@ -1,9 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- This AndroidManifest.xml template was generated by Skip -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
<!-- example permissions for using device location -->
<!-- <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/> -->
<!-- <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/> -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<!-- permissions needed for using the internet or an embedded WebKit browser -->
<uses-permission android:name="android.permission.INTERNET" />

View File

@@ -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<String>, 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 {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 565 B

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 897 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

View File

@@ -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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -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
}
}

View File

@@ -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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

View File

@@ -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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "placeholder-product.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

View File

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "tracking-canceled.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 KiB

View File

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "tracking-completed.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 924 KiB

View File

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "tracking-delivering.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 951 KiB

View File

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "tracking-pending.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 940 KiB

View File

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "tracking-preparing.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 926 KiB

View File

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "tracking-ready.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 799 KiB

View File

@@ -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

View File

@@ -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 = "<group>"; };
49F90C312A52156300F06D93 /* Entitlements.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Entitlements.plist; sourceTree = "<group>"; };
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 */;

View File

@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="23504" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<device id="retina6_12" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="23506"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" image="pedifoods" translatesAutoresizingMaskIntoConstraints="NO" id="W6R-s6-hHG">
<constraints>
<constraint firstAttribute="height" constant="160" id="S0g-eL-cqR"/>
</constraints>
</imageView>
</subviews>
<viewLayoutGuide key="safeArea" id="Bcu-3y-fUS"/>
<color key="backgroundColor" red="0.9529411765" green="0.9607843137" blue="0.9686274510" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="W6R-s6-hHG" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="F0M-uu-fA3"/>
<constraint firstItem="W6R-s6-hHG" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="M3D-C3-C0c"/>
<constraint firstItem="W6R-s6-hHG" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="Bcu-3y-fUS" secondAttribute="leading" constant="24" id="kT5-M8-Yrq"/>
<constraint firstItem="Bcu-3y-fUS" firstAttribute="trailing" relation="greaterThanOrEqual" secondItem="W6R-s6-hHG" secondAttribute="trailing" constant="24" id="rbz-Yf-pdY"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="50" y="32"/>
</scene>
</scenes>
<resources>
<image name="pedifoods" width="1024" height="1024"/>
</resources>
</document>

View File

@@ -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`)

View File

@@ -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.

View File

@@ -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"]),
]
)

View File

@@ -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"

View File

@@ -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)
}
}

View File

@@ -0,0 +1,176 @@
import Foundation
import SwiftUI
#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif
struct CachedRemoteImage<Placeholder: View>: 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<Void, Never>?
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[..<commaIndex]).lowercased()
guard header.contains(";base64") else { return nil }
let payloadStart = source.index(after: commaIndex)
let payload = String(source[payloadStart...])
.replacingOccurrences(of: "\\/", with: "/")
.replacingOccurrences(of: "\n", with: "")
.replacingOccurrences(of: "\r", with: "")
.replacingOccurrences(of: " ", with: "")
guard let data = Data(base64Encoded: payload, options: [.ignoreUnknownCharacters]) else { return nil }
#if canImport(UIKit)
return UIImage(data: data)
#elseif canImport(AppKit)
return NSImage(data: data)
#else
return nil
#endif
}
#endif
}

View File

@@ -0,0 +1,43 @@
import SwiftUI
struct SearchField: View {
var placeholder: String
@Binding var text: String
var body: some View {
HStack(spacing: 12) {
Image(systemName: "magnifyingglass")
.foregroundStyle(AppColors.secondary)
TextField(placeholder, text: $text)
.appNoAutoCap()
}
.padding(.horizontal, 16)
.frame(height: 52)
.background(AppColors.backgroundLight)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)
.stroke(AppColors.secondary.opacity(0.15), lineWidth: 1)
)
}
}
struct PillButton: View {
let title: String
let isActive: Bool
var body: some View {
Text(title)
.font(AppTypography.caption)
.tracking(AppTypography.captionLetterSpacing)
.foregroundStyle(isActive ? AppColors.textInverse : AppColors.secondary)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(isActive ? AppColors.primary : AppColors.backgroundLight)
.clipShape(Capsule())
.overlay(
Capsule()
.stroke(AppColors.secondary.opacity(0.15), lineWidth: isActive ? 0 : 1)
)
}
}

View File

@@ -0,0 +1,46 @@
import SwiftUI
struct SnackbarOverlay: View {
#if os(Android)
let center: SnackbarCenter
#else
@ObservedObject var center: SnackbarCenter
#endif
var body: some View {
#if os(Android)
EmptyView()
#else
VStack {
if let message = center.current {
HStack(spacing: 10) {
if let icon = message.iconSystemName, !icon.isEmpty {
Image(systemName: icon)
.font(.system(size: 16, weight: .semibold))
}
Text(message.title)
.font(AppTypography.heading3)
.multilineTextAlignment(.leading)
.lineLimit(3)
Spacer(minLength: 0)
}
.foregroundStyle(Color.white)
.padding(.top, 14)
.padding(.horizontal, 16)
.padding(.bottom, 16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(message.style.backgroundColor)
.contentShape(Rectangle())
.onTapGesture {
center.handleTap()
}
.transition(.move(edge: .top).combined(with: .opacity))
.zIndex(999)
}
Spacer()
}
.animation(.spring(response: 0.3, dampingFraction: 0.9), value: center.current?.id)
.allowsHitTesting(center.current != nil)
#endif
}
}

View File

@@ -0,0 +1,167 @@
import SwiftUI
struct FeaturedStoreCard: View {
let store: FeaturedStoreCardModel
var body: some View {
VStack(alignment: .leading, spacing: 12) {
ZStack(alignment: .topLeading) {
mediaBlock
if let promo = store.promoText {
Text(promo)
.font(AppTypography.caption)
.foregroundStyle(AppColors.textInverse)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color.red)
.clipShape(Capsule())
.padding(10)
}
HStack {
Spacer()
Button(action: {}) {
Image(systemName: store.isFavorite ? "heart.fill" : "heart")
.foregroundStyle(store.isFavorite ? Color.red : AppColors.textMuted)
.padding(8)
.background(AppColors.surface)
.clipShape(Circle())
.shadow(color: AppShadow.soft.color, radius: AppShadow.soft.radius, y: AppShadow.soft.y)
}
.padding(10)
}
}
Text(store.name)
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
if store.isOpen {
HStack(spacing: 6) {
Image(systemName: "star.fill")
.font(.caption)
.foregroundStyle(Color(hex: "#F5B335"))
Text(String(format: "%.1f", store.rating))
.font(.caption)
.foregroundStyle(AppColors.textPrimary)
Text("(\(store.reviews))")
.font(.caption)
.foregroundStyle(AppColors.textMuted)
Text("·")
.font(.caption)
.foregroundStyle(AppColors.textMuted)
Text(store.distance)
.font(.caption)
.foregroundStyle(AppColors.textMuted)
}
} else {
Text(store.statusLabel?.isEmpty == false ? (store.statusLabel ?? "Fechado") : "Fechado")
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
}
Text(store.category)
.font(.caption)
.foregroundStyle(AppColors.textMuted)
}
.padding(14)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous))
.saturation(store.isOpen ? 1 : 0)
.opacity(store.isOpen ? 1 : 0.9)
}
private var mediaBlock: some View {
ZStack {
RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)
.fill(AppColors.brandSoft)
mediaImage
}
.frame(height: 120)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
@ViewBuilder
private var mediaImage: some View {
AsyncStoreImage(imageURL: store.imageURL)
}
private var storeIconPlaceholder: some View {
ZStack {
Image("placeholder-product")
.resizable()
.scaledToFill()
.opacity(0.7)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.clipped()
Circle()
.fill(AppColors.surface.opacity(0.92))
.frame(width: 64, height: 64)
.overlay(
Image(systemName: store.iconName)
.font(.title2)
.foregroundStyle(AppColors.primary)
)
}
}
}
struct FeaturedStoreCardModel: Identifiable {
let id: String
let name: String
let rating: Double
let reviews: String
let distance: String
let category: String
let promoText: String?
let isFavorite: Bool
let iconName: String
let imageURL: String?
let logoURL: String?
let coverURL: String?
let isOpen: Bool
let statusLabel: String?
}
struct SpecialOfferCard: View {
let model: SpecialOfferCardModel
var body: some View {
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous)
.fill(
LinearGradient(
colors: model.colors,
startPoint: .leading,
endPoint: .trailing
)
)
Circle()
.fill(Color.white.opacity(0.18))
.frame(width: 120, height: 120)
.offset(x: 140, y: 10)
VStack(alignment: .leading, spacing: 8) {
Text(model.title)
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textInverse)
Text(model.subtitle)
.font(AppTypography.body)
.foregroundStyle(AppColors.textInverse.opacity(0.85))
}
.padding(20)
}
.shadow(color: AppShadow.soft.color, radius: AppShadow.soft.radius, y: AppShadow.soft.y)
}
}
struct SpecialOfferCardModel: Identifiable {
let id: String
let title: String
let subtitle: String
let colors: [Color]
}

View File

@@ -0,0 +1,25 @@
import SwiftUI
extension View {
@ViewBuilder
func appNoAutoCap() -> some View {
#if os(iOS)
self
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
#else
self
#endif
}
@ViewBuilder
func appOTPKeyboard() -> some View {
#if os(iOS)
self
.keyboardType(.numberPad)
.textContentType(.oneTimeCode)
#else
self
#endif
}
}

View File

@@ -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)
}
}
}

View File

@@ -0,0 +1,10 @@
enum RootFlow: Hashable {
case auth
case main
}
enum MainTab: Hashable {
case home
case cart
case profile
}

View File

@@ -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)
}
}

View File

@@ -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"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Some files were not shown because too many files have changed in this diff Show More