Merge pull request 'main' (#45) from main into develop

Reviewed-on: Loverde-Company-LTDA/Pedi-Foods-Skip#45
This commit is contained in:
2026-09-10 18:23:42 -03:00
296 changed files with 28668 additions and 3168 deletions

67
.gitea/workflows/beta.yml Normal file
View File

@@ -0,0 +1,67 @@
name: Beta (TestFlight)
on:
push:
branches:
- develop
jobs:
wake-macos-vm:
runs-on: macos-ctl
steps:
- name: Start macOS VM container
run: docker start macos
build-and-upload:
needs: wake-macos-vm
runs-on: macos-release
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
ITC_TEAM_ID: ${{ secrets.ITC_TEAM_ID }}
DEVELOPMENT_TEAM: ${{ secrets.DEVELOPMENT_TEAM }}
BUILD_NUMBER: ${{ gitea.run_number }}
steps:
- name: Checkout
run: |
export GIT_TERMINAL_PROMPT=0
export GIT_ASKPASS=/bin/false
export SSH_ASKPASS=/bin/false
REPO_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
AUTH_URL="$(echo "$REPO_URL" | sed "s#https://#https://x-access-token:${{ gitea.token }}@#")"
git -c credential.helper= -c http.lowSpeedLimit=1000 -c http.lowSpeedTime=30 clone "$AUTH_URL" .
git checkout "${{ gitea.sha }}"
- name: Write App Store Connect API key
run: |
cat > fastlane/apikey.json <<EOF
{
"key_id": "${{ secrets.ASC_KEY_ID }}",
"issuer_id": "${{ secrets.ASC_ISSUER_ID }}",
"key": "${{ secrets.ASC_KEY_CONTENT }}",
"is_key_content_base64": true,
"duration": 1200,
"in_house": false
}
EOF
- name: Unlock keychain and run fastlane beta
run: |
security unlock-keychain -p "${{ secrets.CI_KEYCHAIN_PASSWORD }}" ~/Library/Keychains/ci-signing.keychain-db
security list-keychains -d user -s ~/Library/Keychains/ci-signing.keychain-db ~/Library/Keychains/login.keychain-db
security default-keychain -d user -s ~/Library/Keychains/ci-signing.keychain-db
security find-identity -v -p codesigning
fastlane beta
- name: Clean up API key
if: always()
run: rm -f fastlane/apikey.json
sleep-macos-vm:
needs: build-and-upload
if: always()
runs-on: macos-ctl
steps:
- name: Stop macOS VM container
run: docker stop macos

View File

@@ -0,0 +1,85 @@
name: Release (App Store Connect)
on:
push:
branches:
- main
jobs:
wake-macos-vm:
runs-on: macos-ctl
steps:
- name: Start macOS VM container
run: docker start macos
build-archive-deliver:
needs: wake-macos-vm
runs-on: macos-release
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
ITC_TEAM_ID: ${{ secrets.ITC_TEAM_ID }}
DEVELOPMENT_TEAM: ${{ secrets.DEVELOPMENT_TEAM }}
BUILD_NUMBER: ${{ gitea.run_number }}
steps:
- name: Checkout
run: |
export GIT_TERMINAL_PROMPT=0
export GIT_ASKPASS=/bin/false
export SSH_ASKPASS=/bin/false
REPO_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
AUTH_URL="$(echo "$REPO_URL" | sed "s#https://#https://x-access-token:${{ gitea.token }}@#")"
git -c credential.helper= -c http.lowSpeedLimit=1000 -c http.lowSpeedTime=30 clone "$AUTH_URL" .
git checkout "${{ gitea.sha }}"
- name: Verify ASC secrets
run: |
if [ -z "${{ secrets.ASC_KEY_ID }}" ]; then
echo "ERROR: ASC_KEY_ID is empty"
exit 1
fi
- name: Write App Store Connect API key
run: |
cat > fastlane/apikey.json <<EOF
{
"key_id": "${{ secrets.ASC_KEY_ID }}",
"issuer_id": "${{ secrets.ASC_ISSUER_ID }}",
"key": "${{ secrets.ASC_KEY_CONTENT }}",
"is_key_content_base64": true,
"duration": 1200,
"in_house": false
}
EOF
- name: Unlock keychain and run fastlane release
run: |
echo "whoami: $(whoami)"
echo "HOME: $HOME"
echo "expanded keychain path: $HOME/Library/Keychains/ci-signing.keychain-db"
ls -la "$HOME/Library/Keychains/" || echo "cannot list Keychains dir"
security unlock-keychain -p "${{ secrets.CI_KEYCHAIN_PASSWORD }}" "$HOME/Library/Keychains/ci-signing.keychain-db"
echo "--- keychain info ---"
security show-keychain-info "$HOME/Library/Keychains/ci-signing.keychain-db" || true
security list-keychains -d user -s "$HOME/Library/Keychains/ci-signing.keychain-db" "$HOME/Library/Keychains/login.keychain-db"
security default-keychain -d user -s "$HOME/Library/Keychains/ci-signing.keychain-db"
echo "--- current search list ---"
security list-keychains
echo "--- dump-keychain identity count ---"
security dump-keychain "$HOME/Library/Keychains/ci-signing.keychain-db" | grep -c "class.*0x80001000" || true
echo "--- find-identity ---"
security find-identity -v -p codesigning
fastlane release
- name: Clean up API key
if: always()
run: rm -f fastlane/apikey.json
sleep-macos-vm:
needs: build-archive-deliver
if: always()
runs-on: macos-ctl
steps:
- name: Stop macOS VM container
run: docker stop macos

43
.gitea/workflows/test.yml Normal file
View File

@@ -0,0 +1,43 @@
name: Test (feature branches)
on:
pull_request:
branches:
- "feature/*"
jobs:
wake-macos-vm:
runs-on: macos-ctl
steps:
- name: Start macOS VM container
run: docker start macos
test:
needs: wake-macos-vm
runs-on: macos-release
steps:
- name: Checkout
run: |
export GIT_TERMINAL_PROMPT=0
export GIT_ASKPASS=/bin/false
export SSH_ASKPASS=/bin/false
REPO_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
AUTH_URL="$(echo "$REPO_URL" | sed "s#https://#https://x-access-token:${{ gitea.token }}@#")"
git -c credential.helper= -c http.lowSpeedLimit=1000 -c http.lowSpeedTime=30 clone "$AUTH_URL" .
git checkout "${{ gitea.sha }}"
- name: Run unit tests
run: |
xcodebuild test \
-project PediFoods.xcodeproj \
-scheme "PediFoods" \
-destination "platform=iOS Simulator,name=iPhone 16,OS=latest"
sleep-macos-vm:
needs: test
if: always()
runs-on: macos-ctl
steps:
- name: Stop macOS VM container
run: docker stop macos

View File

@@ -7,14 +7,6 @@
# macOS
.DS_Store
# gradle properties
local.properties
.gradle/
.android/
.kotlin/
Android/app/keystore.jks
Android/app/keystore.properties
xcodebuild*.log
default.profraw

View File

@@ -1,358 +0,0 @@
# Atomenta Mobile App API Documentation
## 🔐 Authentication & Headers
The mobile app accesses distinct sets of APIs:
1. **App APIs (`/api/app/*`)**: For general app features like Home Screen and Orders listing.
2. **Customer APIs (`/api/customer/*`)**: For User Registration, Login, and Profile Management.
3. **Store External APIs (`/api/store/*`)**: For store-specific operations like Catalog and Checkout.
### Common Headers
All requests should ideally include:
```http
Accept: application/json
Content-Type: application/json
```
### Authentication Strategies
#### 1. Customer Auth (for App & Customer APIs)
Used for everything related to the logged-in user (Profile, Orders, etc).
- **Header:** `Authorization: Bearer <Users_JWT_Token>`
- **Note:** Obtained via `/api/customer/login`.
#### 3. Hybrid Store Auth (for Store External APIs)
Used when interacting with a specific store (Catalog, Checkout).
- **Header 1:** `Atomenta-Token: 550e8400-e29b-41d4-a716-446655440008` (Store Module ID)
- **Header 2:** `Authorization: Bearer <Users_JWT_Token>` (Required for Checkout/Orders)
---
## 👤 Customer Management
### 1. Register Customer
**POST** `/api/customer`
**Headers:**
- `Atomenta-Token: 550e8400-e29b-41d4-a716-44665544000a` (Customer Module ID)
**Body:**
```json
{
"name": "Daniel Loverde",
"email": "daniel@example.com",
"phoneNumber": "+5511999999999",
"birthDate": "1990-01-01T00:00:00Z" // Opcional
}
```
**Response:**
```json
{
"error": false,
"code": "CUSTOMER_CREATED",
"result": {
"id": "cust_uuid...",
"name": "Daniel Loverde",
"email": "daniel@example.com"
}
}
```
### 2. Login (Get Token)
**POST** `/api/customer/login`
**Body:**
```json
{
"email": "daniel@example.com",
"otp": "123456" // Em dev/demo, apenas email pode bastar se configurado assim
}
```
**Response:**
```json
{
"error": false,
"code": "LOGIN_SUCCESS",
"result": {
"token": "eyJhbGciOi...", // <--- Use as Bearer Token for other requests
"customer": {
"id": "cust_uuid...",
"name": "Daniel Loverde",
"email": "daniel@example.com"
}
}
}
```
### 3. Get Profile
**GET** `/api/customer/profile`
**Headers:**
- `Authorization: Bearer <JWT_Token>`
- `Atomenta-Token: 550e8400-e29b-41d4-a716-44665544000a`
**Response:**
```json
{
"error": false,
"code": "CUSTOMER_PROFILE_RETRIEVED",
"result": {
"id": "cust_uuid...",
"name": "Daniel Loverde",
"email": "daniel@example.com",
"phoneNumber": "+5511999999999",
"favorites": ["store_abc...", "store_xyz..."],
"address_book": [],
"behavioral_stats": {
"total_orders": 5,
"avg_ticket_size": 45.00
}
}
}
```
### 4. Update Profile
**POST** `/api/customer/:id`
**Headers:**
- `Authorization: Bearer <JWT_Token>`
- `Atomenta-Token: 550e8400-e29b-41d4-a716-44665544000a`
**Body:**
```json
{
"name": "Daniel A. Loverde", // Fields to update
"phoneNumber": "+5511988888888",
"biometricsEnabled": true
}
```
---
## 🏠 App Home Screen
### List Stores (Smart Listing)
**GET** `/api/app/stores`
**Headers:**
- `Authorization: Bearer <Users_JWT_Token>` (Customer obrigatório)
Lists stores based on user location, filtered by distance.
**Query Parameters:**
- `lat` (Required): User Latitude (e.g., `-23.550520`)
- `lng` (Required): User Longitude (e.g., `-46.633308`)
- `category` (Optional): Filter by category name (e.g., `Lanches`)
- `search` (Optional): Search by store name
**Notes:**
- Sem login do customer, a listagem é bloqueada.
- Se `lat/lng` não forem enviados, a API tenta usar o endereço salvo no perfil do customer (`address_book.lat_long`).
- Lojas fora do raio/bairro de entrega não são exibidas.
**Response:**
```json
{
"error": false,
"result": [
{
"id": "store_123...",
"name": "Burger King",
"logo": "https://...",
"cover": "https://...",
"category": "Lanches",
"rating": 4.8,
"deliveryTime": "30-45 min",
"deliveryFee": 5.99,
"distance": 1.2, // km
"isOpen": true,
"statusLabel": "Aberto"
}
]
}
```
### List User Orders
**GET** `/api/app/orders`
**Headers:**
- `Authorization: Bearer <JWT>`
**Response:**
```json
{
"error": false,
"result": [
{
"id": "ord_123...",
"total": 54.90,
"status": "completed",
"createdAt": "2024-03-20T10:00:00Z",
"items": [...]
}
]
}
```
---
## 🏪 Store Integration (External API)
**Base URL:** `/api/store/:storeId`
### 1. Store Identity & Status
**GET** `/api/store/:storeId/identity`
**Headers/Query:**
- `x-user-lat` / `lat`: User Latitude
- `x-user-lng` / `lng`: User Longitude
**Response:**
```json
{
"error": false,
"result": {
"fantasyName": "Mc Donalds",
"distance": 2.5,
"deliveryTime": "30-45 min",
"minOrder": 15.00
}
}
```
### 2. Full Store Info
**GET** `/api/store/:storeId/info`
Returns detailed info including operating hours and accepted payment methods.
**Response:**
```json
{
"error": false,
"code": "STORE_INFO_RETRIEVED",
"result": {
"isOpen": true,
"paymentMethods": {
"paymentOnDelivery": true,
"paymentOnPickup": true,
"acceptCash": true,
"acceptPix": true,
"acceptCreditCard": true, // Resumo Geral
"acceptDebitCard": true, // Resumo Geral
"brands": {
"credit": {
"visa": true,
"master": true,
"elo": true,
"amex": false,
"hipercard": false
},
"debit": {
"visa": true,
"master": true,
"elo": true
}
}
},
"openingHours": { ... },
"address": { ... }
}
}
```
### 3. Product Catalog
**GET** `/api/store/:storeId/catalog`
Returns categories with their respective products.
**Response:**
```json
{
"error": false,
"result": [
{
"id": "cat_1...",
"name": "Burgers",
"products": [
{
"id": "prod_1...",
"name": "Big Mac",
"price": 25.90,
"image": "https://...",
"addonGroups": [...]
}
]
}
]
}
```
### 4. Create Order (Checkout)
**POST** `/api/store/:storeId/orders`
**Body:**
```json
{
"customer": {
"name": "Daniel Loverde",
"phone": "11999999999",
"email": "daniel@example.com",
"asaasId": "cus_000005165985" // Opcional.
},
// Nota: O usuário DEVE estar autenticado via Bearer Token.
// Os dados de 'customer' enviados aqui são usados preferencialmente para
// registrar os dados de entrega/cobrança deste pedido específico no Asaas,
// mas o 'userId' e o histórico são vinculados ao token do usuário logado.
"items": [
{
"productId": "prod_123",
"qty": 2,
"price": 25.00,
"addons": []
}
],
"total": 50.00,
"paymentMethod": "PIX", // "PIX" | "CREDIT_CARD" | "DEBIT_CARD" | "MONEY"
"deliveryType": "DELIVERY", // "DELIVERY" | "PICKUP"
"address": {
"street": "Rua Exemplo",
"number": "123",
"neighborhood": "Centro"
}
}
```
**Response:**
```json
{
"error": false,
"code": "ORDER_CREATED",
"result": {
"id": "ord_987...",
"status": "created",
"paymentPayload": "https://www.asaas.com/i/..." // Link para pagamento (Pix/Boleto)
}
}
```

View File

@@ -1,389 +0,0 @@
# Atomenta API - Store Management & External Guide (Pedi Foods Edition)
Este documento é o guia definitivo e completo para parceiros e aplicações externas (como pedifoods.com.br) que consomem a API da plataforma Atomenta para gestão de lojas. **Esta API reflete 100% das funcionalidades do painel administrativo.**
## 🔐 Autenticação e Headers
Todas as chamadas devem usar autenticação híbrida.
### Headers Obrigatórios:
- **Accept**: `application/json`
- **Atomenta-Token**: `550e8400-e29b-41d4-a716-446655440008` (Escopo: Store Management)
- **Authorization**: `Bearer <JWT_TOKEN>`
---
## 🏬 Gestão de Identidade e Lojas
### 1. Listar Lojas do Usuário
**GET** `/api/store/list` - Retorna todas as lojas vinculadas ao token do usuário.
### 2. Informações Completas da Loja (Settings)
**GET** `/api/store/:storeId/info` - Retorna a configuração completa da loja, incluindo dados bancários, horários e flags de pagamento.
**Resposta Exemplo:**
```json
{
"error": false,
"result": {
"isOpen": true,
"statusLabel": "Aberto Agora",
"isManualOpen": true,
"fantasyName": "Pizzaria do Arantes",
"razaoSocial": "Loverde Co LTDA",
"document": "12.345.678/0001-99",
"documentType": "CNPJ",
"email": "loja@loverde.com.br",
"phone": "11988776655",
"paymentMethods": {
"paymentOnDelivery": true,
"acceptCash": true,
"acceptCreditVisa": true,
"acceptCreditMaster": true,
"acceptCreditElo": true,
"acceptVoucherAlelo": true,
"acceptPix": true
},
"bankInfo": {
"bankName": "Banco do Brasil",
"accountType": "Corrente",
"agency": "1234",
"account": "56789-0",
"pixKey": "12345678000199"
},
"openingHours": {
"monday": [{"open": "18:00", "close": "23:00"}],
"friday": [{"open": "11:00", "close": "15:00"}, {"open": "18:00", "close": "00:00"}]
},
"address": {
"street": "Av. Brasil, 1000",
"latitude": -23.55052,
"longitude": -46.633308
}
}
}
```
---
## 📊 Dashboard Real-time (Portal Parity)
### 3. Dashboard e Monitoramento
**GET** `/api/store/:storeId/dashboard`
**Resposta Detalhada (100% Parity):**
```json
{
"error": false,
"result": {
"orderFlow": {
"urgencys": [ { "id": "ORD-123", "clientName": "Daniel", "minutes": 45, "status": "PREPARING" } ],
"received": [ /* Pedidos Pendentes */ ],
"inProgress": [ /* Pedidos Em Preparo */ ],
"readyTo": [ /* Pedidos Prontos para Entrega/Retirada */ ],
"inRoute": [ /* Pedidos em Rota */ ],
"delivered": [ /* Pedidos Finalizados na Sessão */ ]
},
"payments": {
"totalSelledToday": 2450.00,
"growthToday": 15.5,
"avgTicket": 65.20,
"growthPeriod": 8.5
},
"alerts": {
"pendingOver3Minutes": 2,
"delayedInProgress": 1
}
}
}
```
---
## 🍴 Catálogo e Menu (Complex Payloads)
### 4. Categorias de Pizza
**POST** `/api/store/:storeId/catalog/categories`
```json
{
"name": "Pizzas Salgadas",
"isPizzaCategory": true,
"pizzaConfig": {
"sizes": [
{ "id": "small", "name": "Broto", "slices": 4, "maxFlavors": 1 },
{ "id": "large", "name": "Grande", "slices": 8, "maxFlavors": 3 }
],
"doughs": [ { "id": "tradicional", "name": "Tradicional", "price": 0 } ],
"crusts": [ { "id": "catupiry", "name": "Borda de Catupiry", "price": 10.00 } ]
}
}
```
### 5. Produtos Tipo Combo ou Pizza
**POST** `/api/store/:storeId/catalog/products`
```json
{
"type": "pizza",
"name": "Pizza de Calabresa",
"categoryId": "cat_pizzas",
"pizzaPrices": {
"small": "35.00",
"large": "55.00"
},
"addonGroups": [
{
"name": "Remover Ingredientes",
"minSelectors": 0, "maxSelectors": 5,
"items": [ { "name": "Sem Cebola", "price": 0 } ]
}
]
}
```
---
## 📦 Histórico e Detalhes do Pedido
### 6. Histórico com Filtros
**GET** `/api/store/:storeId/orders/history?start=2024-01-01&end=2024-01-31&q=Ana`
**Objeto de Pedido Completo (result.list[0]):**
```json
{
"id": "order_guid",
"shortId": "1234",
"clientName": "Arantes Loverde",
"totalValue": 120.50,
"netValue": 112.40,
"pediFoodsFee": 1.00,
"status": "COMPLETED",
"createdAt": "2024-01-08T15:00:00Z",
"confirmOtp": "12345678",
"deliveryEstimate": "35-50 min",
"deliveryTypeLabel": "Entrega Própria",
"timeline": [
{ "status": "PENDING", "time": "15:00:00", "message": "Pedido criado" },
{ "status": "ACCEPTED", "time": "15:02:00", "message": "Chef aceitou o pedido" },
{ "status": "COMPLETED", "time": "15:45:00", "message": "Entregue ao cliente" }
],
"items": [
{ "name": "Pizza Grande", "price": 55.00, "choices": ["Borda Catupiry", "Meio Calabresa", "Meio Mussarela"] }
]
}
```
> [!IMPORTANT]
> O campo `confirmOtp` (8 dígitos) é o código que o motoboy deve utilizar para iniciar a confirmação. O `customerOtp` (4 dígitos do cliente) **NÃO** é retornado via API por questões de segurança.
```
### 7. Detalhes de um Pedido Individual
**GET** `/api/store/:storeId/orders/:orderId`
Retorna o objeto completo do pedido (parity 100% com o painel).
---
## 💰 Financeiro Detalhado
### 7. Resumo de Performance e Gateway
**GET** `/api/store/:storeId/financial/summary`
**Resposta:**
```json
{
"error": false,
"result": {
"performance": {
"totalSelledAllTime": 150230.50,
"avgTicket": 72.00
},
"gateway": {
"balance": { "balance": 1500.00, "available": 1200.00 },
"transfers": [
{ "id": "TRANS-1", "date": "2024-01-05", "value": 500.00, "status": "DONE" }
]
}
}
}
```
---
## ⭐ Reviews e Avaliações
### 8. Métricas e Respostas
**GET** `/api/store/:storeId/reviews`
**Resposta:**
```json
{
"error": false,
"result": {
"metrics": {
"averageRate": "4.1",
"totalReviews": 260,
"starsDistribution": { "5": 121, "4": 60, "3": 23, "2": 15, "1": 39 },
"itemFeedback": {
"flavor": 207,
"ingredients": 94,
"packaging": 35,
"temperature": 29,
"appearance": 23
},
"improvementFeedback": {
"quantity": 128,
"temperature": 89,
"packaging": 42,
"ingredients": 23,
"flavor": 13
},
"deliveryFeedback": {
"positive": 102,
"negative": 60
}
},
"list": [
{
"id": "rev_1",
"clientName": "Ana",
"rate": 5,
"message": "Melhor pizza!",
"reply": "Obrigado Ana!",
"repliedAt": "2024-01-08T16:00:00Z"
}
]
}
}
```
### 9. Contestar Avaliação
**POST** `/api/store/:storeId/reviews/:reviewId/dispute`
```json
{ "reason": "Cliente agressivo e mensagem falsa sobre o produto." }
```
---
## 🖼️ Upload de Ativos e Galeria
**POST** `/api/store/:storeId/assets/upload` (Form-data: `file`) -> Retorna URL absoluta.
**GET** `/api/store/:storeId/assets` -> Retorna lista de URLs absolutas de todas as imagens da loja (logo, capa e produtos).
---
## 🏷️ Reordenação de Catálogo
**POST** `/api/store/:storeId/catalog/products/reorder` (Payload: `{ "ids": ["p1", "p2", ...] }`)
**POST** `/api/store/:storeId/catalog/categories/reorder` (Payload: `{ "ids": ["c1", "c2", ...] }`)
---
## 🛵 Fluxo de Entrega Pedi Foods (2-Step OTP)
| Campo | Tipo | Descrição |
|---|---|---|
| `otp` | String | Legacy OTP (4 digits). |
| `confirmOtp` | String | **Novo:** Código de 8 dígitos para o motoboy. (Use este para o fluxo de 2 etapas) |
| `deliveryEstimate` | String | Tempo estimado de entrega (ex: "30-45 min"). |
| `pediFoodsFee` | Number | Taxa de serviço Pedi Foods calculada. |
## 8. Entrega e Confirmação (Motoboy Flow)
Novos endpoints públicos para confirmação de entrega em duas etapas.
### 8.1. Página de Confirmação
`GET /delivery/confirm/:orderId`
Renderiza a página para o motoboy iniciar o processo.
### 8.2. Validar Motoboy (Passo 1)
`POST /delivery/confirm/:orderId/validate-motoboy`
Valida o código de 8 dígitos (`confirmOtp`).
**Payload:**
```json
{ "otp": "12345678" }
```
### 8.3. Validar Cliente (Passo 2)
`POST /delivery/confirm/:orderId/validate-customer`
Valida o código de 4 dígitos (`customerOtp`) e finaliza o pedido (`COMPLETED`).
**Payload:**
```json
{ "otp": "1234" }
```
Para garantir a segurança da entrega, o sistema utiliza um fluxo de confirmação em duas etapas via página pública:
1. **URL de Confirmação**: `https://atomenta.loverde.com.br/delivery/confirm/:orderRealId`
2. **Passo 1 (Motoboy)**: O entregador insere o `confirmOtp` (8 dígitos) visível no painel da loja.
3. **Passo 2 (Cliente)**: Após validar o código do motoboy, o sistema solicita o `customerOtp` (4 dígitos) que o cliente possui em seu app/notificação.
4. **Finalização**: A validação bem-sucedida do segundo código altera o status do pedido para `COMPLETED` automaticamente.
Endpoints utilizados pela página pública (CORS habilitado para domínios parceiros):
- `POST /delivery/confirm/:orderId/validate-motoboy` { "otp": "String(8)" }
- `POST /delivery/confirm/:orderId/validate-customer` { "otp": "String(4)" }
---
## 🛒 Checkout e Criação de Pedido
### 10. Criar Novo Pedido (Public)
**POST** `/api/public/orders`
Este endpoint é utilizado pelo front-end de checkout (Pedi Foods) para criar o pedido no sistema da loja.
**Payload:**
```json
{
"storeId": "store_123456",
"userId": "user_guid_or_guest",
"clientName": "João Silva",
"clientPhone": "11999999999",
"deliveryType": "DELIVERY", // ou "PICKUP"
"address": {
"street": "Rua das Flores",
"number": "123",
"neighborhood": "Centro",
"city": "São Paulo",
"state": "SP",
"zip": "01000-000",
"complement": "Apt 10",
"lat": -23.55,
"lng": -46.63
},
"paymentMethod": "PIX", // "CREDIT_CARD", "DEBIT_CARD", "CASH"
"itemsTotal": 50.00,
"deliveryFee": 5.00,
"serviceFee": 0.00,
"discount": 0.00,
"total": 55.00,
"items": [
{
"id": "prod_1",
"name": "Pizza",
"price": 50.00,
"qty": 1,
"image": "..."
}
]
}
```
**Resposta Sucesso:**
```json
{
"error": false,
"result": {
"id": "order_guid",
"shortId": "1234",
"status": "PENDING",
"paymentStatus": "PENDING",
"paymentPayload": "..." // Se PIX (Big String Base64 ou Copia e Cola)
}
}
```

86
LICENSE.md Normal file
View File

@@ -0,0 +1,86 @@
![](loverde_company_logo_full.png)
Copyright (C) Loverde Company - All Rights Reserved
----------
# ⚠️ INFORMAÇÕES CONFIDENCIAIS E PROPRIEDADE INTELECTUAL ⚠️
## 🚨 AVISO LEGAL IMPORTANTE
**ATENÇÃO: Este repositório contém informações CONFIDENCIAIS e PROPRIETÁRIAS.**
### ⛔ PROIBIÇÕES ABSOLUTAS
- **NÃO** compartilhe este código com terceiros
- **NÃO** faça fork, clone ou cópia sem autorização expressa
- **NÃO** utilize qualquer parte deste código em outros projetos
- **NÃO** distribua, venda ou comercialize este software
- **NÃO** reverse engineer ou descompile o código
- **NÃO** acesse sem permissão explícita do proprietário
### 🔒 DIREITOS DE PROPRIEDADE INTELECTUAL
Este software é propriedade exclusiva de **Daniel Arantes Loverde** e está protegido por:
- **Lei de Propriedade Intelectual (Lei 9.610/98)**
- **Lei de Software (Lei 9.609/98)**
- **Lei Geral de Proteção de Dados (LGPD - Lei 13.709/18)**
- **Direitos Autorais e Patentes**
### ⚖️ CONSEQUÊNCIAS LEGAIS
O acesso não autorizado, uso indevido, cópia ou distribuição deste código constitui:
- **CRIME** de violação de propriedade intelectual
- **CRIME** de roubo de propriedade intelectual
- **CRIME** de violação de direitos autorais
- **CRIME** de acesso não autorizado a sistema informatizado
**Penas previstas: 2 a 5 anos de reclusão + multa**
### 🛡️ MEDIDAS DE SEGURANÇA
- **Monitoramento**: Todo acesso é registrado e monitorado
- **Rastreamento**: IPs e atividades são rastreados
- **Logs**: Sistema completo de auditoria
- **Criptografia**: Dados protegidos com AES-256
- **Autenticação**: Acesso restrito com JWT + OTP
### 📧 CONTATO AUTORIZADO
**ÚNICO CONTATO VÁLIDO:**
- **Email**: daniel@loverde.com.br
- **Autorização**: Apenas via email oficial
### 🔐 CLASSIFICAÇÃO
**NÍVEL DE CONFIDENCIALIDADE: MÁXIMO**
- **Restrito**: Apenas para o proprietário
- **Não Divulgar**: Informações proprietárias
- **Não Compartilhar**: Código fonte protegido
---
## ⚠️ DISCLAIMER FINAL
**Ao acessar este repositório, você concorda que:**
1. **Reconhece** que este é um software proprietário
2. **Compromete-se** a não violar os direitos de propriedade intelectual
3. **Aceita** as consequências legais por qualquer violação
4. **Entende** que o acesso é monitorado e rastreado
5. **Concorda** em respeitar todas as leis aplicáveis
---
*Este documento é parte integrante do software e deve ser mantido confidencial.*
---
This source code is protected under international copyright law. All rights
reserved and protected by the copyright holders.
This file is confidential and only available to authorized individuals with the
permission of the copyright holders. If you encounter this file and do not have
permission, please contact the copyright holders and delete this file.
Autor: Daniel Arantes Loverde

View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>NotificationService</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.usernotifications.service</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).NotificationService</string>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,60 @@
import UserNotifications
/// Rich (image) push see docs/api/push-notifications-integration-guide.md §4.3.
/// Only fires when Atomenta's push payload sets `"mutable-content": 1`, which
/// it does by default on any push carrying an image (real wire key is
/// `"image"`, not the guide's `"imageUrl"` verified against
/// `PushNotificationService.dispatchApns`/`dispatchApnsMany` in Atomenta).
/// `@unchecked Sendable`: the extension process only ever runs one
/// `didReceive`/`serviceExtensionTimeWillExpire` cycle at a time there's no
/// real concurrent access to `contentHandler`/`bestAttemptContent` to guard
/// against, just a background download completion handing back to this
/// instance.
final class NotificationService: UNNotificationServiceExtension, @unchecked Sendable {
private var contentHandler: ((UNNotificationContent) -> Void)?
private var bestAttemptContent: UNMutableNotificationContent?
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
self.contentHandler = contentHandler
let mutableContent = (request.content.mutableCopy() as? UNMutableNotificationContent) ?? UNMutableNotificationContent()
bestAttemptContent = mutableContent
guard let imageURLString = request.content.userInfo["image"] as? String,
let imageURL = URL(string: imageURLString) else {
deliver()
return
}
URLSession.shared.downloadTask(with: imageURL) { [weak self] location, _, _ in
if let location, let attachment = Self.attachment(fromDownloadedFile: location) {
self?.bestAttemptContent?.attachments = [attachment]
}
self?.deliver()
}.resume()
}
/// The system calls this if `didReceive` doesn't finish within its time
/// budget must still deliver the best content built so far, since
/// `contentHandler` is contractually required to run exactly once.
override func serviceExtensionTimeWillExpire() {
deliver()
}
private func deliver() {
guard let bestAttemptContent else { return }
contentHandler?(bestAttemptContent)
contentHandler = nil
}
private static func attachment(fromDownloadedFile location: URL) -> UNNotificationAttachment? {
let tmpURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension("jpg")
do {
try FileManager.default.moveItem(at: location, to: tmpURL)
return try UNNotificationAttachment(identifier: "image", url: tmpURL)
} catch {
return nil
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,6 @@
<Workspace
version = "1.0">
<FileRef
location = "group:Darwin/PediFoods.xcodeproj">
location = "self:">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,143 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1430"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
runPostActionsOnFailure = "NO">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "B3B5CFF77A8ED019D1299ABB"
BuildableName = "PediFoods.app"
BlueprintName = "PediFoods"
ReferencedContainer = "container:PediFoods.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "NO"
buildForProfiling = "NO"
buildForArchiving = "NO"
buildForAnalyzing = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "3027BAE353B414EC70E2A2AC"
BuildableName = "PediFoodsTests.xctest"
BlueprintName = "PediFoodsTests"
ReferencedContainer = "container:PediFoods.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "NO"
buildForProfiling = "NO"
buildForArchiving = "NO"
buildForAnalyzing = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "66486D25E3A8A75EA8FACE44"
BuildableName = "PediFoodsUITests.xctest"
BlueprintName = "PediFoodsUITests"
ReferencedContainer = "container:PediFoods.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
onlyGenerateCoverageForSpecifiedTargets = "NO">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "B3B5CFF77A8ED019D1299ABB"
BuildableName = "PediFoods.app"
BlueprintName = "PediFoods"
ReferencedContainer = "container:PediFoods.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "3027BAE353B414EC70E2A2AC"
BuildableName = "PediFoodsTests.xctest"
BlueprintName = "PediFoodsTests"
ReferencedContainer = "container:PediFoods.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "66486D25E3A8A75EA8FACE44"
BuildableName = "PediFoodsUITests.xctest"
BlueprintName = "PediFoodsUITests"
ReferencedContainer = "container:PediFoods.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
<CommandLineArguments>
</CommandLineArguments>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "B3B5CFF77A8ED019D1299ABB"
BuildableName = "PediFoods.app"
BlueprintName = "PediFoods"
ReferencedContainer = "container:PediFoods.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "B3B5CFF77A8ED019D1299ABB"
BuildableName = "PediFoods.app"
BlueprintName = "PediFoods"
ReferencedContainer = "container:PediFoods.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -0,0 +1,38 @@
{
"images" : [
{
"filename" : "icon_pattern_1024x1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "icon_pattern_dark_1024x1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"filename" : "icon_pattern_1024x1024 1.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

View File

@@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.969",
"green" : "0.961",
"red" : "0.953"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.102",
"green" : "0.102",
"red" : "0.102"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 835 B

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

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: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 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-pizza.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 667 KiB

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" : "sodexo_logo.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@@ -0,0 +1,13 @@
import SwiftUI
struct AppBackButtonIcon: View {
var body: some View {
Image(systemName: "chevron.left")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(AppColors.textPrimary)
.frame(width: 32, height: 32)
.background(AppColors.surface)
.clipShape(Circle())
.shadow(color: .black.opacity(0.06), radius: 8, y: 2)
}
}

View File

@@ -0,0 +1,70 @@
import SwiftUI
struct PrimaryButton: View {
let title: String
var fullWidth: Bool = true
var image: Image? = nil
let action: @MainActor @Sendable () -> Void
var body: some View {
Button(action: { action() }) {
HStack {
Text(title)
.font(AppTypography.button)
.tracking(AppTypography.buttonLetterSpacing)
if let image {
image
}
}
.foregroundStyle(AppColors.textInverse)
.frame(maxWidth: fullWidth ? .infinity : nil)
.frame(height: 56)
.background(AppColors.primary)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
.buttonStyle(.plain)
}
}
struct PrimaryButtonLabel: View {
let title: String
var fullWidth: Bool = true
var body: some View {
Text(title)
.font(AppTypography.button)
.tracking(AppTypography.buttonLetterSpacing)
.foregroundStyle(AppColors.textInverse)
.frame(maxWidth: fullWidth ? .infinity : nil)
.frame(height: 56)
.background(AppColors.primary)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
.labelStyle(.titleOnly)
}
}
struct SecondaryButton: View {
let title: String
var fullWidth: Bool = true
var image: Image? = nil
let action: @MainActor @Sendable () -> Void
var body: some View {
Button(action: { action() }) {
HStack {
Text(title)
.font(AppTypography.button)
.tracking(AppTypography.buttonLetterSpacing)
if let image {
image
}
}
.foregroundStyle(AppColors.textPrimary)
.frame(maxWidth: fullWidth ? .infinity : nil)
.frame(height: 56)
.background(AppColors.secondary)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
.buttonStyle(.plain)
}
}

View File

@@ -0,0 +1,146 @@
import Foundation
import SwiftUI
import UIKit
enum ImageFitMode {
/// Scale to cover the whole box, cropping whichever axis overflows.
/// Default matches historical behavior for every image in the app.
case fill
/// Scale so height always matches the box exactly; width follows the
/// source's aspect ratio and gets cropped/gapped on the sides. Opt-in
/// only used by the Store Detail cover header.
case heightFit
}
struct CachedRemoteImage<Placeholder: View>: View {
let imageURL: String?
let ttl: TimeInterval
let fitMode: ImageFitMode
let placeholder: Placeholder
@StateObject var loader = CachedRemoteImageLoader()
init(
imageURL: String?,
ttl: TimeInterval = AppCacheTTL.twoHours,
fitMode: ImageFitMode = .fill,
@ViewBuilder placeholder: () -> Placeholder
) {
self.imageURL = imageURL
self.ttl = ttl
self.fitMode = fitMode
self.placeholder = placeholder()
}
var body: some View {
GeometryReader { geometry in
Group {
if let uiImage = loader.uiImage {
rendered(image: Image(uiImage: uiImage), pixelSize: uiImage.size, in: geometry.size)
} else {
placeholder
}
}
.frame(width: geometry.size.width, height: geometry.size.height)
.clipped()
}
.onAppear {
loader.load(imageURL: imageURL, ttl: ttl)
}
.onChange(of: imageURL) { _, newValue in
loader.load(imageURL: newValue, ttl: ttl)
}
}
@ViewBuilder
private func rendered(image: Image, pixelSize: CGSize, in containerSize: CGSize) -> some View {
switch fitMode {
case .fill:
image
.resizable()
.scaledToFill()
case .heightFit:
let aspect = pixelSize.height > 0 ? pixelSize.width / pixelSize.height : 1
let renderWidth = containerSize.height * aspect
image
.resizable()
.frame(width: renderWidth, height: containerSize.height)
.frame(width: containerSize.width, height: containerSize.height)
}
}
}
@MainActor
final class CachedRemoteImageLoader: ObservableObject {
@Published var uiImage: UIImage?
private var currentKey: String?
private var task: Task<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()
uiImage = nil
guard let normalized, normalized.isEmpty == false else { return }
let dataCacheKey = Self.dataURLCacheKey(normalized)
if let cachedDataImage: PlatformImage = AppContentCache.shared.value(for: dataCacheKey, as: PlatformImage.self) {
uiImage = cachedDataImage
return
}
if let image = Self.imageFromDataURL(normalized) {
AppContentCache.shared.set(image, for: dataCacheKey, ttl: ttl)
uiImage = image
return
}
guard let url = URL(string: normalized) else { return }
task = Task { [weak self] in
let image = await AppImageCache.shared.image(for: url, ttl: ttl)
guard Task.isCancelled == false else { return }
await MainActor.run {
self?.uiImage = image
}
}
}
private static func normalizeImageSource(_ value: String?) -> String? {
ImageSourceResolver.resolve(value)
}
private static func dataURLCacheKey(_ source: String) -> String {
let head = String(source.prefix(48))
let tail = String(source.suffix(48))
return "data-image:\(source.count):\(head):\(tail)"
}
private static func imageFromDataURL(_ source: String) -> PlatformImage? {
let lower = source.lowercased()
guard lower.hasPrefix("data:image"), let commaIndex = source.firstIndex(of: ",") else { return nil }
let header = String(source[..<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 }
return UIImage(data: data)
}
}

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.primary)
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.primary.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.primary)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(isActive ? AppColors.primary : AppColors.backgroundLight)
.clipShape(Capsule())
.overlay(
Capsule()
.stroke(AppColors.primary.opacity(0.15), lineWidth: isActive ? 0 : 1)
)
}
}

View File

@@ -0,0 +1,38 @@
import SwiftUI
struct SnackbarOverlay: View {
@ObservedObject var center: SnackbarCenter
var body: some View {
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)
.appContentShape(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)
}
}

View File

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

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

516
PediFoods/ContentView.swift Normal file
View File

@@ -0,0 +1,516 @@
import Foundation
import SwiftUI
enum DeepLinkRouteEffect: Equatable {
case navigateToOrder(OrderRouteContext, tab: MainTab)
case none
}
struct ContentView: View {
@State var root: RootFlow = .main
@State var selectedTab: MainTab = .home
private let tokenStore: TokenStore = DefaultTokenStore()
@State var appState = AppState()
@State var isBootstrappingSession = false
@State var hasPerformedInitialLaunchBootstrap = false
@State var showLaunchSplash = true
@State var shouldPulseLaunchSplash = true
@State private var sessionExpiredObserver: NSObjectProtocol?
@State var cartResetObserver: Any?
@State var appResumeObserver: Any?
@State var pushDeepLinkObserver: Any?
@StateObject var snackbarCenter = SnackbarCenter.shared
var body: some View {
ZStack(alignment: .top) {
Group {
switch root {
case .auth:
AuthFlowView(
root: $root,
selectedTab: $selectedTab,
tokenStore: tokenStore,
appState: $appState
)
.transition(.move(edge: .trailing))
case .main:
if isBootstrappingSession {
sessionBootstrapLoadingView
} else {
MainTabView(selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState, enterAuth: enterAuthFlow)
.transition(.move(edge: .leading))
}
}
}
SnackbarOverlay(center: snackbarCenter)
if showLaunchSplash {
LaunchSplashView(shouldPulse: shouldPulseLaunchSplash)
.transition(.opacity)
}
}
.sheet(item: $appState.activeModal, onDismiss: { appState.activeModal = nil }) { modal in
switch modal {
case .addressPicker:
AddressPickerModalView(appState: $appState, selectedTab: $selectedTab)
case .filters:
FiltersModalView(appState: $appState)
}
}
.onChange(of: appState.address.display) { _, _ in
dismissAddressPickerIfAddressExists()
}
.onChange(of: appState.address.selectedId) { _, _ in
dismissAddressPickerIfAddressExists()
}
.onChange(of: appState.address.latitude) { _, _ in
dismissAddressPickerIfAddressExists()
}
.onChange(of: appState.address.longitude) { _, _ in
dismissAddressPickerIfAddressExists()
}
.task {
await performInitialLaunchBootstrap()
}
.onChange(of: root) { _, newValue in
if newValue == .main {
Task {
await bootstrapSessionStateIfNeeded()
}
}
}
.onChange(of: appState.profile.id) { _, _ in
Task { @MainActor in
await refreshFeatureFlags(forceRefresh: true)
}
}
.onChange(of: appState.cart.storeId) { _, _ in
Task { @MainActor in
await refreshFeatureFlags(forceRefresh: true)
}
}
.onAppear {
attachCartResetObserverIfNeeded()
attachAppResumeObserverIfNeeded()
attachSessionExpiredObserverIfNeeded()
attachPushDeepLinkObserverIfNeeded()
}
.onDisappear {
detachCartResetObserver()
detachAppResumeObserver()
detachSessionExpiredObserver()
detachPushDeepLinkObserver()
}
}
private var sessionBootstrapLoadingView: some View {
VStack(spacing: 12) {
ProgressView()
Text("Carregando sua sessão...")
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(AppColors.backgroundLight)
}
@MainActor
private func performInitialLaunchBootstrap() async {
guard hasPerformedInitialLaunchBootstrap == false else { return }
hasPerformedInitialLaunchBootstrap = true
let start = Date()
await bootstrapSessionStateIfNeeded()
// Keep the in-app splash visible long enough to avoid abrupt transition
// between native launch screen and app content.
let elapsed = Date().timeIntervalSince(start)
let minimumSplashDuration: TimeInterval = 1.0
if elapsed < minimumSplashDuration {
let remaining = minimumSplashDuration - elapsed
let nanoseconds = UInt64(remaining * 1_000_000_000)
try? await Task.sleep(nanoseconds: nanoseconds)
}
if root == .auth {
shouldPulseLaunchSplash = false
try? await Task.sleep(nanoseconds: 180_000_000)
withAnimation(.easeInOut(duration: 0.34)) {
showLaunchSplash = false
}
return
}
shouldPulseLaunchSplash = false
withAnimation(.easeOut(duration: 0.28)) {
showLaunchSplash = false
}
}
@MainActor
private func bootstrapSessionStateIfNeeded() async {
guard root == .main else { return }
guard isBootstrappingSession == false else { return }
isBootstrappingSession = true
if let jwt = tokenStore.jwt {
appState.session.jwt = jwt
appState.session.isAuthenticated = true
}
if let cachedAddress = SessionStateStore.loadAddress() {
appState.address = cachedAddress
dismissAddressPickerIfAddressExists()
}
if let cachedCart = SessionStateStore.loadCart() {
appState.cart = cachedCart
}
// Anonymous session: the account address cache above doesn't apply
// restore the "ENTREGAR EM" label from the state/city picked via the
// public locator (GuestLocationStore persists this in Keychain across
// launches on its own; this just resyncs the display label with it).
if appState.session.isAuthenticated == false,
let guestState = GuestLocationStore.shared.selectedState,
let guestCity = GuestLocationStore.shared.selectedCity {
appState.address.display = "\(guestCity), \(guestState)"
dismissAddressPickerIfAddressExists()
}
// Always refresh profile when authenticated.
// This keeps profile/address/cart scope consistent after relogin
// and avoids stale local state during checkout payload generation.
// Anonymous browsing has no session to refresh, so skip the call
// entirely rather than let it 401 and force-logout a guest.
if appState.session.isAuthenticated {
do {
let response = try await ApiService().profile()
if response.error == false, let customer = response.result {
hydrateAppState(with: customer)
}
} catch let error as ApiServiceError {
if case .sessionExpired = error {
forceLogoutToStart()
}
} catch {
// Keep local state when backend refresh fails transiently
}
}
await refreshFeatureFlags(forceRefresh: false)
isBootstrappingSession = false
}
private func attachCartResetObserverIfNeeded() {
guard cartResetObserver == nil else { return }
cartResetObserver = NotificationCenter.default.addObserver(
forName: .cartDidReset,
object: nil,
queue: nil
) { _ in
Task { @MainActor in
appState.cart = CartState()
}
}
}
private func detachCartResetObserver() {
guard let cartResetObserver else { return }
NotificationCenter.default.removeObserver(cartResetObserver)
self.cartResetObserver = nil
}
private func attachAppResumeObserverIfNeeded() {
guard appResumeObserver == nil else { return }
appResumeObserver = NotificationCenter.default.addObserver(
forName: .appDidResume,
object: nil,
queue: nil
) { _ in
Task { @MainActor in
guard root == .main else { return }
await refreshFeatureFlags(forceRefresh: true)
}
}
}
private func detachAppResumeObserver() {
guard let appResumeObserver else { return }
NotificationCenter.default.removeObserver(appResumeObserver)
self.appResumeObserver = nil
}
/// §6 of the push notifications guide, generalized: any tapped push that
/// resolves to a `DeepLinkDestination` (reported by
/// `PushNotificationCoordinator`) lands here. Adding a future promo/coupon
/// screen means adding one case to `route(to:)` below this observer,
/// the notification name, and the AppState plumbing for it stay put.
private func attachPushDeepLinkObserverIfNeeded() {
guard pushDeepLinkObserver == nil else { return }
pushDeepLinkObserver = NotificationCenter.default.addObserver(
forName: .pushDeepLinkReceived,
object: nil,
queue: nil
) { notification in
guard let destination = notification.userInfo?["destination"] as? DeepLinkDestination else { return }
Task { @MainActor in
route(to: destination)
}
}
}
private func detachPushDeepLinkObserver() {
guard let pushDeepLinkObserver else { return }
NotificationCenter.default.removeObserver(pushDeepLinkObserver)
self.pushDeepLinkObserver = nil
}
/// Pure mapping from a deep-link destination to what `route(to:)` should
/// do - split out so it's testable without a live `ContentView`
/// instance (this reads no `@State`, `route(to:)` is what applies the
/// result to `appState`/`selectedTab`).
func routeEffect(for destination: DeepLinkDestination) -> DeepLinkRouteEffect {
switch destination {
case .orderTracking(let orderId, let shortId):
return .navigateToOrder(
OrderRouteContext(
orderId: orderId,
shortId: shortId,
paymentMethod: nil,
total: nil,
intent: .auto
),
tab: .profile
)
case .screen(let name, let params):
// Promo/coupon screens (participating stores/products, §6's
// `targetScreen` convention) land here once they exist see
// decisions/2026-08-04-push-deeplink-routing-contract.md.
logger.debug("Unhandled deep-link screen: \(name, privacy: .public) params=\(params, privacy: .public)")
return .none
}
}
@MainActor
private func route(to destination: DeepLinkDestination) {
guard root == .main else { return }
switch routeEffect(for: destination) {
case .navigateToOrder(let context, let tab):
appState.pendingOrderDeepLink = context
selectedTab = tab
case .none:
break
}
}
@MainActor
private func hydrateAppState(with customer: CustomerProfile) {
appState.profile.id = customer.id
appState.profile.name = customer.name
appState.profile.email = customer.email
appState.profile.phone = customer.phoneNumber ?? ""
appState.profile.profilePicture = customer.profilePicture ?? ""
appState.favorites.storeIds = Set(customer.favorites ?? [])
SessionStateStore.setActiveUserKey(
SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email)
)
if let cachedCart = SessionStateStore.loadCart() {
appState.cart = cachedCart
} else {
appState.cart = CartState()
}
let addresses = customer.addressBook ?? []
guard addresses.isEmpty == false else {
appState.address = AddressState()
SessionStateStore.clearAddress()
return
}
let preferredAddress = resolvePreferredAddress(from: addresses, current: appState.address)
if let preferredAddress {
applyAddress(preferredAddress)
SessionStateStore.saveAddress(appState.address)
}
}
private func resolvePreferredAddress(from addresses: [CustomerAddress], current: AddressState) -> CustomerAddress? {
guard addresses.isEmpty == false else { return nil }
if let selectedId = current.selectedId?.trimmingCharacters(in: .whitespacesAndNewlines),
selectedId.isEmpty == false,
let byId = addresses.first(where: { ($0.id ?? "").trimmingCharacters(in: .whitespacesAndNewlines) == selectedId }) {
return byId
}
let normalizedDisplay = current.display
.trimmingCharacters(in: .whitespacesAndNewlines)
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
.lowercased()
if normalizedDisplay.isEmpty == false && normalizedDisplay != "defina seu endereco",
let byLabel = addresses.first(where: {
(($0.label ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
.lowercased()) == normalizedDisplay
}) {
return byLabel
}
if let lat = current.latitude, let lng = current.longitude,
let byCoordinate = addresses.first(where: { address in
guard let addrLat = address.latLong?.first,
let addrLng = address.latLong?.dropFirst().first else { return false }
return abs(addrLat - lat) < 0.00001 && abs(addrLng - lng) < 0.00001
}) {
return byCoordinate
}
return addresses.first
}
@MainActor
private func applyAddress(_ address: CustomerAddress) {
appState.address.selectedId = address.id
let cleanLabel = (address.label ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
appState.address.display = cleanLabel.isEmpty ? "Defina seu endereco" : cleanLabel
if let lat = address.latLong?.first, let lng = address.latLong?.dropFirst().first {
appState.address.latitude = lat
appState.address.longitude = lng
} else {
appState.address.latitude = nil
appState.address.longitude = nil
}
}
@MainActor
private func forceLogoutToStart() {
tokenStore.clear()
SessionStateStore.clearActiveUser()
SessionStateStore.clearTrackedOrders()
SessionStateStore.clearPendingCartOrder()
AppContentCache.shared.invalidate()
AppImageCache.shared.invalidateAll()
isBootstrappingSession = false
appState = AppState()
selectedTab = .home
enterAuthFlow()
}
/// The single funnel for entering the auth flow. `LoginView` is a
/// static screen with its own `LCENavigationView` back button (sets
/// `root = .main`), so no animation priming is needed here - see
/// decisions/2026-08-27-payment-auth-lockout-clean-intro-screen.md.
@MainActor
private func enterAuthFlow() {
withAnimation(.easeInOut(duration: 0.3)) {
root = .auth
}
}
@MainActor
private func refreshFeatureFlags(forceRefresh: Bool) async {
guard root == .main else { return }
let subjectType: String
let subjectId: String
if let profileId = appState.profile.id?.trimmingCharacters(in: .whitespacesAndNewlines), profileId.isEmpty == false {
subjectType = "customer"
subjectId = profileId
} else {
subjectType = "anonymous"
subjectId = "anonymous-device"
}
var attrs: [String: String] = [:]
let addressLabel = appState.address.display.trimmingCharacters(in: .whitespacesAndNewlines)
if addressLabel.isEmpty == false, addressLabel.lowercased() != "defina seu endereco" {
attrs["addressLabel"] = addressLabel
}
let context = FeatureControlEvaluationContext(
subjectType: subjectType,
subjectId: subjectId,
storeId: appState.cart.storeId,
attributes: attrs
)
let snapshot = await FeatureControlService.shared.evaluate(
context: context,
jwt: appState.session.jwt,
forceRefresh: forceRefresh
)
appState.featureFlags = snapshot
await FeatureControlService.shared.sendExposureEvents(
snapshot: snapshot,
context: context,
jwt: appState.session.jwt
)
}
private func hasConfiguredAddress() -> Bool {
if appState.address.selectedId != nil {
return true
}
let normalized = appState.address.display
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
return normalized.isEmpty == false && normalized != "defina seu endereco"
}
@MainActor
private func dismissAddressPickerIfAddressExists() {
guard appState.activeModal == .addressPicker else { return }
if hasConfiguredAddress() {
appState.activeModal = nil
}
}
private func attachSessionExpiredObserverIfNeeded() {
guard sessionExpiredObserver == nil else { return }
sessionExpiredObserver = NotificationCenter.default.addObserver(
forName: .sessionExpired,
object: nil,
queue: .main
) { _ in
Task { @MainActor in
forceLogoutToStart()
}
}
}
private func detachSessionExpiredObserver() {
guard let observer = sessionExpiredObserver else { return }
NotificationCenter.default.removeObserver(observer)
sessionExpiredObserver = nil
}
}
struct AddressPickerModalView: View {
@Binding var appState: AppState
@Binding var selectedTab: MainTab
var body: some View {
NavigationStack {
if appState.session.isAuthenticated {
AddressesView(
message: appState.address.onboardingMessage,
appState: $appState,
selectionMode: true
)
.onAppear {
appState.address.onboardingMessage = nil
}
} else {
PublicLocationPickerView(appState: $appState)
}
}
}
}

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,49 @@
import Foundation
/// Single decode target for anything that can deep-link into the app push
/// notification taps today (docs/api/push-notifications-integration-guide.md
/// §6), in-app promo/discount banner taps tomorrow (same shape: a string-keyed
/// dictionary naming a screen + its params). Adding a new destination means
/// adding one case here + one branch in `ContentView.route(to:)` never a new
/// `NotificationCenter` name, `AppState` field, or observer pair.
enum DeepLinkDestination: Equatable {
/// `type: "order_status"` the only push type Atomenta fixes today
/// (§6), so it gets its own case instead of routing through `targetScreen`.
case orderTracking(orderId: String, shortId: String?)
/// Everything else, keyed by `targetScreen` our own convention (§6
/// leaves this to us). Once a real screen exists for a given name (e.g.
/// future promo/coupon "list of participating stores/products"), give it
/// its own case; until then this carries the raw params so nothing is
/// silently dropped.
case screen(name: String, params: [String: String])
}
/// Resolves the *navigation* half of a tapped push's payload. Pure and
/// side-effect-free on purpose: easy to unit test, and reusable as-is for
/// in-app banner taps once those exist. Campaign-open reporting (§6a) is a
/// separate side effect, not a navigation target, and is not decided here
/// see `PushNotificationCoordinator.handleTap`.
struct PushDeepLinkParser {
static func parse(_ userInfo: [AnyHashable: Any]) -> DeepLinkDestination? {
if let type = userInfo["type"] as? String, type == "order_status" {
// Atomenta's real order-status push (PushNotificationService.
// notifyOrderStatusUpdate) sends only `shortId`, never `orderId`
// `GET /api/public/orders/:orderId` accepts either as the lookup
// key server-side, so `shortId` doubles as the id here too.
let shortId = userInfo["shortId"] as? String
guard let orderId = (userInfo["orderId"] as? String) ?? shortId else { return nil }
return .orderTracking(orderId: orderId, shortId: shortId)
}
guard let targetScreen = userInfo["targetScreen"] as? String else { return nil }
var params: [String: String] = [:]
for (key, value) in userInfo {
guard let key = key as? String, key != "type", key != "targetScreen" else { continue }
if let stringValue = value as? String {
params[key] = stringValue
}
}
return .screen(name: targetScreen, params: params)
}
}

View File

@@ -0,0 +1,125 @@
import SwiftUI
import UIKit
// MARK: - Colors
struct AppColors {
private init() {}
// Brandbook 2026 (Pedi Foods)
// Primary now follows the "FOODS" dark green tone from the logo.
static let primary = Color(.sRGB, red: 52/255.0, green: 93/255.0, blue: 84/255.0, opacity: 1.0)
// Secondary keeps the vivid lime from the symbol/logo body.
static let secondary = Color(.sRGB, red: 213/255.0, green: 216/255.0, blue: 65/255.0, opacity: 1.0)
static let tertiary = Color(.sRGB, red: 167/255.0, green: 191/255.0, blue: 66/255.0, opacity: 1.0)
static let brandDark = Color(.sRGB, red: 31/255.0, green: 45/255.0, blue: 64/255.0, opacity: 1.0)
static let brandSoft = Color(.sRGB, red: 242/255.0, green: 245/255.0, blue: 227/255.0, opacity: 1.0)
static let backgroundLight = Color(.sRGB, red: 243/255.0, green: 245/255.0, blue: 247/255.0, opacity: 1.0)
static let backgroundDark = Color(.sRGB, red: 31/255.0, green: 45/255.0, blue: 64/255.0, opacity: 1.0)
static let surface = Color.white
static let textPrimary = Color.black
static let textInverse = Color.white
static let textMuted = Color(.sRGB, red: 102/255.0, green: 112/255.0, blue: 133/255.0, opacity: 1.0)
}
struct AppDarkColors {
private init() {}
static let background = Color(.sRGB, red: 31/255.0, green: 45/255.0, blue: 64/255.0, opacity: 1.0)
static let surface = Color.white.opacity(0.08)
static let textPrimary = Color.white
static let textSecondary = Color(.sRGB, red: 208/255.0, green: 213/255.0, blue: 221/255.0, opacity: 1.0)
static let primary = AppColors.primary
static let secondary = AppColors.secondary
static let tertiary = AppColors.tertiary
}
// MARK: - Typography
struct AppTypography {
private init() {}
// Brandbook typography: Nexa (fallback: system font)
static let fontFamily = "Nexa-Regular"
static let heading1 = resolvedFont(size: 28, fallbackWeight: .bold)
static let heading25 = resolvedFont(size: 25, fallbackWeight: .bold)
static let heading2 = resolvedFont(size: 20, fallbackWeight: .semibold)
static let heading3 = resolvedFont(size: 16, fallbackWeight: .semibold)
static let body = resolvedFont(size: 16, fallbackWeight: .regular)
static let button = resolvedFont(size: 14, fallbackWeight: .semibold)
static let caption = resolvedFont(size: 10, fallbackWeight: .regular)
static let overline = resolvedFont(size: 11, fallbackWeight: .regular)
static let bodyLineHeight: CGFloat = 1.6
static let buttonLetterSpacing: CGFloat = 0.08
static let captionLetterSpacing: CGFloat = 0.12
private static func resolvedFont(size: CGFloat, fallbackWeight: Font.Weight) -> Font {
if isBrandFontAvailable {
return Font.custom(fontFamily, size: size)
}
return .system(size: size, weight: fallbackWeight, design: .default)
}
private static var isBrandFontAvailable: Bool {
UIFont(name: fontFamily, size: 16) != nil
}
}
// MARK: - Layout
struct AppLayout {
private init() {}
static let radiusMD: CGFloat = 12
static let radiusLG: CGFloat = 16
static let radiusXL: CGFloat = 24
static let radiusFull: CGFloat = 9999
static let spacing: [CGFloat] = [4, 8, 12, 16, 24, 32]
}
// MARK: - Shadow
struct ShadowSpec {
let color: Color
let radius: CGFloat
let y: CGFloat
}
struct AppShadow {
private init() {}
static let soft = ShadowSpec(color: Color.black.opacity(0.08), radius: 20, y: 6)
static let glow = ShadowSpec(color: AppColors.tertiary.opacity(0.2), radius: 24, y: 8)
}
// MARK: - Helpers
extension Color {
init(hex: String, alpha: Double = 1.0) {
let cleaned = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
var int: UInt64 = 0
if let val = UInt64(cleaned, radix: 16) {
int = val
}
let r, g, b: UInt64
switch cleaned.count {
case 6: // RRGGBB
r = (int >> 16) & 0xFF
g = (int >> 8) & 0xFF
b = int & 0xFF
case 3: // RGB
r = ((int >> 8) & 0xF) * 17
g = ((int >> 4) & 0xF) * 17
b = (int & 0xF) * 17
default:
r = 0; g = 0; b = 0
}
self.init(.sRGB,
red: Double(r) / 255,
green: Double(g) / 255,
blue: Double(b) / 255,
opacity: alpha)
}
}

View File

@@ -1,6 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
</dict>
<dict/>
</plist>

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" name="LaunchBackground"/>
<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,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.devicecheck.app-attest-opt-in</key>
<array>
<string>CDhash</string>
</array>
<key>com.apple.developer.devicecheck.appattest-environment</key>
<string>$(APP_ATTEST_ENVIRONMENT)</string>
<key>com.apple.developer.location.push</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,63 @@
import OSLog
import SwiftUI
let logger: Logger = Logger(subsystem: "com.br.pedifoods.app", category: "PediFoods")
@main
struct PediFoodsApp: App {
@UIApplicationDelegateAdaptor(PediFoodsAppDelegate.self) private var appDelegate
@Environment(\.scenePhase) private var scenePhase
var body: some Scene {
WindowGroup {
ContentView()
}
.onChange(of: scenePhase) { oldPhase, newPhase in
switch newPhase {
case .active:
logger.debug("onResume")
NotificationCenter.default.post(name: .appDidResume, object: nil)
case .inactive:
logger.debug("onPause")
case .background:
logger.debug("onStop")
@unknown default:
logger.debug("unknown scene phase: \(String(describing: newPhase))")
}
}
}
}
final class PediFoodsAppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
logger.debug("onInit")
return true
}
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
logger.debug("onLaunch")
Task { @MainActor in
PushNotificationCoordinator.shared.startObservingDeviceToken()
PushNotificationCoordinator.shared.becomeNotificationCenterDelegate()
await PushNotificationCoordinator.shared.refreshRegistrationIfAuthorized()
await PushNotificationCoordinator.shared.syncCustomerAttributes()
}
return true
}
func applicationWillTerminate(_ application: UIApplication) {
logger.debug("onDestroy")
}
func applicationDidReceiveMemoryWarning(_ application: UIApplication) {
logger.debug("onLowMemory")
}
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
NotificationCenter.default.post(name: NSNotification.Name("didRegisterForRemoteNotificationsWithDeviceToken"), object: application, userInfo: ["deviceToken": deviceToken])
}
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: any Error) {
NotificationCenter.default.post(name: NSNotification.Name("didFailToRegisterForRemoteNotificationsWithError"), object: application, userInfo: ["error": error])
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,83 @@
import Foundation
struct SavedCard: Decodable, Identifiable, Hashable {
let id: String
let nickname: String?
let holderName: String
let last4: String
let brand: String?
let expiryMonth: String
let expiryYear: String
let isDefault: Bool
var displayLabel: String {
if let nickname, nickname.isEmpty == false { return nickname }
let brandLabel = (brand ?? "Cartão").capitalized
return "\(brandLabel) •••• \(last4)"
}
var expiryLabel: String { "\(expiryMonth)/\(expiryYear)" }
}
struct SaveCardCreditCardPayload: Encodable {
let holderName: String
let number: String
let expiryMonth: String
let expiryYear: String
let ccv: String
}
struct SaveCardHolderInfoPayload: Encodable {
let name: String
let email: String
let cpfCnpj: String
let postalCode: String
let addressNumber: String
let phone: String
}
struct SaveCardPayload: Encodable {
let creditCard: SaveCardCreditCardPayload
let creditCardHolderInfo: SaveCardHolderInfoPayload
let nickname: String?
let isDefault: Bool
}
struct UpdateCardPayload: Encodable {
let nickname: String?
let isDefault: Bool?
}
struct SavedCardResult: Decodable {
let id: String
let holderName: String
let last4: String
let brand: String?
let expiryMonth: String
let expiryYear: String
let isDefault: Bool
}
struct CreditCardOrderPayload: Encodable {
let holderName: String
let number: String
let expiryMonth: String
let expiryYear: String
let ccv: String
}
struct ChangePaymentMethodPayload: Encodable {
let paymentMethod: String
let clientCpfCnpj: String?
let creditCard: CreditCardOrderPayload?
let creditCardHolderInfo: SaveCardHolderInfoPayload?
let savedCardId: String?
}
struct ChangePaymentMethodResult: Decodable {
let paymentMethod: String?
let paymentLocation: String?
let paymentId: String?
let paymentPayload: CreateOrderPaymentPayload?
let payment: CreateOrderPaymentInfo?
}

View File

@@ -0,0 +1,498 @@
import Foundation
#if canImport(LCEssentials)
import LCEssentials
#endif
enum NetworkError: Error, LocalizedError {
case invalidURL
case invalidResponse
case httpError(Int, String?)
case unauthorized(String?)
case decodeError(String?)
case rateLimited(Int?)
case cancelled
case timedOut
case transportError(String)
var errorDescription: String? {
switch self {
case .invalidURL: return "URL invalida"
case .invalidResponse: return "Resposta invalida do servidor"
case .httpError(let code, let message):
return message ?? "Erro HTTP (\(code))"
case .unauthorized(let message):
return message ?? "Sessao expirada. Faca login novamente."
case .decodeError(let payload):
if let payload, payload.isEmpty == false {
return "Erro ao interpretar dados: \(payload)"
}
return "Erro ao interpretar dados"
case .rateLimited(let retryAfter):
if let retryAfter {
return "Muitas requisicoes. Tente novamente em \(retryAfter)s."
}
return "Muitas requisicoes. Tente novamente."
case .cancelled:
return "Requisicao cancelada"
case .timedOut:
return "O servidor demorou demais para responder. Tente novamente."
case .transportError(let message):
return "Erro de rede: \(message)"
}
}
}
struct ApiErrorDescriptor {
let code: String?
let message: String?
}
struct ApiRequest: Sendable {
let path: String
let method: String
let module: ApiModule
let requiresAuth: Bool
let queryItems: [URLQueryItem]
let body: Data?
/// Overrides `ApiConfig.baseURL` (Atomenta) for this single request used
/// to reach the PediFoods BFF (`ApiConfig.pediFoodsBFFURL`) instead.
let baseURLOverride: URL?
/// Sent as `Authorization: Bearer <token>` instead of the customer JWT.
/// Used for guest-session-authenticated public locator calls, which must
/// never touch `TokenStore`'s customer session.
let customBearerToken: String?
init(path: String,
method: String = "GET",
module: ApiModule = .none,
requiresAuth: Bool = true,
queryItems: [URLQueryItem] = [],
body: Data? = nil,
baseURLOverride: URL? = nil,
customBearerToken: String? = nil) {
self.path = path
self.method = method
self.module = module
self.requiresAuth = requiresAuth
self.queryItems = queryItems
self.body = body
self.baseURLOverride = baseURLOverride
self.customBearerToken = customBearerToken
}
}
final class ApiClient: @unchecked Sendable {
private let session: URLSession
private let tokenStore: TokenStore
private let maxAttempts = 3
private let baseBackoffNanoseconds: UInt64 = 300_000_000
init(session: URLSession = .shared, tokenStore: TokenStore = DefaultTokenStore()) {
self.session = session
self.tokenStore = tokenStore
}
func send<T: Decodable & Sendable>(_ request: ApiRequest) async throws -> T {
// Hard client-side cutoff independent of whatever timeout logic
// lives inside the underlying transport (LCEssentials or plain
// URLSession). If that transport ever stalls without ever
// resolving no response, no error, nothing the UI must still
// get an answer so it can stop showing "nothing happened."
try await withTimeout(seconds: 20) { [self] in
#if canImport(LCEssentials)
try await sendWithLCEssentials(request)
#else
try await sendWithURLSession(request)
#endif
}
}
private func withTimeout<T: Sendable>(seconds: Double, operation: @escaping @Sendable () async throws -> T) async throws -> T {
try await withThrowingTaskGroup(of: T.self) { group in
group.addTask {
try await operation()
}
group.addTask {
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
throw NetworkError.timedOut
}
guard let result = try await group.next() else {
throw NetworkError.timedOut
}
group.cancelAll()
return result
}
}
}
extension ApiClient {
#if canImport(LCEssentials)
func sendWithLCEssentials<T: Decodable>(_ request: ApiRequest) async throws -> T {
let urlString = try buildURL(path: request.path, query: request.queryItems, baseURL: request.baseURLOverride ?? ApiConfig.baseURL).absoluteString
let method = request.method
let headers = buildHeaders(for: request)
let params = request.body
var attempt = 1
while attempt <= maxAttempts {
do {
let responseString = try await Self.performLCERequest(
url: urlString,
params: params,
method: method,
headers: headers
)
guard let data = responseString.data(using: .utf8) else {
throw NetworkError.decodeError("Resposta nao UTF-8")
}
if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let errorFlag = object["error"] as? Bool, errorFlag {
let message = object["message"] as? String ?? object["msg"] as? String
throw NetworkError.httpError(200, message ?? "Erro no servidor")
}
do {
return try JSONDecoder().decode(T.self, from: data)
} catch {
throw NetworkError.decodeError(sanitizedBody(data))
}
} catch {
let mapped = mapError(error)
guard shouldRetry(mapped), attempt < maxAttempts else {
throw mapped
}
try await Task.sleep(nanoseconds: backoff(for: attempt, error: mapped))
attempt += 1
}
}
throw NetworkError.invalidResponse
}
@MainActor
static func performLCERequest(
url: String,
params: Data?,
method: String,
headers: [String: String]
) async throws -> String {
let httpMethod = toHTTPMethod(method)
return try await API.shared.request(
url: url,
params: params,
method: httpMethod,
headers: headers,
jsonEncoding: true,
debug: true
)
}
static func toHTTPMethod(_ method: String) -> httpMethod {
switch method.uppercased() {
case "POST": return .post
case "PUT": return .put
case "DELETE": return .delete
case "PATCH": return .patch
default: return .get
}
}
func mapError(_ error: Error) -> NetworkError {
if let network = error as? NetworkError {
return network
}
if let decoding = error as? DecodingError {
return .decodeError(String(describing: decoding))
}
let nsError = error as NSError
let apiMessage = serverMessage(from: nsError)
let payload = serverPayload(from: nsError)
if isSessionExpiredPayload(code: payload?.code, message: payload?.message ?? apiMessage) {
return .unauthorized(payload?.message ?? apiMessage)
}
if nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorCancelled {
return .cancelled
}
printError(title: "httpReqError", msg: error.localizedDescription)
switch nsError.code {
case 401, 403:
return .unauthorized(apiMessage)
case 429:
return .rateLimited(nil)
case 400...599:
return .httpError(nsError.code, apiMessage)
default:
break
}
if nsError.domain == NSURLErrorDomain {
return .transportError(nsError.localizedDescription)
}
return .transportError(nsError.localizedDescription)
}
#endif
func sendWithURLSession<T: Decodable>(_ request: ApiRequest) async throws -> T {
let url = try buildURL(path: request.path, query: request.queryItems, baseURL: request.baseURLOverride ?? ApiConfig.baseURL)
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = request.method
urlRequest.httpBody = request.body
for (key, value) in buildHeaders(for: request) {
urlRequest.setValue(value, forHTTPHeaderField: key)
}
var attempt = 1
while attempt <= maxAttempts {
do {
return try await perform(urlRequest, as: T.self)
} catch is CancellationError {
throw NetworkError.cancelled
} catch let error as NetworkError {
guard shouldRetry(error), attempt < maxAttempts else {
throw error
}
try await Task.sleep(nanoseconds: backoff(for: attempt, error: error))
attempt += 1
} catch {
let wrapped = NetworkError.transportError(error.localizedDescription)
guard attempt < maxAttempts else { throw wrapped }
try await Task.sleep(nanoseconds: backoff(for: attempt, error: wrapped))
attempt += 1
}
}
throw NetworkError.invalidResponse
}
func perform<T: Decodable>(_ request: URLRequest, as type: T.Type) async throws -> T {
let (data, response): (Data, URLResponse)
do {
(data, response) = try await session.data(for: request)
} catch {
if let urlError = error as? URLError, urlError.code == .cancelled {
throw NetworkError.cancelled
}
throw NetworkError.transportError(error.localizedDescription)
}
guard let http = response as? HTTPURLResponse else {
throw NetworkError.invalidResponse
}
let payload = serverPayload(from: data)
if isSessionExpiredPayload(code: payload?.code, message: payload?.message) {
throw NetworkError.unauthorized(payload?.message)
}
if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let errorFlag = object["error"] as? Bool, errorFlag {
let message = object["message"] as? String ?? object["msg"] as? String
throw NetworkError.httpError(http.statusCode, message ?? "Erro no servidor")
}
if http.statusCode == 429 {
let retryAfter = Int(http.value(forHTTPHeaderField: "Retry-After") ?? "")
throw NetworkError.rateLimited(retryAfter)
}
if http.statusCode == 401 || http.statusCode == 403 {
throw NetworkError.unauthorized(serverMessage(from: data))
}
if !(200...299).contains(http.statusCode) {
throw NetworkError.httpError(http.statusCode, serverMessage(from: data))
}
do {
return try JSONDecoder().decode(type, from: data)
} catch {
throw NetworkError.decodeError(sanitizedBody(data))
}
}
func buildHeaders(for request: ApiRequest) -> [String: String] {
var headers: [String: String] = [
"Accept": "application/json",
"Content-Type": "application/json"
]
if let token = ApiConfig.token(for: request.module) {
headers["Atomenta-Token"] = token
}
if let customBearerToken = request.customBearerToken {
headers["Authorization"] = "Bearer \(customBearerToken)"
} else if request.requiresAuth, let jwt = tokenStore.jwt {
headers["Authorization"] = "Bearer \(jwt)"
}
return headers
}
func shouldRetry(_ error: NetworkError) -> Bool {
switch error {
case .rateLimited, .transportError:
return true
case .httpError(let statusCode, _):
return statusCode >= 500
case .invalidURL, .invalidResponse, .decodeError, .unauthorized, .cancelled, .timedOut:
return false
}
}
func backoff(for attempt: Int, error: NetworkError) -> UInt64 {
if case .rateLimited(let retryAfter) = error, let retryAfter {
return UInt64(retryAfter) * 1_000_000_000
}
let multiplier = UInt64(max(1, attempt))
return min(baseBackoffNanoseconds * multiplier, 2_000_000_000)
}
func serverMessage(from data: Data) -> String? {
let payload = serverPayload(from: data)
if let message = payload?.message, message.isEmpty == false {
return message
}
if let code = payload?.code, code.isEmpty == false {
return "Erro: \(code)"
}
// fallback if error true is present but without a classic structure
if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let errorFlag = object["error"] as? Bool, errorFlag {
if let msg = object["message"] as? String ?? object["msg"] as? String {
return msg
}
}
return sanitizedBody(data)
}
func serverPayload(from data: Data) -> ApiErrorDescriptor? {
if let envelope = try? JSONDecoder().decode(ApiEnvelope<EmptyResult>.self, from: data) {
return ApiErrorDescriptor(code: envelope.code, message: envelope.message)
}
if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
let errorFlag = object["error"] as? Bool ?? false
let code = object["code"] as? String
let message = object["message"] as? String ?? object["msg"] as? String ?? object["error_description"] as? String
if errorFlag || code != nil || message != nil {
return ApiErrorDescriptor(code: code, message: message)
}
}
return nil
}
func isSessionExpiredPayload(code: String?, message: String?) -> Bool {
let normalizedCode = (code ?? "").lowercased()
let normalizedMessage = (message ?? "").lowercased()
if normalizedCode.contains("auth") || normalizedCode.contains("token") || normalizedCode.contains("unauthorized") {
return true
}
if normalizedMessage.contains("token") && (normalizedMessage.contains("expir") || normalizedMessage.contains("invalid") || normalizedMessage.contains("sess")) {
return true
}
return false
}
#if canImport(LCEssentials)
func serverMessage(from error: NSError) -> String? {
for key in [NSLocalizedFailureReasonErrorKey, "body", "responseBody", "data", "message"] {
if let value = error.userInfo[key] as? String,
!value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
if let data = value.data(using: .utf8),
let parsed = serverMessage(from: data),
!parsed.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
let t = parsed.trimmingCharacters(in: .whitespacesAndNewlines)
if t.hasPrefix("{") { continue }
if let safe = sanitizedMessage(t) { return safe }
}
let t = value.trimmingCharacters(in: .whitespacesAndNewlines)
if !t.hasPrefix("{"), let safe = sanitizedMessage(t) { return safe }
} else if let valueData = error.userInfo[key] as? Data {
if let parsed = serverMessage(from: valueData) {
let t = parsed.trimmingCharacters(in: .whitespacesAndNewlines)
if !t.hasPrefix("{"), let safe = sanitizedMessage(t) { return safe }
}
}
}
for (_, value) in error.userInfo {
if let str = value as? String,
str.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("{"),
let data = str.data(using: .utf8),
let parsed = serverMessage(from: data) {
let t = parsed.trimmingCharacters(in: .whitespacesAndNewlines)
if !t.hasPrefix("{"), let safe = sanitizedMessage(t) { return safe }
}
}
if let description = error.userInfo[NSLocalizedDescriptionKey] as? String,
!description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
!description.lowercased().contains("nsurlerrordomain"),
let safe = sanitizedMessage(description) {
return safe
}
return nil
}
func serverPayload(from error: NSError) -> ApiErrorDescriptor? {
for key in [NSLocalizedFailureReasonErrorKey, "body", "responseBody", "data"] {
if let value = error.userInfo[key] as? String,
let data = value.data(using: .utf8),
let payload = serverPayload(from: data) {
return payload
} else if let valueData = error.userInfo[key] as? Data,
let payload = serverPayload(from: valueData) {
return payload
}
}
for (_, value) in error.userInfo {
if let str = value as? String,
str.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("{"),
let data = str.data(using: .utf8),
let payload = serverPayload(from: data) {
return payload
}
}
return nil
}
#endif
func sanitizedBody(_ data: Data) -> String? {
guard let raw = String(data: data, encoding: .utf8) else { return nil }
return sanitizedMessage(raw)
}
func sanitizedMessage(_ raw: String) -> String? {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.isEmpty == false else { return nil }
// Drop data URLs (base64 images)
if trimmed.lowercased().hasPrefix("data:image") { return "Erro ao processar imagem." }
// Drop fields containing base64,
if trimmed.contains("base64,") { return "Resposta do servidor inválida." }
// Truncate long strings (raw JSON bodies, etc.)
if trimmed.count > 300 {
return String(trimmed.prefix(300)) + ""
}
return trimmed
}
func buildURL(path: String, query: [URLQueryItem], baseURL: URL) throws -> URL {
guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else {
throw NetworkError.invalidURL
}
components.path = components.path.appending(path)
if !query.isEmpty {
components.queryItems = query
}
guard let url = components.url else { throw NetworkError.invalidURL }
return url
}
}

View File

@@ -0,0 +1,71 @@
import Foundation
enum ApiModule: Sendable {
case app
case customer
case store
case resource
case none
}
enum ApiConfig {
// Atomenta directly customer/store/cards/orders/addresses/etc. Only
// the public locator (session, locations, categories, stores-by-location,
// store detail) goes through pediFoodsBFFURL below via explicit
// baseURLOverride on those specific requests, per
// docs/plans/app-migrate-atomenta-calls-to-pedifoods-bff.md.
static var baseURL: URL {
let raw = ProcessInfo.processInfo.environment["ATOMENTA_API_URL"] ?? "https://atomenta.com.br"
return URL(string: raw) ?? URL(string: "https://atomenta.com.br")!
}
static var featureControlBffURL: URL {
let raw = ProcessInfo.processInfo.environment["FEATURE_CONTROL_BFF_URL"] ?? "http://localhost:8787"
return URL(string: raw) ?? URL(string: "http://localhost:8787")!
}
/// BFF (`PediFoods_web`) base URL. The public store locator (guest session,
/// states/cities/stores-by-location, store detail) must go through here
/// never call `baseURL` (Atomenta) directly for these, per
/// docs/plans/app-migrate-atomenta-calls-to-pedifoods-bff.md.
static var pediFoodsBFFURL: URL {
let raw = ProcessInfo.processInfo.environment["PEDIFOODS_BFF_URL"] ?? "https://pedifoods.com.br"
return URL(string: raw) ?? URL(string: "https://pedifoods.com.br")!
}
static var featureControlEnvironment: String {
let raw = ProcessInfo.processInfo.environment["FEATURE_CONTROL_ENVIRONMENT"] ?? "production"
let clean = raw.trimmingCharacters(in: .whitespacesAndNewlines)
return clean.isEmpty ? "production" : clean
}
// Tokens provided by backend modules
static let storeToken = "550e8400-e29b-41d4-a716-446655440008"
static let customerToken = "550e8400-e29b-41d4-a716-44665544000a"
static let resourceToken = "550e8400-e29b-41d4-a716-446655440009"
static func token(for module: ApiModule) -> String? {
switch module {
case .store: return storeToken
case .customer: return customerToken
case .resource: return resourceToken
case .app, .none: return nil
}
}
}
enum LegalDocument: Sendable {
case terms
case privacyPolicy
private var path: String {
switch self {
case .terms: return "api/public/pedi-foods-customer/terms"
case .privacyPolicy: return "api/public/pedi-foods-customer/privacy-policy"
}
}
var url: URL {
ApiConfig.baseURL.appendingPathComponent(path)
}
}

View File

@@ -0,0 +1,122 @@
import Foundation
struct PublicCategory: Decodable {
let id: String
let name: String
let icon: String?
}
struct CustomerProfileUpdatePayload: Encodable {
let addressBook: [CustomerAddressPayload]
enum CodingKeys: String, CodingKey {
case addressBook = "address_book"
}
}
struct CustomerIdentityUpdatePayload: Encodable {
let name: String?
let email: String?
let phoneNumber: String?
let profilePicture: String?
enum CodingKeys: String, CodingKey {
case name
case email
case phoneNumber
case profilePicture
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(name, forKey: .name)
try container.encodeIfPresent(email, forKey: .email)
try container.encodeIfPresent(phoneNumber, forKey: .phoneNumber)
if let profilePicture, profilePicture.isEmpty == false {
try container.encode(profilePicture, forKey: .profilePicture)
}
}
}
/// `POST /api/customer/:id` partial update, sibling of `CustomerProfileUpdatePayload`.
/// See docs/api/push-notifications-integration-guide.md §2b.
struct CustomerNotificationsUpdatePayload: Encodable {
let notificationsEnabled: Bool
}
/// `POST /api/customer/:id` biometric-login preference. Persistence only for
/// now; the actual Face ID/Touch ID unlock flow is a separate, later plan.
struct CustomerFaceIdUpdatePayload: Encodable {
let faceIdEnabled: Bool
}
/// `PUT /api/customer/:id/push-token` see docs/api/push-notifications-integration-guide.md §2.
struct CustomerPushTokenPayload: Encodable {
let pushToken: String
let deviceId: String
let deviceOS: String
}
/// `PUT /api/customer/:id/attributes` wholesale replace, not a merge.
/// See docs/api/push-notifications-integration-guide.md §2a.
struct CustomerAttributesUpdatePayload: Encodable {
let appVersion: String?
let attributes: [String: String]?
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(appVersion, forKey: .appVersion)
try container.encodeIfPresent(attributes, forKey: .attributes)
}
enum CodingKeys: String, CodingKey {
case appVersion
case attributes
}
}
/// `POST /api/customer/:id/push-campaigns/opened` see
/// docs/api/push-notifications-integration-guide.md §6a.
struct PushCampaignOpenedPayload: Encodable {
let campaignId: String
}
struct PushCampaignOpenedResult: Decodable {
let recorded: Bool
}
struct CustomerAddressPayload: Encodable {
let label: String?
let address: String?
let number: String?
let complement: String?
let neighborhood: String?
let city: String?
let state: String?
let zipCode: String?
let latLong: [Double]?
enum CodingKeys: String, CodingKey {
case label
case address
case number
case complement
case neighborhood
case city
case state
case zipCode
case latLong = "lat_long"
}
init(from address: CustomerAddress) {
self.label = address.label
self.address = address.address
self.number = address.number
self.complement = address.complement
self.neighborhood = address.neighborhood
self.city = address.city
self.state = address.state
self.zipCode = address.zipCode
self.latLong = address.latLong
}
}

View File

@@ -0,0 +1,6 @@
import Foundation
struct CustomerFavoritesMutationResult: Decodable {
let favorites: [String]
let store: StoreSummary?
}

View File

@@ -0,0 +1,552 @@
import Foundation
// MARK: - DTOs
struct EmptyResult: Decodable {}
struct ProfilePatchEnvelope: Decodable {
let error: Bool
let code: String?
let message: String?
let profilePictureUrl: String?
}
struct RegistrationResult: Decodable {
let id: String?
let name: String?
let email: String?
}
struct LoginResult: Decodable {
let token: String
let customer: CustomerProfile?
}
struct CustomerProfile: Decodable {
let id: String
let name: String
let email: String
let phoneNumber: String?
let profilePicture: String?
let favorites: [String]?
let addressBook: [CustomerAddress]?
let notificationsEnabled: Bool?
let faceIdEnabled: Bool?
enum CodingKeys: String, CodingKey {
case id
case name
case email
case phoneNumber
case profilePicture
case favorites
case addressBook = "address_book"
case notificationsEnabled
case faceIdEnabled
}
}
struct CustomerAddress: Decodable {
let id: String?
let label: String?
let address: String?
let number: String?
let complement: String?
let neighborhood: String?
let city: String?
let state: String?
let zipCode: String?
let latLong: [Double]?
let isDefault: Bool?
init(id: String?, label: String?, address: String?, number: String?,
complement: String?, neighborhood: String?, city: String?,
state: String?, zipCode: String?, latLong: [Double]?, isDefault: Bool?) {
self.id = id; self.label = label; self.address = address
self.number = number; self.complement = complement
self.neighborhood = neighborhood; self.city = city
self.state = state; self.zipCode = zipCode
self.latLong = latLong; self.isDefault = isDefault
}
enum CodingKeys: String, CodingKey {
case id, label, address, number, complement
case neighborhood, city, state, zipCode
case latLong = "lat_long"
case isDefault
}
}
struct StoreSummary: Decodable {
let id: String
let name: String
let logo: String?
let cover: String?
let category: String?
let rating: Double?
let reviewsCount: Int?
let positiveReviews: Int?
let deliveryTime: String?
let deliveryFee: Double?
let distance: Double?
let isOpen: Bool?
let statusLabel: String?
enum CodingKeys: String, CodingKey {
case id
case name
case logo
case cover
case category
case rating
case reviewsCount
case totalReviews
case reviews
case positiveReviews
case positive_reviews
case deliveryTime
case deliveryFee
case distance
case isOpen
case statusLabel
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString
name = (try? container.decode(String.self, forKey: .name)) ?? "Loja"
logo = try? container.decode(String.self, forKey: .logo)
cover = try? container.decode(String.self, forKey: .cover)
category = try? container.decode(String.self, forKey: .category)
rating = ApiService.decodeFlexibleDouble(from: container, keys: [.rating])
reviewsCount = ApiService.decodeFlexibleInt(from: container, keys: [.reviewsCount, .totalReviews, .reviews])
positiveReviews = ApiService.decodeFlexibleInt(from: container, keys: [.positiveReviews, .positive_reviews])
deliveryTime = try? container.decode(String.self, forKey: .deliveryTime)
deliveryFee = ApiService.decodeFlexibleDouble(from: container, keys: [.deliveryFee])
distance = ApiService.decodeFlexibleDouble(from: container, keys: [.distance])
isOpen = try? container.decode(Bool.self, forKey: .isOpen)
statusLabel = try? container.decode(String.self, forKey: .statusLabel)
}
}
struct StoreInfoResult: Decodable {
let isOpen: Bool?
let statusLabel: String?
let fantasyName: String?
let phone: String?
let whatsapp: String?
let logo: String?
let cover: String?
let deliveryTime: String?
let minOrder: Double?
let address: StoreAddressInfo?
let paymentMethods: StorePaymentMethodsInfo?
enum CodingKeys: String, CodingKey {
case isOpen
case statusLabel
case fantasyName
case phone
case whatsapp
case logo
case cover
case deliveryTime
case minOrder
case address
case paymentMethods
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
isOpen = try? container.decode(Bool.self, forKey: .isOpen)
statusLabel = try? container.decode(String.self, forKey: .statusLabel)
fantasyName = try? container.decode(String.self, forKey: .fantasyName)
phone = ApiService.decodeFlexibleString(from: container, keys: [.phone])
whatsapp = ApiService.decodeFlexibleString(from: container, keys: [.whatsapp, .phone])
logo = try? container.decode(String.self, forKey: .logo)
cover = try? container.decode(String.self, forKey: .cover)
deliveryTime = try? container.decode(String.self, forKey: .deliveryTime)
minOrder = ApiService.decodeFlexibleDouble(from: container, keys: [.minOrder])
address = try? container.decode(StoreAddressInfo.self, forKey: .address)
paymentMethods = try? container.decode(StorePaymentMethodsInfo.self, forKey: .paymentMethods)
}
}
struct StoreAddressInfo: Decodable {
let street: String?
let number: String?
let neighborhood: String?
let city: String?
let state: String?
let zipCode: String?
let latitude: Double?
let longitude: Double?
enum CodingKeys: String, CodingKey {
case street
case number
case neighborhood
case city
case state
case zipCode
case zipcode
case latitude
case longitude
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
street = try? container.decode(String.self, forKey: .street)
number = try? container.decode(String.self, forKey: .number)
neighborhood = try? container.decode(String.self, forKey: .neighborhood)
city = try? container.decode(String.self, forKey: .city)
state = try? container.decode(String.self, forKey: .state)
zipCode = (try? container.decode(String.self, forKey: .zipCode))
?? (try? container.decode(String.self, forKey: .zipcode))
latitude = ApiService.decodeFlexibleDouble(from: container, keys: [.latitude])
longitude = ApiService.decodeFlexibleDouble(from: container, keys: [.longitude])
}
}
struct StorePaymentMethodsInfo: Decodable {
let paymentOnDelivery: Bool?
let paymentOnPickup: Bool?
let acceptPix: Bool?
let acceptCash: Bool?
let acceptCreditCard: Bool?
let acceptDebitCard: Bool?
let acceptCreditVisa: Bool?
let acceptCreditMaster: Bool?
let acceptCreditElo: Bool?
let acceptCreditAmex: Bool?
let acceptCreditHipercard: Bool?
let acceptDebitVisa: Bool?
let acceptDebitMaster: Bool?
let acceptDebitElo: Bool?
let acceptVoucherAlelo: Bool?
let acceptVoucherSodexo: Bool?
let acceptVoucherTicket: Bool?
let acceptVoucherVR: Bool?
enum CodingKeys: String, CodingKey {
case paymentOnDelivery
case paymentOnPickup
case acceptPix
case acceptCash
case acceptCreditCard
case acceptDebitCard
case acceptCreditVisa
case acceptCreditMaster
case acceptCreditElo
case acceptCreditAmex
case acceptCreditHipercard
case acceptDebitVisa
case acceptDebitMaster
case acceptDebitElo
case acceptVoucherAlelo
case acceptVoucherSodexo
case acceptVoucherTicket
case acceptVoucherVR
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
paymentOnDelivery = try? container.decode(Bool.self, forKey: .paymentOnDelivery)
paymentOnPickup = try? container.decode(Bool.self, forKey: .paymentOnPickup)
acceptPix = try? container.decode(Bool.self, forKey: .acceptPix)
acceptCash = try? container.decode(Bool.self, forKey: .acceptCash)
acceptCreditCard = try? container.decode(Bool.self, forKey: .acceptCreditCard)
acceptDebitCard = try? container.decode(Bool.self, forKey: .acceptDebitCard)
acceptCreditVisa = try? container.decode(Bool.self, forKey: .acceptCreditVisa)
acceptCreditMaster = try? container.decode(Bool.self, forKey: .acceptCreditMaster)
acceptCreditElo = try? container.decode(Bool.self, forKey: .acceptCreditElo)
acceptCreditAmex = try? container.decode(Bool.self, forKey: .acceptCreditAmex)
acceptCreditHipercard = try? container.decode(Bool.self, forKey: .acceptCreditHipercard)
acceptDebitVisa = try? container.decode(Bool.self, forKey: .acceptDebitVisa)
acceptDebitMaster = try? container.decode(Bool.self, forKey: .acceptDebitMaster)
acceptDebitElo = try? container.decode(Bool.self, forKey: .acceptDebitElo)
acceptVoucherAlelo = try? container.decode(Bool.self, forKey: .acceptVoucherAlelo)
acceptVoucherSodexo = try? container.decode(Bool.self, forKey: .acceptVoucherSodexo)
acceptVoucherTicket = try? container.decode(Bool.self, forKey: .acceptVoucherTicket)
acceptVoucherVR = try? container.decode(Bool.self, forKey: .acceptVoucherVR)
}
var hasAnyCreditCard: Bool {
(acceptCreditCard ?? false)
|| (acceptCreditVisa ?? false)
|| (acceptCreditMaster ?? false)
|| (acceptCreditElo ?? false)
|| (acceptCreditAmex ?? false)
|| (acceptCreditHipercard ?? false)
}
var hasAnyDebitCard: Bool {
(acceptDebitCard ?? false)
|| (acceptDebitVisa ?? false)
|| (acceptDebitMaster ?? false)
|| (acceptDebitElo ?? false)
}
var hasAnyVoucher: Bool {
(acceptVoucherAlelo ?? false)
|| (acceptVoucherSodexo ?? false)
|| (acceptVoucherTicket ?? false)
|| (acceptVoucherVR ?? false)
}
}
struct StoreCatalogCategory: Decodable {
let id: String
let name: String
let isPizzaCategory: Bool
let pizzaConfig: StorePizzaConfig?
let products: [StoreCatalogProduct]
enum CodingKeys: String, CodingKey {
case id
case name
case isPizzaCategory
case pizzaConfig
case products
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString
name = (try? container.decode(String.self, forKey: .name)) ?? "Categoria"
isPizzaCategory = (try? container.decode(Bool.self, forKey: .isPizzaCategory)) ?? false
pizzaConfig = try? container.decode(StorePizzaConfig.self, forKey: .pizzaConfig)
products = (try? container.decode([StoreCatalogProduct].self, forKey: .products)) ?? []
}
}
struct StoreCatalogProduct: Decodable, Identifiable {
let id: String
let type: String?
let name: String
let description: String?
let image: String?
let price: Double?
let originalPrice: Double?
let pizzaPrices: [String: Double]
let addonGroups: [StoreAddonGroup]
enum CodingKeys: String, CodingKey {
case id
case type
case name
case description
case desc
case image
case cover
case photo
case price
case originalPrice
case oldPrice
case pizzaPrices
case addonGroups
case addons
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString
type = try? container.decode(String.self, forKey: .type)
name = (try? container.decode(String.self, forKey: .name)) ?? "Produto"
description = (try? container.decode(String.self, forKey: .description)) ?? (try? container.decode(String.self, forKey: .desc))
image = (try? container.decode(String.self, forKey: .image))
?? (try? container.decode(String.self, forKey: .cover))
?? (try? container.decode(String.self, forKey: .photo))
price = ApiService.decodeFlexibleDouble(from: container, keys: [.price])
originalPrice = ApiService.decodeFlexibleDouble(from: container, keys: [.originalPrice, .oldPrice])
pizzaPrices = StoreCatalogProduct.decodePizzaPrices(container: container)
addonGroups = (try? container.decode([StoreAddonGroup].self, forKey: .addonGroups))
?? (try? container.decode([StoreAddonGroup].self, forKey: .addons))
?? []
}
private static func decodePizzaPrices(container: KeyedDecodingContainer<CodingKeys>) -> [String: Double] {
if let direct = try? container.decode([String: Double].self, forKey: .pizzaPrices) {
return direct
}
if let asInt = try? container.decode([String: Int].self, forKey: .pizzaPrices) {
return asInt.mapValues { Double($0) }
}
if let asString = try? container.decode([String: String].self, forKey: .pizzaPrices) {
var parsed: [String: Double] = [:]
for (key, value) in asString {
let normalized = value
.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: ",", with: ".")
if let number = Double(normalized) {
parsed[key] = number
}
}
return parsed
}
return [:]
}
}
struct StoreAddonGroup: Decodable, Identifiable {
let id: String
let name: String
let minSelectors: Int?
let maxSelectors: Int?
let items: [StoreAddonItem]
enum CodingKeys: String, CodingKey {
case id
case name
case minSelectors
case maxSelectors
case items
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString
name = (try? container.decode(String.self, forKey: .name)) ?? "Adicionais"
minSelectors = try? container.decode(Int.self, forKey: .minSelectors)
maxSelectors = try? container.decode(Int.self, forKey: .maxSelectors)
items = (try? container.decode([StoreAddonItem].self, forKey: .items)) ?? []
}
}
struct StoreAddonItem: Decodable, Identifiable {
let id: String
let name: String
let price: Double?
enum CodingKeys: String, CodingKey {
case id
case name
case price
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString
name = (try? container.decode(String.self, forKey: .name)) ?? "Item"
price = ApiService.decodeFlexibleDouble(from: container, keys: [.price])
}
}
struct CepLookupResult: Decodable {
let zipCode: String?
let street: String?
let neighborhood: String?
let city: String?
let state: String?
let complement: String?
let latitude: Double?
let longitude: Double?
enum CodingKeys: String, CodingKey {
case zipCode
case cep
case zip
case normalized
case raw
case street
case logradouro
case address
case neighborhood
case bairro
case district
case city
case cidade
case localidade
case state
case estado
case uf
case complement
case complemento
case latitude
case lat
case longitude
case lng
}
enum NormalizedKeys: String, CodingKey {
case cep
case logradouro
case bairro
case cidade
case uf
case latitude
case longitude
}
enum RawKeys: String, CodingKey {
case cep
case address
case district
case city
case state
case lat
case lng
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let normalizedContainer = try? container.nestedContainer(keyedBy: NormalizedKeys.self, forKey: .normalized)
let rawContainer = try? container.nestedContainer(keyedBy: RawKeys.self, forKey: .raw)
let directZip = CepLookupResult.decodeString(from: container, keys: [.zipCode, .cep, .zip])
let directStreet = CepLookupResult.decodeString(from: container, keys: [.street, .logradouro, .address])
let directNeighborhood = CepLookupResult.decodeString(from: container, keys: [.neighborhood, .bairro, .district])
let directCity = CepLookupResult.decodeString(from: container, keys: [.city, .cidade, .localidade])
let directState = CepLookupResult.decodeString(from: container, keys: [.state, .estado, .uf])
let directComplement = CepLookupResult.decodeString(from: container, keys: [.complement, .complemento])
let directLatitude = CepLookupResult.decodeFlexibleDouble(from: container, keys: [.latitude, .lat])
let directLongitude = CepLookupResult.decodeFlexibleDouble(from: container, keys: [.longitude, .lng])
let normalizedZip = normalizedContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.cep]) }
let normalizedStreet = normalizedContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.logradouro]) }
let normalizedNeighborhood = normalizedContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.bairro]) }
let normalizedCity = normalizedContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.cidade]) }
let normalizedState = normalizedContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.uf]) }
let normalizedLatitude = normalizedContainer.flatMap { CepLookupResult.decodeFlexibleDouble(from: $0, keys: [.latitude]) }
let normalizedLongitude = normalizedContainer.flatMap { CepLookupResult.decodeFlexibleDouble(from: $0, keys: [.longitude]) }
let rawZip = rawContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.cep]) }
let rawStreet = rawContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.address]) }
let rawNeighborhood = rawContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.district]) }
let rawCity = rawContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.city]) }
let rawState = rawContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.state]) }
let rawLatitude = rawContainer.flatMap { CepLookupResult.decodeFlexibleDouble(from: $0, keys: [.lat]) }
let rawLongitude = rawContainer.flatMap { CepLookupResult.decodeFlexibleDouble(from: $0, keys: [.lng]) }
zipCode = directZip ?? normalizedZip ?? rawZip
street = directStreet ?? normalizedStreet ?? rawStreet
neighborhood = directNeighborhood ?? normalizedNeighborhood ?? rawNeighborhood
city = directCity ?? normalizedCity ?? rawCity
state = directState ?? normalizedState ?? rawState
complement = directComplement
latitude = directLatitude ?? normalizedLatitude ?? rawLatitude
longitude = directLongitude ?? normalizedLongitude ?? rawLongitude
}
private static func decodeString<K: CodingKey>(from container: KeyedDecodingContainer<K>, keys: [K]) -> String? {
for key in keys {
if let value = try? container.decode(String.self, forKey: key) {
return value
}
}
return nil
}
private static func decodeFlexibleDouble<K: CodingKey>(from container: KeyedDecodingContainer<K>, keys: [K]) -> Double? {
for key in keys {
if let value = try? container.decode(Double.self, forKey: key) {
return value
}
if let valueAsString = try? container.decode(String.self, forKey: key),
let parsed = Double(valueAsString.replacingOccurrences(of: ",", with: ".")) {
return parsed
}
}
return nil
}
}

View File

@@ -0,0 +1,196 @@
import Foundation
struct CreateOrderPayload: Encodable {
let customer: CreateOrderCustomerPayload
let items: [CreateOrderItemPayload]
let total: Double
let paymentMethod: String
let deliveryType: String
let address: CreateOrderAddressPayload?
// Cartão salvo
let savedCardId: String?
// Novo cartão (checkout transparente)
let clientCpfCnpj: String?
let creditCard: CreditCardOrderPayload?
let creditCardHolderInfo: SaveCardHolderInfoPayload?
}
struct CreateOrderCustomerPayload: Encodable {
let name: String
let phone: String
let email: String
let asaasId: String?
}
struct CreateOrderItemPayload: Codable {
let productId: String
let name: String
let qty: Int
let price: Double
let addons: [CreateOrderAddonPayload]
let choices: [String]?
}
struct CreateOrderAddonPayload: Codable {
let addonId: String
let name: String
let qty: Int
let price: Double
}
struct CreateOrderAddressPayload: Encodable {
let street: String
let number: String
let neighborhood: String
let city: String?
let state: String?
let zip: String?
let complement: String?
}
struct CreateOrderResult: Decodable {
let id: String?
let shortId: String?
let status: String?
let paymentStatus: String?
let paymentConfirmed: Bool?
let paymentMethod: String?
let paymentPayload: CreateOrderPaymentPayload?
let payment: CreateOrderPaymentInfo?
enum CodingKeys: String, CodingKey {
case id
case shortId
case status
case paymentStatus
case paymentConfirmed
case paymentMethod
case paymentPayload
case payment
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = ApiService.decodeFlexibleString(from: container, keys: [.id])
shortId = ApiService.decodeFlexibleString(from: container, keys: [.shortId])
status = ApiService.decodeFlexibleString(from: container, keys: [.status])
paymentStatus = ApiService.decodeFlexibleString(from: container, keys: [.paymentStatus])
paymentConfirmed = try? container.decode(Bool.self, forKey: .paymentConfirmed)
paymentMethod = ApiService.decodeFlexibleString(from: container, keys: [.paymentMethod])
payment = try? container.decode(CreateOrderPaymentInfo.self, forKey: .payment)
if let objectPayload = try? container.decode(CreateOrderPaymentPayload.self, forKey: .paymentPayload) {
paymentPayload = objectPayload
} else if let stringPayload = try? container.decode(String.self, forKey: .paymentPayload) {
paymentPayload = CreateOrderPaymentPayload(
copyPaste: stringPayload,
qrCodeImage: nil,
expirationDate: nil
)
} else {
paymentPayload = nil
}
}
}
struct CreateOrderPaymentInfo: Codable {
let method: String?
let status: String?
let pix: CreateOrderPaymentPayload?
}
struct CreateOrderPaymentPayload: Codable {
let copyPaste: String?
let qrCodeImage: String?
let expirationDate: String?
enum CodingKeys: String, CodingKey {
case copyPaste
case payload
case qrCodeImage
case encodedImage
case expirationDate
}
init(copyPaste: String?, qrCodeImage: String?, expirationDate: String?) {
self.copyPaste = copyPaste
self.qrCodeImage = qrCodeImage
self.expirationDate = expirationDate
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
copyPaste = (try? container.decode(String.self, forKey: .copyPaste))
?? (try? container.decode(String.self, forKey: .payload))
qrCodeImage = (try? container.decode(String.self, forKey: .qrCodeImage))
?? (try? container.decode(String.self, forKey: .encodedImage))
expirationDate = try? container.decode(String.self, forKey: .expirationDate)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(copyPaste, forKey: .copyPaste)
try container.encodeIfPresent(qrCodeImage, forKey: .qrCodeImage)
try container.encodeIfPresent(expirationDate, forKey: .expirationDate)
}
}
struct ValidateDeliveryAddressPayload: Encodable {
let address: ValidateDeliveryAddressDataPayload
}
struct ValidateDeliveryAddressDataPayload: Encodable {
let street: String?
let number: String?
let neighborhood: String?
let city: String?
let state: String?
let zip: String?
let lat: Double?
let lng: Double?
}
struct ValidateDeliveryAddressResult: Decodable {
let deliveryAllowed: Bool?
let reasonCode: String?
let reasonMessage: String?
let deliveryMode: String?
let distance: Double?
let deliveryFee: Double?
let deliveryTime: String?
let sameCity: Bool?
enum CodingKeys: String, CodingKey {
case deliveryAllowed
case delivery_allowed
case reasonCode
case reason_code
case reasonMessage
case reason_message
case deliveryMode
case delivery_mode
case distance
case deliveryFee
case delivery_fee
case fee
case taxa
case deliveryTime
case delivery_time
case sameCity
case same_city
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
deliveryAllowed = (try? c.decode(Bool.self, forKey: .deliveryAllowed))
?? (try? c.decode(Bool.self, forKey: .delivery_allowed))
reasonCode = ApiService.decodeFlexibleString(from: c, keys: [.reasonCode, .reason_code])
reasonMessage = ApiService.decodeFlexibleString(from: c, keys: [.reasonMessage, .reason_message])
deliveryMode = ApiService.decodeFlexibleString(from: c, keys: [.deliveryMode, .delivery_mode])
distance = ApiService.decodeFlexibleDouble(from: c, keys: [.distance])
deliveryFee = ApiService.decodeFlexibleDouble(from: c, keys: [.deliveryFee, .delivery_fee, .fee, .taxa])
deliveryTime = ApiService.decodeFlexibleString(from: c, keys: [.deliveryTime, .delivery_time])
sameCity = (try? c.decode(Bool.self, forKey: .sameCity))
?? (try? c.decode(Bool.self, forKey: .same_city))
}
}

View File

@@ -0,0 +1,715 @@
import Foundation
struct AppOrderSummary: Decodable, Identifiable {
let id: String
let orderId: String?
let realId: String?
let storeId: String?
let shortId: String?
let total: Double?
let status: String?
let statusDetailed: String?
let statusLabel: String?
let nextAction: String?
let paymentStatus: String?
let paymentMethod: String?
let deliveryType: String?
let storeName: String?
let storePhone: String?
let storeLogoURL: String?
let createdAt: String?
let updatedAt: String?
enum CodingKeys: String, CodingKey {
case id
case orderId
case realId
case storeId
case store_id
case shortId
case total
case status
case statusDetailed
case statusLabel
case nextAction
case paymentStatus
case paymentMethod
case deliveryType
case storeName
case storePhone
case storeLogo
case store_logo
case logo
case storeImage
case store_image
case storeImageUrl
case logoUrl
case date
case createdAt
case updatedAt
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
orderId = ApiService.decodeFlexibleString(from: container, keys: [.orderId])
id = ApiService.decodeFlexibleString(from: container, keys: [.orderId, .realId, .id]) ?? UUID().uuidString
realId = ApiService.decodeFlexibleString(from: container, keys: [.realId])
storeId = ApiService.decodeFlexibleString(from: container, keys: [.storeId, .store_id])
shortId = ApiService.decodeFlexibleString(from: container, keys: [.shortId])
total = ApiService.decodeFlexibleDouble(from: container, keys: [.total])
status = ApiService.decodeFlexibleString(from: container, keys: [.status])
statusDetailed = ApiService.decodeFlexibleString(from: container, keys: [.statusDetailed])
statusLabel = ApiService.decodeFlexibleString(from: container, keys: [.statusLabel])
nextAction = ApiService.decodeFlexibleString(from: container, keys: [.nextAction])
paymentStatus = ApiService.decodeFlexibleString(from: container, keys: [.paymentStatus])
paymentMethod = ApiService.decodeFlexibleString(from: container, keys: [.paymentMethod])
deliveryType = ApiService.decodeFlexibleString(from: container, keys: [.deliveryType])
storeName = ApiService.decodeFlexibleString(from: container, keys: [.storeName])
storePhone = ApiService.decodeFlexibleString(from: container, keys: [.storePhone])
storeLogoURL = ApiService.decodeFlexibleString(from: container, keys: [.storeLogo, .store_logo, .logo, .storeImage, .store_image, .storeImageUrl, .logoUrl])
let fallbackDate = ApiService.decodeFlexibleString(from: container, keys: [.date])
createdAt = ApiService.decodeFlexibleString(from: container, keys: [.createdAt]) ?? fallbackDate
updatedAt = ApiService.decodeFlexibleString(from: container, keys: [.updatedAt]) ?? fallbackDate
}
}
struct PublicOrderResult: Codable, Identifiable {
let id: String
let shortId: String?
let realId: String?
let storeId: String?
let status: String?
let paymentStatus: String?
let paymentConfirmed: Bool?
let paymentMethod: String?
let paymentMethodCode: String?
let paymentPayload: CreateOrderPaymentPayload?
let payment: CreateOrderPaymentInfo?
let nextAction: String?
let deliveryType: String?
let deliveryTypeLabel: String?
let subtotal: Double?
let deliveryFee: Double?
let discount: Double?
let total: Double?
let storeName: String?
let storePhone: String?
let storeLogoURL: String?
let createdAt: String?
let updatedAt: String?
let otp: String?
let customerOtp: String?
let confirmOtp: String?
let cancellationReason: String?
let fullAddress: String?
let deliveryAddress: PublicOrderDeliveryAddress?
let review: PublicOrderReview?
let items: [PublicOrderItem]
let timeline: [PublicOrderTimelineEvent]
enum CodingKeys: String, CodingKey {
case id
case shortId
case realId
case storeId
case store_id
case status
case paymentStatus
case paymentConfirmed
case paymentMethod
case paymentMethodCode
case paymentPayload
case payment
case nextAction
case deliveryType
case deliveryTypeLabel
case subtotal
case subTotal
case itemsTotal
case deliveryFee
case delivery_fee
case fee
case discount
case desconto
case couponDiscount
case total
case storeName
case storePhone
case storeLogo
case store_logo
case logo
case storeImage
case store_image
case storeImageUrl
case logoUrl
case createdAt
case updatedAt
case otp
case customerOtp
case confirmOtp
case cancellationReason
case fullAddress
case address
case deliveryAddress
case delivery_address
case customerAddress
case customer_address
case review
case orderReview
case items
case timeline
case history
case orderedAt
}
init(
id: String,
shortId: String? = nil,
realId: String? = nil,
storeId: String? = nil,
status: String? = nil,
paymentStatus: String? = nil,
paymentConfirmed: Bool? = nil,
paymentMethod: String? = nil,
paymentMethodCode: String? = nil,
paymentPayload: CreateOrderPaymentPayload? = nil,
payment: CreateOrderPaymentInfo? = nil,
nextAction: String? = nil,
deliveryType: String? = nil,
deliveryTypeLabel: String? = nil,
subtotal: Double? = nil,
deliveryFee: Double? = nil,
discount: Double? = nil,
total: Double? = nil,
storeName: String? = nil,
storePhone: String? = nil,
storeLogoURL: String? = nil,
createdAt: String? = nil,
updatedAt: String? = nil,
otp: String? = nil,
customerOtp: String? = nil,
confirmOtp: String? = nil,
cancellationReason: String? = nil,
fullAddress: String? = nil,
deliveryAddress: PublicOrderDeliveryAddress? = nil,
review: PublicOrderReview? = nil,
items: [PublicOrderItem] = [],
timeline: [PublicOrderTimelineEvent] = []
) {
self.id = id
self.shortId = shortId
self.realId = realId
self.storeId = storeId
self.status = status
self.paymentStatus = paymentStatus
self.paymentConfirmed = paymentConfirmed
self.paymentMethod = paymentMethod
self.paymentMethodCode = paymentMethodCode
self.paymentPayload = paymentPayload
self.payment = payment
self.nextAction = nextAction
self.deliveryType = deliveryType
self.deliveryTypeLabel = deliveryTypeLabel
self.subtotal = subtotal
self.deliveryFee = deliveryFee
self.discount = discount
self.total = total
self.storeName = storeName
self.storePhone = storePhone
self.storeLogoURL = storeLogoURL
self.createdAt = createdAt
self.updatedAt = updatedAt
self.otp = otp
self.customerOtp = customerOtp
self.confirmOtp = confirmOtp
self.cancellationReason = cancellationReason
self.fullAddress = fullAddress
self.deliveryAddress = deliveryAddress
self.review = review
self.items = items
self.timeline = timeline
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = ApiService.decodeFlexibleString(from: container, keys: [.id]) ?? UUID().uuidString
shortId = ApiService.decodeFlexibleString(from: container, keys: [.shortId])
realId = ApiService.decodeFlexibleString(from: container, keys: [.realId])
storeId = ApiService.decodeFlexibleString(from: container, keys: [.storeId, .store_id])
status = ApiService.decodeFlexibleString(from: container, keys: [.status])
paymentStatus = ApiService.decodeFlexibleString(from: container, keys: [.paymentStatus])
paymentConfirmed = try? container.decode(Bool.self, forKey: .paymentConfirmed)
paymentMethod = ApiService.decodeFlexibleString(from: container, keys: [.paymentMethod])
paymentMethodCode = ApiService.decodeFlexibleString(from: container, keys: [.paymentMethodCode])
nextAction = ApiService.decodeFlexibleString(from: container, keys: [.nextAction])
payment = try? container.decode(CreateOrderPaymentInfo.self, forKey: .payment)
if let objectPayload = try? container.decode(CreateOrderPaymentPayload.self, forKey: .paymentPayload) {
paymentPayload = objectPayload
} else if let stringPayload = try? container.decode(String.self, forKey: .paymentPayload) {
paymentPayload = CreateOrderPaymentPayload(
copyPaste: stringPayload,
qrCodeImage: nil,
expirationDate: nil
)
} else {
paymentPayload = nil
}
deliveryType = ApiService.decodeFlexibleString(from: container, keys: [.deliveryType])
deliveryTypeLabel = ApiService.decodeFlexibleString(from: container, keys: [.deliveryTypeLabel])
subtotal = ApiService.decodeFlexibleDouble(from: container, keys: [.subtotal, .subTotal, .itemsTotal])
deliveryFee = ApiService.decodeFlexibleDouble(from: container, keys: [.deliveryFee, .delivery_fee, .fee])
discount = ApiService.decodeFlexibleDouble(from: container, keys: [.discount, .desconto, .couponDiscount])
total = ApiService.decodeFlexibleDouble(from: container, keys: [.total])
storeName = ApiService.decodeFlexibleString(from: container, keys: [.storeName])
storePhone = ApiService.decodeFlexibleString(from: container, keys: [.storePhone])
storeLogoURL = ApiService.decodeFlexibleString(from: container, keys: [.storeLogo, .store_logo, .logo, .storeImage, .store_image, .storeImageUrl, .logoUrl])
createdAt = ApiService.decodeFlexibleString(from: container, keys: [.createdAt, .orderedAt])
updatedAt = ApiService.decodeFlexibleString(from: container, keys: [.updatedAt])
otp = ApiService.decodeFlexibleString(from: container, keys: [.otp])
customerOtp = ApiService.decodeFlexibleString(from: container, keys: [.customerOtp])
confirmOtp = ApiService.decodeFlexibleString(from: container, keys: [.confirmOtp])
cancellationReason = ApiService.decodeFlexibleString(from: container, keys: [.cancellationReason])
fullAddress = ApiService.decodeFlexibleString(from: container, keys: [.fullAddress])
deliveryAddress = (try? container.decode(PublicOrderDeliveryAddress.self, forKey: .address))
?? (try? container.decode(PublicOrderDeliveryAddress.self, forKey: .deliveryAddress))
?? (try? container.decode(PublicOrderDeliveryAddress.self, forKey: .delivery_address))
?? (try? container.decode(PublicOrderDeliveryAddress.self, forKey: .customerAddress))
?? (try? container.decode(PublicOrderDeliveryAddress.self, forKey: .customer_address))
review = (try? container.decode(PublicOrderReview.self, forKey: .review))
?? (try? container.decode(PublicOrderReview.self, forKey: .orderReview))
items = (try? container.decode([PublicOrderItem].self, forKey: .items)) ?? []
timeline = (try? container.decode([PublicOrderTimelineEvent].self, forKey: .timeline))
?? (try? container.decode([PublicOrderTimelineEvent].self, forKey: .history))
?? []
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encodeIfPresent(shortId, forKey: .shortId)
try container.encodeIfPresent(realId, forKey: .realId)
try container.encodeIfPresent(storeId, forKey: .storeId)
try container.encodeIfPresent(status, forKey: .status)
try container.encodeIfPresent(paymentStatus, forKey: .paymentStatus)
try container.encodeIfPresent(paymentConfirmed, forKey: .paymentConfirmed)
try container.encodeIfPresent(paymentMethod, forKey: .paymentMethod)
try container.encodeIfPresent(paymentMethodCode, forKey: .paymentMethodCode)
try container.encodeIfPresent(paymentPayload, forKey: .paymentPayload)
try container.encodeIfPresent(payment, forKey: .payment)
try container.encodeIfPresent(nextAction, forKey: .nextAction)
try container.encodeIfPresent(deliveryType, forKey: .deliveryType)
try container.encodeIfPresent(deliveryTypeLabel, forKey: .deliveryTypeLabel)
try container.encodeIfPresent(subtotal, forKey: .subtotal)
try container.encodeIfPresent(deliveryFee, forKey: .deliveryFee)
try container.encodeIfPresent(discount, forKey: .discount)
try container.encodeIfPresent(total, forKey: .total)
try container.encodeIfPresent(storeName, forKey: .storeName)
try container.encodeIfPresent(storePhone, forKey: .storePhone)
try container.encodeIfPresent(createdAt, forKey: .createdAt)
try container.encodeIfPresent(updatedAt, forKey: .updatedAt)
try container.encodeIfPresent(otp, forKey: .otp)
try container.encodeIfPresent(customerOtp, forKey: .customerOtp)
try container.encodeIfPresent(confirmOtp, forKey: .confirmOtp)
try container.encodeIfPresent(cancellationReason, forKey: .cancellationReason)
try container.encodeIfPresent(fullAddress, forKey: .fullAddress)
try container.encodeIfPresent(deliveryAddress, forKey: .address)
try container.encodeIfPresent(review, forKey: .review)
try container.encode(items, forKey: .items)
try container.encode(timeline, forKey: .timeline)
}
var displayOtpCode: String? {
let values = [customerOtp, otp, confirmOtp]
for value in values {
let trimmed = (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty == false { return trimmed }
}
return nil
}
var isInDeliveryRoute: Bool {
let normalized = (status ?? "").uppercased()
if normalized.contains("OUT_FOR_DELIVERY") { return true }
if normalized.contains("EM_ROTA") { return true }
if normalized.contains("ON_ROUTE") { return true }
if normalized.contains("ROTA") { return true }
return false
}
var isFinalStatus: Bool {
let normalized = (status ?? "").uppercased()
return normalized == "COMPLETED" || normalized == "CANCELED" || normalized == "REFUNDED"
}
var isPaymentConfirmed: Bool {
if let paymentConfirmed {
return paymentConfirmed
}
let payment = (paymentStatus ?? "").uppercased()
let currentStatus = (status ?? "").uppercased()
if Self.looksConfirmed(payment) || Self.looksConfirmed(currentStatus) {
return true
}
// Fallback: alguns ambientes atualizam apenas a timeline primeiro.
if timeline.contains(where: { event in
let statusValue = (event.status ?? "").uppercased()
let messageValue = (event.message ?? "").uppercased()
return Self.looksConfirmed(statusValue) || Self.looksConfirmed(messageValue)
}) {
return true
}
return false
}
private static func looksConfirmed(_ value: String) -> Bool {
if value.isEmpty { return false }
if value.contains("PENDING") || value.contains("AWAIT") { return false }
if value.contains("FAILED") || value.contains("ERROR") { return false }
if value.contains("CANCEL") || value.contains("REFUND") { return false }
if value.contains("CONFIRM") { return true }
if value.contains("APPROV") { return true }
if value.contains("PAID") { return true }
if value.contains("RECEIV") { return true }
return value == "SUCCESS" || value == "DONE"
}
}
struct PublicOrderDeliveryAddress: Codable {
let label: String?
let street: String?
let number: String?
let neighborhood: String?
let city: String?
let state: String?
let zip: String?
let complement: String?
enum CodingKeys: String, CodingKey {
case label
case street
case address
case number
case neighborhood
case district
case city
case state
case zip
case zipCode
case zipcode
case complement
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
label = ApiService.decodeFlexibleString(from: container, keys: [.label])
street = ApiService.decodeFlexibleString(from: container, keys: [.street, .address])
number = ApiService.decodeFlexibleString(from: container, keys: [.number])
neighborhood = ApiService.decodeFlexibleString(from: container, keys: [.neighborhood, .district])
city = ApiService.decodeFlexibleString(from: container, keys: [.city])
state = ApiService.decodeFlexibleString(from: container, keys: [.state])
zip = ApiService.decodeFlexibleString(from: container, keys: [.zip, .zipCode, .zipcode])
complement = ApiService.decodeFlexibleString(from: container, keys: [.complement])
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(label, forKey: .label)
try container.encodeIfPresent(street, forKey: .street)
try container.encodeIfPresent(number, forKey: .number)
try container.encodeIfPresent(neighborhood, forKey: .neighborhood)
try container.encodeIfPresent(city, forKey: .city)
try container.encodeIfPresent(state, forKey: .state)
try container.encodeIfPresent(zip, forKey: .zip)
try container.encodeIfPresent(complement, forKey: .complement)
}
}
struct PublicOrderReview: Codable {
let id: String?
let orderId: String?
let rate: Int?
let message: String?
let orderRate: Int?
let orderComment: String?
let orderPositiveTags: [String]?
let orderImprovementTags: [String]?
let deliverySentiment: String?
let deliveryPositiveTags: [String]?
let deliveryNegativeTags: [String]?
let appNps: Int?
let platform: String?
let date: String?
enum CodingKeys: String, CodingKey {
case id
case orderId
case rate
case message
case orderRate
case orderComment
case orderPositiveTags
case orderImprovementTags
case itemFeedback
case improvementFeedback
case deliverySentiment
case deliveryFeedback
case deliveryPositiveTags
case deliveryNegativeTags
case appNps
case app_nps
case platform
case date
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = ApiService.decodeFlexibleString(from: container, keys: [.id])
orderId = ApiService.decodeFlexibleString(from: container, keys: [.orderId])
rate = ApiService.decodeFlexibleInt(from: container, keys: [.rate])
message = ApiService.decodeFlexibleString(from: container, keys: [.message])
orderRate = ApiService.decodeFlexibleInt(from: container, keys: [.orderRate, .rate])
orderComment = ApiService.decodeFlexibleString(from: container, keys: [.orderComment, .message])
orderPositiveTags = Self.decodeStringList(
from: container,
keys: [.orderPositiveTags, .itemFeedback]
)
orderImprovementTags = Self.decodeStringList(
from: container,
keys: [.orderImprovementTags, .improvementFeedback]
)
deliverySentiment = ApiService.decodeFlexibleString(from: container, keys: [.deliverySentiment, .deliveryFeedback])
deliveryPositiveTags = Self.decodeStringList(from: container, keys: [.deliveryPositiveTags])
deliveryNegativeTags = Self.decodeStringList(from: container, keys: [.deliveryNegativeTags])
appNps = Self.decodeNps(from: container, keys: [.appNps, .app_nps])
platform = ApiService.decodeFlexibleString(from: container, keys: [.platform])
date = ApiService.decodeFlexibleString(from: container, keys: [.date])
}
private static func decodeStringList(
from container: KeyedDecodingContainer<CodingKeys>,
keys: [CodingKeys]
) -> [String]? {
for key in keys {
if let list = try? container.decode([String].self, forKey: key) {
return list
}
if let single = try? container.decode(String.self, forKey: key) {
let normalized = single.trimmingCharacters(in: .whitespacesAndNewlines)
if normalized.isEmpty == false {
return [normalized]
}
}
}
return nil
}
private static func decodeNps(
from container: KeyedDecodingContainer<CodingKeys>,
keys: [CodingKeys]
) -> Int? {
for key in keys {
if let value = try? container.decode(Int.self, forKey: key) {
return value
}
if let value = try? container.decode(Double.self, forKey: key) {
return Int(value.rounded())
}
if let raw = try? container.decode(String.self, forKey: key) {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { continue }
if let asInt = Int(trimmed) {
return asInt
}
let normalized = trimmed.replacingOccurrences(of: ",", with: ".")
if let asDouble = Double(normalized) {
return Int(asDouble.rounded())
}
}
}
return nil
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(id, forKey: .id)
try container.encodeIfPresent(orderId, forKey: .orderId)
try container.encodeIfPresent(rate, forKey: .rate)
try container.encodeIfPresent(message, forKey: .message)
try container.encodeIfPresent(orderRate, forKey: .orderRate)
try container.encodeIfPresent(orderComment, forKey: .orderComment)
try container.encodeIfPresent(orderPositiveTags, forKey: .orderPositiveTags)
try container.encodeIfPresent(orderImprovementTags, forKey: .orderImprovementTags)
try container.encodeIfPresent(deliverySentiment, forKey: .deliverySentiment)
try container.encodeIfPresent(deliveryPositiveTags, forKey: .deliveryPositiveTags)
try container.encodeIfPresent(deliveryNegativeTags, forKey: .deliveryNegativeTags)
try container.encodeIfPresent(appNps, forKey: .appNps)
try container.encodeIfPresent(platform, forKey: .platform)
try container.encodeIfPresent(date, forKey: .date)
}
}
struct PublicOrderItem: Codable, Identifiable {
let id: String
let productId: String?
let name: String?
let qty: Int?
let price: Double?
enum CodingKeys: String, CodingKey {
case id
case productId
case name
case qty
case quantity
case price
}
init(id: String = UUID().uuidString, productId: String? = nil, name: String?, qty: Int?, price: Double?) {
self.id = id
self.productId = productId
self.name = name
self.qty = qty
self.price = price
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString
productId = try? container.decode(String.self, forKey: .productId)
name = try? container.decode(String.self, forKey: .name)
qty = ApiService.decodeFlexibleInt(from: container, keys: [.qty, .quantity])
price = ApiService.decodeFlexibleDouble(from: container, keys: [.price])
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encodeIfPresent(productId, forKey: .productId)
try container.encodeIfPresent(name, forKey: .name)
try container.encodeIfPresent(qty, forKey: .qty)
try container.encodeIfPresent(price, forKey: .price)
}
}
struct PublicOrderTimelineEvent: Codable, Identifiable {
let id: String
let status: String?
let label: String?
let active: Bool?
let completed: Bool?
let message: String?
let time: String?
let date: String?
enum CodingKeys: String, CodingKey {
case id
case status
case label
case active
case completed
case message
case event
case time
case date
case createdAt
case updatedAt
}
init(
id: String = UUID().uuidString,
status: String?,
label: String? = nil,
active: Bool? = nil,
completed: Bool? = nil,
message: String?,
time: String?,
date: String? = nil
) {
self.id = id
self.status = status
self.label = label
self.active = active
self.completed = completed
self.message = message
self.time = time
self.date = date
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString
status = try? container.decode(String.self, forKey: .status)
label = try? container.decode(String.self, forKey: .label)
active = try? container.decode(Bool.self, forKey: .active)
completed = try? container.decode(Bool.self, forKey: .completed)
message = (try? container.decode(String.self, forKey: .message))
?? (try? container.decode(String.self, forKey: .event))
time = (try? container.decode(String.self, forKey: .time))
?? (try? container.decode(String.self, forKey: .createdAt))
?? (try? container.decode(String.self, forKey: .updatedAt))
date = try? container.decode(String.self, forKey: .date)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encodeIfPresent(status, forKey: .status)
try container.encodeIfPresent(label, forKey: .label)
try container.encodeIfPresent(active, forKey: .active)
try container.encodeIfPresent(completed, forKey: .completed)
try container.encodeIfPresent(message, forKey: .message)
try container.encodeIfPresent(time, forKey: .time)
try container.encodeIfPresent(date, forKey: .date)
}
}
struct OrderRealtimeUpdate: Decodable {
let id: String?
let shortId: String?
let storeId: String?
let userId: String?
let status: String?
let paymentStatus: String?
let updatedAt: String?
enum CodingKeys: String, CodingKey {
case id
case shortId
case storeId
case userId
case status
case paymentStatus
case updatedAt
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = ApiService.decodeFlexibleString(from: container, keys: [.id])
shortId = ApiService.decodeFlexibleString(from: container, keys: [.shortId])
storeId = ApiService.decodeFlexibleString(from: container, keys: [.storeId])
userId = ApiService.decodeFlexibleString(from: container, keys: [.userId])
status = ApiService.decodeFlexibleString(from: container, keys: [.status])
paymentStatus = ApiService.decodeFlexibleString(from: container, keys: [.paymentStatus])
updatedAt = ApiService.decodeFlexibleString(from: container, keys: [.updatedAt])
}
}
extension CreateOrderResult {
func asPublicOrderResult() -> PublicOrderResult {
PublicOrderResult(
id: id ?? UUID().uuidString,
shortId: shortId,
status: status,
paymentStatus: paymentStatus,
paymentConfirmed: paymentConfirmed,
paymentMethod: paymentMethod,
paymentPayload: paymentPayload,
payment: payment
)
}
}

View File

@@ -0,0 +1,80 @@
import Foundation
struct StorePizzaConfig: Decodable {
let sizes: [StorePizzaSize]
let doughs: [StorePizzaDough]
let crusts: [StorePizzaCrust]
enum CodingKeys: String, CodingKey {
case sizes
case doughs
case crusts
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
sizes = (try? container.decode([StorePizzaSize].self, forKey: .sizes)) ?? []
doughs = (try? container.decode([StorePizzaDough].self, forKey: .doughs)) ?? []
crusts = (try? container.decode([StorePizzaCrust].self, forKey: .crusts)) ?? []
}
}
struct StorePizzaSize: Decodable, Identifiable {
let id: String
let name: String?
let slices: Int?
let maxFlavors: Int?
enum CodingKeys: String, CodingKey {
case id
case name
case slices
case maxFlavors
}
}
struct StorePizzaDough: Decodable, Identifiable {
let id: String
let name: String?
let active: Bool?
enum CodingKeys: String, CodingKey {
case id
case name
case active
}
}
struct StorePizzaCrust: Decodable, Identifiable {
let id: String
let name: String?
let active: Bool?
let priceModifier: Double?
enum CodingKeys: String, CodingKey {
case id
case name
case active
case priceModifier
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString
name = try? container.decode(String.self, forKey: .name)
active = try? container.decode(Bool.self, forKey: .active)
if let value = try? container.decode(Double.self, forKey: .priceModifier) {
priceModifier = value
} else if let value = try? container.decode(Int.self, forKey: .priceModifier) {
priceModifier = Double(value)
} else if let value = try? container.decode(String.self, forKey: .priceModifier) {
let normalized = value
.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: ",", with: ".")
priceModifier = Double(normalized)
} else {
priceModifier = nil
}
}
}

View File

@@ -0,0 +1,268 @@
import Foundation
enum ReviewPlatform: String, Encodable {
case ios
case android
case web
static var current: ReviewPlatform {
#if os(iOS)
return .ios
#else
return .web
#endif
}
}
struct SubmitOrderReviewPayload: Encodable {
let rate: Int
let message: String
let orderRate: Int
let orderComment: String
let orderPositiveTags: [String]
let orderImprovementTags: [String]
let deliverySentiment: String
let deliveryPositiveTags: [String]
let deliveryNegativeTags: [String]
let appNps: Int
let platform: String
}
struct ReviewTagItem: Decodable, Hashable, Identifiable {
let id: String
let label: String
}
struct ReviewOrderTagRules: Decodable {
let positiveAllowedWhenRateGte: Int?
let improvementAllowedWhenRateLte: Int?
}
struct ReviewOrderTagsCatalog: Decodable {
let positive: [ReviewTagItem]
let improvement: [ReviewTagItem]
let rules: ReviewOrderTagRules?
}
struct ReviewDeliverySentimentRule: Decodable {
let id: String
let allowedTags: [String]
}
struct ReviewDeliveryTagsCatalog: Decodable {
let sentiments: [ReviewDeliverySentimentRule]
let positive: [ReviewTagItem]
let negative: [ReviewTagItem]
}
struct ReviewNpsCatalog: Decodable {
let min: Int?
let max: Int?
}
struct ReviewAppTagsCatalog: Decodable {
let nps: ReviewNpsCatalog?
let platforms: [String]?
}
struct ReviewTagsCatalog: Decodable {
let version: String?
let order: ReviewOrderTagsCatalog?
let delivery: ReviewDeliveryTagsCatalog?
let app: ReviewAppTagsCatalog?
}
struct SubmitOrderReviewResult: Decodable {
let id: String?
let storeId: String?
let userId: String?
let clientName: String?
let rate: Int?
let message: String?
let orderRate: Int?
let orderComment: String?
let deliverySentiment: String?
let orderPositiveTags: [String]?
let orderImprovementTags: [String]?
let deliveryPositiveTags: [String]?
let deliveryNegativeTags: [String]?
let appNps: Int?
let platform: String?
let orderId: String?
let date: String?
let editableUntil: String?
let storeReplyUntil: String?
let reviewWindowExpiresAt: String?
let storeReplyMessage: String?
let storeReplyAt: String?
enum CodingKeys: String, CodingKey {
case id
case storeId
case userId
case clientName
case rate
case message
case orderRate
case orderComment
case deliverySentiment
case deliveryFeedback
case itemFeedback
case improvementFeedback
case orderPositiveTags
case orderImprovementTags
case deliveryPositiveTags
case deliveryNegativeTags
case appNps
case app_nps
case platform
case orderId
case date
case editableUntil
case storeReplyUntil
case reviewWindowExpiresAt
case storeReply
case store_response
case storeResponse
case reply
case storeReplyMessage
case storeReplyAt
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = ApiService.decodeFlexibleString(from: container, keys: [.id])
storeId = ApiService.decodeFlexibleString(from: container, keys: [.storeId])
userId = ApiService.decodeFlexibleString(from: container, keys: [.userId])
clientName = ApiService.decodeFlexibleString(from: container, keys: [.clientName])
rate = ApiService.decodeFlexibleInt(from: container, keys: [.rate])
message = ApiService.decodeFlexibleString(from: container, keys: [.message])
orderRate = ApiService.decodeFlexibleInt(from: container, keys: [.orderRate, .rate])
orderComment = ApiService.decodeFlexibleString(from: container, keys: [.orderComment, .message])
deliverySentiment = ApiService.decodeFlexibleString(from: container, keys: [.deliverySentiment, .deliveryFeedback])
orderPositiveTags = Self.decodeStringList(from: container, keys: [.orderPositiveTags, .itemFeedback])
orderImprovementTags = Self.decodeStringList(from: container, keys: [.orderImprovementTags, .improvementFeedback])
deliveryPositiveTags = (try? container.decode([String].self, forKey: .deliveryPositiveTags)) ?? nil
deliveryNegativeTags = (try? container.decode([String].self, forKey: .deliveryNegativeTags)) ?? nil
appNps = Self.decodeNps(from: container, keys: [.appNps, .app_nps])
platform = ApiService.decodeFlexibleString(from: container, keys: [.platform])
orderId = ApiService.decodeFlexibleString(from: container, keys: [.orderId])
date = ApiService.decodeFlexibleString(from: container, keys: [.date])
editableUntil = ApiService.decodeFlexibleString(from: container, keys: [.editableUntil])
storeReplyUntil = ApiService.decodeFlexibleString(from: container, keys: [.storeReplyUntil])
reviewWindowExpiresAt = ApiService.decodeFlexibleString(from: container, keys: [.reviewWindowExpiresAt])
storeReplyMessage = Self.decodeReplyMessage(from: container)
storeReplyAt = Self.decodeReplyDate(from: container)
}
private static func decodeStringList(
from container: KeyedDecodingContainer<CodingKeys>,
keys: [CodingKeys]
) -> [String]? {
for key in keys {
if let list = try? container.decode([String].self, forKey: key) {
return list
}
if let single = try? container.decode(String.self, forKey: key) {
let normalized = single.trimmingCharacters(in: .whitespacesAndNewlines)
if normalized.isEmpty == false {
return [normalized]
}
}
}
return nil
}
private static func decodeNps(
from container: KeyedDecodingContainer<CodingKeys>,
keys: [CodingKeys]
) -> Int? {
for key in keys {
if let value = try? container.decode(Int.self, forKey: key) {
return value
}
if let value = try? container.decode(Double.self, forKey: key) {
return Int(value.rounded())
}
if let raw = try? container.decode(String.self, forKey: key) {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { continue }
if let asInt = Int(trimmed) {
return asInt
}
let normalized = trimmed.replacingOccurrences(of: ",", with: ".")
if let asDouble = Double(normalized) {
return Int(asDouble.rounded())
}
}
}
return nil
}
private static func decodeReplyMessage(from container: KeyedDecodingContainer<CodingKeys>) -> String? {
if let value = ApiService.decodeFlexibleString(from: container, keys: [.storeReplyMessage]) {
return value
}
if let value = ApiService.decodeFlexibleString(from: container, keys: [.storeReply, .store_response, .storeResponse, .reply]) {
return value
}
for key in [CodingKeys.storeReply, .store_response, .storeResponse, .reply] {
if let object = try? container.decode([String: String].self, forKey: key) {
let candidates = ["message", "text", "reply", "content", "body"]
for candidate in candidates {
let value = (object[candidate] ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if value.isEmpty == false { return value }
}
}
}
return nil
}
private static func decodeReplyDate(from container: KeyedDecodingContainer<CodingKeys>) -> String? {
if let value = ApiService.decodeFlexibleString(from: container, keys: [.storeReplyAt]) {
return value
}
for key in [CodingKeys.storeReply, .store_response, .storeResponse, .reply] {
if let object = try? container.decode([String: String].self, forKey: key) {
let candidates = ["date", "createdAt", "updatedAt", "repliedAt", "replyAt"]
for candidate in candidates {
let value = (object[candidate] ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if value.isEmpty == false { return value }
}
}
}
return nil
}
}
struct PublicStoreReviewsResult: Decodable {
let reviews: [SubmitOrderReviewResult]
enum CodingKeys: String, CodingKey {
case reviews
case data
case items
}
init(from decoder: Decoder) throws {
if let list = try? [SubmitOrderReviewResult](from: decoder) {
reviews = list
return
}
let container = try decoder.container(keyedBy: CodingKeys.self)
if let list = try? container.decode([SubmitOrderReviewResult].self, forKey: .reviews) {
reviews = list
return
}
if let list = try? container.decode([SubmitOrderReviewResult].self, forKey: .data) {
reviews = list
return
}
if let list = try? container.decode([SubmitOrderReviewResult].self, forKey: .items) {
reviews = list
return
}
reviews = []
}
}

View File

@@ -0,0 +1,650 @@
import Foundation
enum ApiServiceError: Error, LocalizedError {
case sessionExpired(String?)
var errorDescription: String? {
switch self {
case .sessionExpired(let message):
return message ?? "Sessao expirada. Faca login novamente."
}
}
}
struct ApiEnvelope<T: Decodable & Sendable>: Decodable, Sendable {
let error: Bool
let code: String?
let message: String?
let result: T?
}
final class ApiService {
private let client: ApiClient
private var tokenStore: TokenStore
private let profileCachePrefix = "api:profile:"
private let ordersCachePrefix = "api:orders:"
private let favoritesCachePrefix = "api:favorites:"
private let publicCategoriesCacheKey = "api:public-categories"
init(client: ApiClient = ApiClient(), tokenStore: TokenStore = DefaultTokenStore()) {
self.client = client
self.tokenStore = tokenStore
}
private func send<T: Decodable & Sendable>(_ req: ApiRequest) async throws -> T {
do {
return try await client.send(req)
} catch let error as NetworkError {
if canTriggerSessionExpiry(for: req), case .unauthorized(let message) = error {
expireSession(message)
throw ApiServiceError.sessionExpired(message)
}
throw error
}
}
private func sendEnvelope<T: Decodable>(_ req: ApiRequest) async throws -> ApiEnvelope<T> {
let envelope: ApiEnvelope<T> = try await send(req)
if canTriggerSessionExpiry(for: req), isSessionExpiredEnvelope(envelope) {
expireSession(envelope.message)
throw ApiServiceError.sessionExpired(envelope.message)
}
return envelope
}
/// A request that never carried the customer JWT (public/unauthenticated
/// calls) can never mean *the customer's* session expired an unrelated
/// error (e.g. Atomenta's module-token check) must not force-logout a
/// user, anonymous or not, just because its error code happens to
/// contain the substring "token". See app-migrate-atomenta-calls-to-pedifoods-bff.md.
private func canTriggerSessionExpiry(for req: ApiRequest) -> Bool {
req.requiresAuth && tokenStore.jwt != nil
}
private func isSessionExpiredEnvelope<T>(_ envelope: ApiEnvelope<T>) -> Bool {
guard envelope.error else { return false }
let code = (envelope.code ?? "").lowercased()
let message = (envelope.message ?? "").lowercased()
if code.contains("auth") || code.contains("token") || code.contains("unauthorized") {
return true
}
if message.contains("token") && (message.contains("expir") || message.contains("invalid") || message.contains("sess")) {
return true
}
return false
}
private func expireSession(_ message: String?) {
tokenStore.clear()
AppContentCache.shared.invalidate(prefix: profileCachePrefix)
AppContentCache.shared.invalidate(prefix: ordersCachePrefix)
AppContentCache.shared.invalidate(prefix: favoritesCachePrefix)
AppContentCache.shared.invalidate(prefix: publicCategoriesCacheKey)
NotificationCenter.default.post(name: .sessionExpired, object: message)
}
private func invalidateFavoritesCache() {
AppContentCache.shared.invalidate(prefix: profileCachePrefix)
AppContentCache.shared.invalidate(prefix: favoritesCachePrefix)
}
/// Every successful profile mutation must leave `profile()`'s cache
/// holding the server's authoritative post-mutation state never
/// patched locally from a write-response of possibly different shape,
/// and never left merely invalidated for some future caller to lazily
/// refetch (which may never happen, leaving stale data visible
/// indefinitely within the TTL). Always does a real GET.
@discardableResult
private func refreshProfileCache() async -> ApiEnvelope<CustomerProfile>? {
try? await profile(forceRefresh: true)
}
private func scopedCacheSuffix() -> String {
let jwt = tokenStore.jwt ?? "anonymous"
if jwt.count <= 16 { return jwt }
return String(jwt.prefix(16))
}
// MARK: - Auth
func registerCustomer(name: String, email: String, phoneNumber: String, birthDate: String? = nil) async throws -> ApiEnvelope<RegistrationResult> {
var payload: [String: String] = [
"name": name,
"email": email,
"phoneNumber": phoneNumber
]
if let birthDate, birthDate.isEmpty == false {
payload["birthDate"] = birthDate
}
// If the visitor picked a state/city via the public locator before
// signing up, forward it so the backend can set it as the account's
// default city. NOTE: as of this writing Atomenta's customer create
// controller only reads name/email/phoneNumber these two fields
// are a no-op server-side until that controller is updated to
// persist them (see docs/plans/app-migrate-atomenta-calls-to-pedifoods-bff.md).
if let state = GuestLocationStore.shared.selectedState, let city = GuestLocationStore.shared.selectedCity {
payload["defaultState"] = state
payload["defaultCity"] = city
}
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/customer", method: "POST", module: .customer, requiresAuth: false, body: body)
return try await sendEnvelope(req)
}
func requestOtp(email: String, phoneNumber: String) async throws -> ApiEnvelope<EmptyResult> {
let body = try makeLoginBody(email: email, phoneNumber: phoneNumber, otp: nil)
let req = ApiRequest(path: "/api/customer/login", method: "POST", module: .customer, requiresAuth: false, body: body)
return try await sendEnvelope(req)
}
func validateOtp(email: String, phoneNumber: String, otp: String) async throws -> ApiEnvelope<LoginResult> {
let body = try makeLoginBody(email: email, phoneNumber: phoneNumber, otp: otp)
let req = ApiRequest(path: "/api/customer/login", method: "POST", module: .customer, requiresAuth: false, body: body)
let response: ApiEnvelope<LoginResult> = try await sendEnvelope(req)
if let token = response.result?.token {
tokenStore.jwt = token
}
return response
}
private func makeLoginBody(email: String, phoneNumber: String, otp: String?) throws -> Data {
let sanitizedEmail = email.trimmingCharacters(in: .whitespacesAndNewlines)
let sanitizedPhone = phoneNumber.trimmingCharacters(in: .whitespacesAndNewlines)
guard sanitizedEmail.isEmpty == false, sanitizedPhone.isEmpty == false else {
throw NetworkError.httpError(400, "Email e telefone são obrigatórios.")
}
var payload: [String: String] = [
"email": sanitizedEmail,
"phoneNumber": sanitizedPhone,
"phone": sanitizedPhone
]
if let otp, otp.isEmpty == false {
payload["otp"] = otp
}
guard JSONSerialization.isValidJSONObject(payload) else {
throw NetworkError.invalidResponse
}
return try JSONSerialization.data(withJSONObject: payload, options: [])
}
func profile(forceRefresh: Bool = false) async throws -> ApiEnvelope<CustomerProfile> {
let cacheKey = "\(profileCachePrefix)\(scopedCacheSuffix())"
if forceRefresh == false,
let cached: ApiEnvelope<CustomerProfile> = AppContentCache.shared.value(for: cacheKey, as: ApiEnvelope<CustomerProfile>.self) {
return cached
}
let req = ApiRequest(path: "/api/customer/profile", method: "GET", module: .customer, requiresAuth: true)
let envelope: ApiEnvelope<CustomerProfile> = try await sendEnvelope(req)
if envelope.error == false, envelope.result != nil {
AppContentCache.shared.set(envelope, for: cacheKey, ttl: AppCacheTTL.twoHours)
}
return envelope
}
func updateCustomerProfile(name: String, email: String, phoneNumber: String, profilePicture: String?) async throws -> ProfilePatchEnvelope {
let payload = CustomerIdentityUpdatePayload(
name: name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : name,
email: email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : email,
phoneNumber: phoneNumber.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : phoneNumber,
profilePicture: profilePicture
)
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/customer/profile", method: "PATCH", module: .customer, requiresAuth: true, body: body)
let envelope: ProfilePatchEnvelope = try await send(req)
if envelope.error == false {
await refreshProfileCache()
}
return envelope
}
/// See docs/api/push-notifications-integration-guide.md §2b only reachable
/// via `POST /api/customer/:id` today, not `PATCH /profile`.
func updateNotificationsEnabled(_ enabled: Bool) async throws -> ApiEnvelope<CustomerProfile> {
let currentProfile = try await profile(forceRefresh: true)
guard currentProfile.error == false, let customer = currentProfile.result else {
throw NetworkError.invalidResponse
}
let payload = CustomerNotificationsUpdatePayload(notificationsEnabled: enabled)
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/customer/\(customer.id)", method: "POST", module: .customer, requiresAuth: true, body: body)
let envelope: ApiEnvelope<CustomerProfile> = try await sendEnvelope(req)
if envelope.error == false {
await refreshProfileCache()
}
return envelope
}
/// Persists the biometric-login preference only no LocalAuthentication
/// wiring yet, that's a separate later plan.
func updateFaceIdEnabled(_ enabled: Bool) async throws -> ApiEnvelope<CustomerProfile> {
let currentProfile = try await profile(forceRefresh: true)
guard currentProfile.error == false, let customer = currentProfile.result else {
throw NetworkError.invalidResponse
}
let payload = CustomerFaceIdUpdatePayload(faceIdEnabled: enabled)
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/customer/\(customer.id)", method: "POST", module: .customer, requiresAuth: true, body: body)
let envelope: ApiEnvelope<CustomerProfile> = try await sendEnvelope(req)
if envelope.error == false {
await refreshProfileCache()
}
return envelope
}
/// See docs/api/push-notifications-integration-guide.md §2.
func registerPushToken(_ token: String, deviceId: String, deviceOS: String) async throws -> ApiEnvelope<EmptyResult> {
let currentProfile = try await profile(forceRefresh: true)
guard currentProfile.error == false, let customer = currentProfile.result else {
throw NetworkError.invalidResponse
}
let payload = CustomerPushTokenPayload(pushToken: token, deviceId: deviceId, deviceOS: deviceOS)
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/customer/\(customer.id)/push-token", method: "PUT", module: .customer, requiresAuth: true, body: body)
return try await sendEnvelope(req)
}
/// See docs/api/push-notifications-integration-guide.md §2a. Wholesale
/// replace, not a merge callers must pass every `attributes` key they
/// still want kept, not just the changed ones.
func updateCustomerAttributes(appVersion: String?, attributes: [String: String]?) async throws -> ApiEnvelope<EmptyResult> {
let currentProfile = try await profile(forceRefresh: true)
guard currentProfile.error == false, let customer = currentProfile.result else {
throw NetworkError.invalidResponse
}
let payload = CustomerAttributesUpdatePayload(appVersion: appVersion, attributes: attributes)
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/customer/\(customer.id)/attributes", method: "PUT", module: .customer, requiresAuth: true, body: body)
return try await sendEnvelope(req)
}
/// See docs/api/push-notifications-integration-guide.md §6a. Fire on tap
/// only, for `type: "campaign"` pushes idempotent server-side.
func reportPushCampaignOpened(campaignId: String) async throws -> ApiEnvelope<PushCampaignOpenedResult> {
let currentProfile = try await profile(forceRefresh: true)
guard currentProfile.error == false, let customer = currentProfile.result else {
throw NetworkError.invalidResponse
}
let payload = PushCampaignOpenedPayload(campaignId: campaignId)
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/customer/\(customer.id)/push-campaigns/opened", method: "POST", module: .customer, requiresAuth: true, body: body)
return try await sendEnvelope(req)
}
func addCustomerAddress(_ address: CustomerAddress) async throws -> ApiEnvelope<CustomerProfile> {
return try await saveCustomerAddress(address, replacingAddressId: nil)
}
func saveCustomerAddress(_ address: CustomerAddress, replacingAddressId: String?) async throws -> ApiEnvelope<CustomerProfile> {
let currentProfile = try await profile(forceRefresh: true)
guard currentProfile.error == false, let customer = currentProfile.result else {
throw NetworkError.invalidResponse
}
let currentAddressBook = customer.addressBook ?? []
var addressBook = currentAddressBook.map(CustomerAddressPayload.init(from:))
let addressPayload = CustomerAddressPayload(from: address)
if let replacingAddressId,
let replaceIndex = currentAddressBook.firstIndex(where: { $0.id == replacingAddressId }) {
addressBook[replaceIndex] = addressPayload
} else {
addressBook.insert(addressPayload, at: 0)
}
return try await updateCustomerAddressBook(customerId: customer.id, addressBook: addressBook)
}
func deleteCustomerAddress(_ address: CustomerAddress) async throws -> ApiEnvelope<CustomerProfile> {
let currentProfile = try await profile(forceRefresh: true)
guard currentProfile.error == false, let customer = currentProfile.result else {
throw NetworkError.invalidResponse
}
var currentAddressBook = customer.addressBook ?? []
if let targetId = address.id, targetId.isEmpty == false {
if let index = currentAddressBook.firstIndex(where: { $0.id == targetId }) {
currentAddressBook.remove(at: index)
}
} else if let index = currentAddressBook.firstIndex(where: { matchesAddress($0, address) }) {
currentAddressBook.remove(at: index)
}
let updatedAddressBook = currentAddressBook.map(CustomerAddressPayload.init(from:))
return try await updateCustomerAddressBook(customerId: customer.id, addressBook: updatedAddressBook)
}
func setDefaultAddress(addressId: String) async throws -> ApiEnvelope<EmptyResult> {
let req = ApiRequest(path: "/api/customer/addresses/\(addressId)/default", method: "PATCH", module: .customer, requiresAuth: true)
let envelope: ApiEnvelope<EmptyResult> = try await sendEnvelope(req)
if envelope.error == false {
await refreshProfileCache()
}
return envelope
}
func lookupZipCode(_ zipCode: String) async throws -> ApiEnvelope<CepLookupResult> {
let digits = zipCode.filter(\.isNumber)
let normalized = String(digits.prefix(8))
let formatted: String
if normalized.count == 8 {
let prefix = String(normalized.prefix(5))
let suffix = String(normalized.dropFirst(5))
formatted = "\(prefix)-\(suffix)"
} else {
formatted = normalized
}
let req = ApiRequest(path: "/api/public/cep/\(formatted)", method: "GET", module: .none, requiresAuth: true)
return try await sendEnvelope(req)
}
private func updateCustomerAddressBook(customerId: String, addressBook: [CustomerAddressPayload]) async throws -> ApiEnvelope<CustomerProfile> {
let payload = CustomerProfileUpdatePayload(addressBook: addressBook)
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/customer/\(customerId)", method: "POST", module: .customer, requiresAuth: true, body: body)
let envelope: ApiEnvelope<CustomerProfile> = try await sendEnvelope(req)
if envelope.error == false {
await refreshProfileCache()
} else {
invalidateFavoritesCache()
}
return envelope
}
func listFavoriteStores(forceRefresh: Bool = false) async throws -> ApiEnvelope<[StoreSummary]> {
let cacheKey = "\(favoritesCachePrefix)\(scopedCacheSuffix())"
if forceRefresh == false,
let cached: ApiEnvelope<[StoreSummary]> = AppContentCache.shared.value(for: cacheKey, as: ApiEnvelope<[StoreSummary]>.self) {
return cached
}
let req = ApiRequest(path: "/api/customer/favorites", method: "GET", module: .customer, requiresAuth: true)
let envelope: ApiEnvelope<[StoreSummary]> = try await sendEnvelope(req)
if envelope.error == false, envelope.result != nil {
AppContentCache.shared.set(envelope, for: cacheKey, ttl: AppCacheTTL.twoHours)
}
return envelope
}
func addStoreToFavorites(storeId: String) async throws -> ApiEnvelope<CustomerFavoritesMutationResult> {
let req = ApiRequest(path: "/api/customer/favorites/\(storeId)", method: "POST", module: .customer, requiresAuth: true)
let envelope: ApiEnvelope<CustomerFavoritesMutationResult> = try await sendEnvelope(req)
if envelope.error == false {
invalidateFavoritesCache()
}
return envelope
}
func removeStoreFromFavorites(storeId: String) async throws -> ApiEnvelope<CustomerFavoritesMutationResult> {
let req = ApiRequest(path: "/api/customer/favorites/\(storeId)", method: "DELETE", module: .customer, requiresAuth: true)
let envelope: ApiEnvelope<CustomerFavoritesMutationResult> = try await sendEnvelope(req)
if envelope.error == false {
invalidateFavoritesCache()
}
return envelope
}
func setStoreFavorite(storeId: String, isFavorite: Bool) async throws -> ApiEnvelope<CustomerFavoritesMutationResult> {
if isFavorite {
return try await addStoreToFavorites(storeId: storeId)
}
return try await removeStoreFromFavorites(storeId: storeId)
}
private func matchesAddress(_ lhs: CustomerAddress, _ rhs: CustomerAddress) -> Bool {
lhs.label == rhs.label &&
lhs.address == rhs.address &&
lhs.number == rhs.number &&
lhs.complement == rhs.complement &&
lhs.neighborhood == rhs.neighborhood &&
lhs.city == rhs.city &&
lhs.state == rhs.state &&
lhs.zipCode == rhs.zipCode
}
// MARK: - Stores
func listPublicCategories(forceRefresh: Bool = false) async throws -> ApiEnvelope<[PublicCategory]> {
if forceRefresh == false,
let cached: ApiEnvelope<[PublicCategory]> = AppContentCache.shared.value(for: publicCategoriesCacheKey, as: ApiEnvelope<[PublicCategory]>.self) {
return cached
}
// Open endpoint, no guest session needed, but it lives on the BFF
// domain (pedifoods.com.br), not Atomenta see
// docs/plans/app-migrate-atomenta-calls-to-pedifoods-bff.md.
let req = ApiRequest(path: "/api/public/categories", method: "GET", module: .none, requiresAuth: false, baseURLOverride: ApiConfig.pediFoodsBFFURL)
let envelope: ApiEnvelope<[PublicCategory]> = try await sendEnvelope(req)
if envelope.error == false {
AppContentCache.shared.set(envelope, for: publicCategoriesCacheKey, ttl: AppCacheTTL.twoHours)
}
return envelope
}
func listStores(lat: Double? = nil, lng: Double? = nil, category: String? = nil, search: String? = nil) async throws -> ApiEnvelope<[StoreSummary]> {
var items: [URLQueryItem] = []
if let lat, let lng {
items.append(URLQueryItem(name: "lat", value: String(lat)))
items.append(URLQueryItem(name: "lng", value: String(lng)))
}
if let category {
items.append(URLQueryItem(name: "category", value: category))
}
if let search {
items.append(URLQueryItem(name: "search", value: search))
}
let req = ApiRequest(path: "/api/app/stores", method: "GET", module: .app, requiresAuth: true, queryItems: items)
return try await sendEnvelope(req)
}
func storeInfo(storeId: String) async throws -> ApiEnvelope<StoreInfoResult> {
let req = ApiRequest(path: "/api/store/\(storeId)/info", method: "GET", module: .store, requiresAuth: true)
return try await sendEnvelope(req)
}
func storeCatalog(storeId: String) async throws -> ApiEnvelope<[StoreCatalogCategory]> {
let req = ApiRequest(path: "/api/store/\(storeId)/catalog", method: "GET", module: .store, requiresAuth: true)
return try await sendEnvelope(req)
}
func createOrder(storeId: String, payload: CreateOrderPayload) async throws -> ApiEnvelope<CreateOrderResult> {
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/store/\(storeId)/orders", method: "POST", module: .store, requiresAuth: true, body: body)
let envelope: ApiEnvelope<CreateOrderResult> = try await sendEnvelope(req)
if envelope.error == false {
AppContentCache.shared.invalidate(prefix: ordersCachePrefix)
}
return envelope
}
func validateDeliveryAddress(storeId: String, payload: ValidateDeliveryAddressPayload) async throws -> ApiEnvelope<ValidateDeliveryAddressResult> {
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/store/\(storeId)/delivery/validate-address", method: "POST", module: .store, requiresAuth: true, body: body)
return try await sendEnvelope(req)
}
func listOrders(forceRefresh: Bool = false) async throws -> ApiEnvelope<[AppOrderSummary]> {
let cacheKey = "\(ordersCachePrefix)\(scopedCacheSuffix())"
if forceRefresh == false,
let cached: ApiEnvelope<[AppOrderSummary]> = AppContentCache.shared.value(for: cacheKey, as: ApiEnvelope<[AppOrderSummary]>.self) {
return cached
}
let req = ApiRequest(
path: "/api/app/orders",
method: "GET",
module: .app,
requiresAuth: true,
queryItems: [URLQueryItem(name: "_ts", value: String(Int(Date().timeIntervalSince1970)))]
)
let envelope: ApiEnvelope<[AppOrderSummary]> = try await sendEnvelope(req)
if envelope.error == false {
AppContentCache.shared.set(envelope, for: cacheKey, ttl: AppCacheTTL.twoHours)
}
return envelope
}
func publicOrder(orderId: String) async throws -> ApiEnvelope<PublicOrderResult> {
let req = ApiRequest(
path: "/api/public/orders/\(orderId)",
method: "GET",
module: .none,
requiresAuth: true,
queryItems: [URLQueryItem(name: "_ts", value: String(Int(Date().timeIntervalSince1970)))]
)
return try await sendEnvelope(req)
}
func submitOrderReview(orderId: String, payload: SubmitOrderReviewPayload) async throws -> ApiEnvelope<SubmitOrderReviewResult> {
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(
path: "/api/public/orders/\(orderId)/review",
method: "POST",
module: .none,
requiresAuth: true,
body: body
)
return try await sendEnvelope(req)
}
func reviewTagsCatalog() async throws -> ApiEnvelope<ReviewTagsCatalog> {
let req = ApiRequest(
path: "/api/public/reviews/tags",
method: "GET",
module: .none,
requiresAuth: false
)
return try await sendEnvelope(req)
}
func publicStoreReviews(storeId: String) async throws -> ApiEnvelope<PublicStoreReviewsResult> {
let req = ApiRequest(
path: "/api/public/store/\(storeId)/reviews",
method: "GET",
module: .none,
requiresAuth: false,
queryItems: [URLQueryItem(name: "_ts", value: String(Int(Date().timeIntervalSince1970)))]
)
return try await sendEnvelope(req)
}
// MARK: - Cards
func listCards() async throws -> ApiEnvelope<[SavedCard]> {
let req = ApiRequest(path: "/api/customer/cards", method: "GET", module: .customer, requiresAuth: true)
return try await sendEnvelope(req)
}
func saveCard(payload: SaveCardPayload) async throws -> ApiEnvelope<SavedCardResult> {
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/customer/cards", method: "POST", module: .customer, requiresAuth: true, body: body)
return try await sendEnvelope(req)
}
func updateCard(cardId: String, payload: UpdateCardPayload) async throws -> ApiEnvelope<SavedCardResult> {
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/customer/cards/\(cardId)", method: "PATCH", module: .customer, requiresAuth: true, body: body)
return try await sendEnvelope(req)
}
func deleteCard(cardId: String) async throws -> ApiEnvelope<EmptyResult> {
let req = ApiRequest(path: "/api/customer/cards/\(cardId)", method: "DELETE", module: .customer, requiresAuth: true)
return try await sendEnvelope(req)
}
func deleteAccount() async throws -> ApiEnvelope<EmptyResult> {
let req = ApiRequest(path: "/api/customer/account", method: "DELETE", module: .customer, requiresAuth: true)
return try await sendEnvelope(req)
}
func changePaymentMethod(storeId: String, orderId: String, payload: ChangePaymentMethodPayload) async throws -> ApiEnvelope<ChangePaymentMethodResult> {
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/store/\(storeId)/orders/\(orderId)/payment-method", method: "PATCH", module: .store, requiresAuth: true, body: body)
return try await sendEnvelope(req)
}
// MARK: - Profile CPF
func updateProfileCpf(cpf: String) async throws -> ApiEnvelope<EmptyResult> {
let payload = ["cpf": cpf]
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/customer/profile", method: "PATCH", module: .customer, requiresAuth: true, body: body)
let result: ApiEnvelope<EmptyResult> = try await sendEnvelope(req)
if result.error == false {
await refreshProfileCache()
}
return result
}
}
extension ApiService {
static func decodeFlexibleString<K: CodingKey>(from container: KeyedDecodingContainer<K>, keys: [K]) -> String? {
for key in keys {
if let value = try? container.decode(String.self, forKey: key) {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty == false {
return trimmed
}
}
if let asInt = try? container.decode(Int.self, forKey: key) {
return String(asInt)
}
if let asDouble = try? container.decode(Double.self, forKey: key) {
if asDouble.rounded() == asDouble {
return String(Int(asDouble))
}
return String(asDouble)
}
}
return nil
}
static func decodeFlexibleDouble<K: CodingKey>(from container: KeyedDecodingContainer<K>, keys: [K]) -> Double? {
for key in keys {
if let value = try? container.decode(Double.self, forKey: key) {
return value
}
if let asInt = try? container.decode(Int.self, forKey: key) {
return Double(asInt)
}
if let asString = try? container.decode(String.self, forKey: key) {
let normalized = asString
.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: ",", with: ".")
if let parsed = Double(normalized) {
return parsed
}
}
}
return nil
}
static func decodeFlexibleInt<K: CodingKey>(from container: KeyedDecodingContainer<K>, keys: [K]) -> Int? {
for key in keys {
if let value = try? container.decode(Int.self, forKey: key) {
return value
}
if let asDouble = try? container.decode(Double.self, forKey: key) {
return Int(asDouble)
}
if let asString = try? container.decode(String.self, forKey: key) {
let normalized = asString
.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: ".", with: "")
.replacingOccurrences(of: ",", with: "")
if let parsed = Int(normalized) {
return parsed
}
}
}
return nil
}
}

View File

@@ -0,0 +1,131 @@
import Foundation
import UIKit
typealias PlatformImage = UIImage
enum AppCacheTTL {
static let twoHours: TimeInterval = 2 * 60 * 60
static let homeStores: TimeInterval = 5 * 60
}
enum AppCacheKey {
static let homeStoresLatestSnapshot = "home-stores.latest.snapshot"
}
final class AppContentCache: @unchecked Sendable {
static let shared = AppContentCache()
private struct Entry {
let value: Any
let expiry: Date
}
private var entries: [String: Entry] = [:]
private let queue = DispatchQueue(label: "com.pedifoods.content-cache", qos: .userInitiated)
private init() {}
func value<T>(for key: String, as type: T.Type = T.self) -> T? {
queue.sync {
guard let entry = entries[key] else { return nil }
if entry.expiry <= Date() {
entries.removeValue(forKey: key)
return nil
}
return entry.value as? T
}
}
func set<T>(_ value: T, for key: String, ttl: TimeInterval) {
queue.sync {
entries[key] = Entry(value: value, expiry: Date().addingTimeInterval(ttl))
}
}
func invalidate(prefix: String? = nil) {
queue.sync {
guard let prefix, prefix.isEmpty == false else {
entries.removeAll()
return
}
let keys = entries.keys.filter { $0.hasPrefix(prefix) }
for key in keys {
entries.removeValue(forKey: key)
}
}
}
}
final class AppImageCache: @unchecked Sendable {
static let shared = AppImageCache()
private struct Entry {
let image: PlatformImage
let expiry: Date
}
private var entries: [String: Entry] = [:]
private let queue = DispatchQueue(label: "com.pedifoods.image-cache", qos: .userInitiated)
private init() {
configureURLCacheIfNeeded()
}
func image(for url: URL, ttl: TimeInterval, forceRefresh: Bool = false) async -> PlatformImage? {
let key = url.absoluteString
let now = Date()
if forceRefresh == false {
let cached = queue.sync { entries[key] }
if let cached, cached.expiry > now {
return cached.image
}
}
var request = URLRequest(url: url)
request.timeoutInterval = 20
request.cachePolicy = forceRefresh ? .reloadIgnoringLocalCacheData : .returnCacheDataElseLoad
if forceRefresh == false,
let diskCached = URLCache.shared.cachedResponse(for: request),
let image = platformImage(from: diskCached.data) {
queue.sync {
entries[key] = Entry(image: image, expiry: now.addingTimeInterval(ttl))
}
return image
}
do {
let (data, response) = try await URLSession.shared.data(for: request)
guard let image = platformImage(from: data) else { return nil }
URLCache.shared.storeCachedResponse(CachedURLResponse(response: response, data: data), for: request)
queue.sync {
entries[key] = Entry(image: image, expiry: now.addingTimeInterval(ttl))
}
return image
} catch {
return nil
}
}
func invalidateAll() {
queue.sync {
entries.removeAll()
}
URLCache.shared.removeAllCachedResponses()
}
private func configureURLCacheIfNeeded() {
let current = URLCache.shared
let minMemoryCapacity = 64 * 1024 * 1024
let minDiskCapacity = 256 * 1024 * 1024
if current.memoryCapacity < minMemoryCapacity || current.diskCapacity < minDiskCapacity {
URLCache.shared = URLCache(memoryCapacity: minMemoryCapacity, diskCapacity: minDiskCapacity)
}
}
private func platformImage(from data: Data) -> PlatformImage? {
UIImage(data: data)
}
}

View File

@@ -0,0 +1,278 @@
import Foundation
struct FeatureControlRawFlag: Codable, Equatable {
let enabled: Bool
let variant: String
let payload: FeatureControlJSONValue?
let reason: String?
}
enum FeatureControlJSONValue: Codable, Equatable {
case string(String)
case number(Double)
case bool(Bool)
case object([String: FeatureControlJSONValue])
case array([FeatureControlJSONValue])
case null
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if container.decodeNil() {
self = .null
} else if let value = try? container.decode(Bool.self) {
self = .bool(value)
} else if let value = try? container.decode(Double.self) {
self = .number(value)
} else if let value = try? container.decode(String.self) {
self = .string(value)
} else if let value = try? container.decode([String: FeatureControlJSONValue].self) {
self = .object(value)
} else if let value = try? container.decode([FeatureControlJSONValue].self) {
self = .array(value)
} else {
self = .null
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case .string(let value): try container.encode(value)
case .number(let value): try container.encode(value)
case .bool(let value): try container.encode(value)
case .object(let value): try container.encode(value)
case .array(let value): try container.encode(value)
case .null: try container.encodeNil()
}
}
}
private struct FeatureControlBootstrapRequest: Codable {
struct Context: Codable {
let subjectType: String
let subjectId: String
let storeId: String?
let platform: String
let appVersion: String
let attributes: [String: String]
}
let environment: String
let keys: [String]
let context: Context
}
private struct FeatureControlBootstrapResponse: Codable {
let ok: Bool
let source: String?
let configVersion: Int
let evaluatedAt: String?
let flags: [String: FeatureFlagValue]
let raw: [String: FeatureControlRawFlag]
}
private struct FeatureControlExposureRequest: Codable {
struct Event: Codable {
let featureKey: String
let variant: String
let subjectType: String
let storeId: String?
}
let events: [Event]
}
private struct FeatureControlCacheEntry: Codable {
let expiresAtUnixMs: Int64
let snapshot: FeatureFlagsState
}
struct FeatureControlEvaluationContext {
let subjectType: String
let subjectId: String
let storeId: String?
let attributes: [String: String]
}
@MainActor
final class FeatureControlService {
static let shared = FeatureControlService()
private let session: URLSession
private let cacheTTL: TimeInterval
private let decoder = JSONDecoder()
private let encoder = JSONEncoder()
private let userDefaults: UserDefaults
private let defaultsPrefix = "feature-control.cache.v1."
init(
session: URLSession = .shared,
cacheTTL: TimeInterval = 60,
userDefaults: UserDefaults = .standard
) {
self.session = session
self.cacheTTL = cacheTTL
self.userDefaults = userDefaults
}
func evaluate(
context: FeatureControlEvaluationContext,
jwt: String?,
forceRefresh: Bool = false
) async -> FeatureFlagsState {
let key = storageKey(for: context)
if forceRefresh == false, let cached = loadFromCache(storageKey: key) {
return cached
}
let requestBody = FeatureControlBootstrapRequest(
environment: ApiConfig.featureControlEnvironment,
keys: featureKeys(),
context: .init(
subjectType: context.subjectType,
subjectId: context.subjectId,
storeId: context.storeId,
platform: platformName(),
appVersion: appVersion(),
attributes: context.attributes
)
)
do {
let response = try await performBootstrapRequest(body: requestBody, jwt: jwt)
let snapshot = FeatureFlagsState(
configVersion: response.configVersion,
evaluatedAt: response.evaluatedAt,
source: response.source ?? "live",
values: response.flags,
raw: response.raw
)
saveToCache(snapshot: snapshot, storageKey: key)
return snapshot
} catch {
if let cached = loadFromCache(storageKey: key) {
return FeatureFlagsState(
configVersion: cached.configVersion,
evaluatedAt: cached.evaluatedAt,
source: "cache_fallback",
values: cached.values,
raw: cached.raw
)
}
return FeatureFlagsState(source: "defaults")
}
}
func sendExposureEvents(snapshot: FeatureFlagsState, context: FeatureControlEvaluationContext, jwt: String?) async {
guard snapshot.raw.isEmpty == false else { return }
let events = snapshot.raw.compactMap { entry -> FeatureControlExposureRequest.Event? in
let key = entry.key
let value = entry.value
guard value.enabled || value.variant.lowercased() != "off" else { return nil }
return .init(
featureKey: key,
variant: value.variant,
subjectType: context.subjectType,
storeId: context.storeId
)
}
guard events.isEmpty == false else { return }
let batched = Array(events.prefix(100))
let payload = FeatureControlExposureRequest(events: batched)
guard let body = try? encoder.encode(payload) else { return }
var request = URLRequest(url: ApiConfig.featureControlBffURL.appendingPathComponent("/feature-control/telemetry/exposure"))
request.httpMethod = "POST"
request.httpBody = body
request.timeoutInterval = 3
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
if let jwt, jwt.isEmpty == false {
request.setValue("Bearer \(jwt)", forHTTPHeaderField: "Authorization")
}
_ = try? await session.data(for: request)
}
private func performBootstrapRequest(body: FeatureControlBootstrapRequest, jwt: String?) async throws -> FeatureControlBootstrapResponse {
var request = URLRequest(url: ApiConfig.featureControlBffURL.appendingPathComponent("/feature-control/bootstrap"))
request.httpMethod = "POST"
request.httpBody = try encoder.encode(body)
request.timeoutInterval = 3
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
if let jwt, jwt.isEmpty == false {
request.setValue("Bearer \(jwt)", forHTTPHeaderField: "Authorization")
}
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
throw NetworkError.invalidResponse
}
return try decoder.decode(FeatureControlBootstrapResponse.self, from: data)
}
private func storageKey(for context: FeatureControlEvaluationContext) -> String {
let tokens = [
ApiConfig.featureControlEnvironment,
context.subjectType,
context.subjectId,
context.storeId ?? "none",
platformName(),
appVersion(),
featureKeys().joined(separator: "|")
]
let base = tokens.joined(separator: "::")
.lowercased()
.replacingOccurrences(of: " ", with: "_")
return defaultsPrefix + base
}
private func saveToCache(snapshot: FeatureFlagsState, storageKey: String) {
let expiresAt = Int64((Date().timeIntervalSince1970 + cacheTTL) * 1000)
let entry = FeatureControlCacheEntry(expiresAtUnixMs: expiresAt, snapshot: snapshot)
guard let data = try? encoder.encode(entry) else { return }
userDefaults.set(data, forKey: storageKey)
}
private func loadFromCache(storageKey: String) -> FeatureFlagsState? {
guard let data = userDefaults.data(forKey: storageKey),
let entry = try? decoder.decode(FeatureControlCacheEntry.self, from: data) else {
return nil
}
let now = Int64(Date().timeIntervalSince1970 * 1000)
guard entry.expiresAtUnixMs > now else {
userDefaults.removeObject(forKey: storageKey)
return nil
}
return entry.snapshot
}
private func featureKeys() -> [String] {
let raw = ProcessInfo.processInfo.environment["FEATURE_CONTROL_KEYS"] ?? "at.city.aguai,at.promo,at.ios.only,at.android.only,at.cupons"
let items = raw
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { $0.isEmpty == false }
return items.isEmpty ? ["at.ios.only"] : items
}
private func platformName() -> String {
return "ios"
}
private func appVersion() -> String {
let version = (Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
if let version, version.isEmpty == false {
return version
}
return "0.0.0"
}
}

View File

@@ -0,0 +1,77 @@
import Foundation
/// Local (device-only) state for the pre-login public store locator
/// see docs/plans/app-migrate-atomenta-calls-to-pedifoods-bff.md.
final class GuestLocationStore: @unchecked Sendable {
static let shared = GuestLocationStore()
private let serviceName = "com.br.pedifoods.app.guest"
private let deviceIdKey = "device_id"
private let attestationKey = "attestation"
private let attestKeyIdKey = "attest_key_id"
private let stateKey = "selected_state"
private let cityKey = "selected_city"
/// Stable per-install identifier sent as `deviceId` in the guest handshake.
var deviceId: String {
if let existing = KeychainStore.load(service: serviceName, key: deviceIdKey) {
return existing
}
let generated = UUID().uuidString
KeychainStore.save(generated, service: serviceName, key: deviceIdKey)
return generated
}
/// Fallback-only placeholder (backend just checks non-empty) for
/// environments that can't run real App Attest Simulator, or non-iOS.
/// Real devices use DCAppAttestService via GuestSessionService instead.
var attestationPlaceholder: String {
if let existing = KeychainStore.load(service: serviceName, key: attestationKey) {
return existing
}
let generated = UUID().uuidString
KeychainStore.save(generated, service: serviceName, key: attestationKey)
return generated
}
/// App Attest key ID already registered with the backend for this
/// device, if any. Present -> use it to sign assertions; absent -> this
/// device needs to attest a freshly generated key first.
var appAttestKeyId: String? {
get { KeychainStore.load(service: serviceName, key: attestKeyIdKey) }
set {
if let newValue {
KeychainStore.save(newValue, service: serviceName, key: attestKeyIdKey)
} else {
KeychainStore.delete(service: serviceName, key: attestKeyIdKey)
}
}
}
var selectedState: String? {
get { KeychainStore.load(service: serviceName, key: stateKey) }
set {
if let newValue {
KeychainStore.save(newValue, service: serviceName, key: stateKey)
} else {
KeychainStore.delete(service: serviceName, key: stateKey)
}
}
}
var selectedCity: String? {
get { KeychainStore.load(service: serviceName, key: cityKey) }
set {
if let newValue {
KeychainStore.save(newValue, service: serviceName, key: cityKey)
} else {
KeychainStore.delete(service: serviceName, key: cityKey)
}
}
}
func clearSelectedLocation() {
KeychainStore.delete(service: serviceName, key: stateKey)
KeychainStore.delete(service: serviceName, key: cityKey)
}
}

View File

@@ -0,0 +1,251 @@
import Foundation
#if os(iOS)
import DeviceCheck
import CryptoKit
#endif
private struct ChallengeResult: Decodable, Sendable {
let challenge: String
}
/// Fields differ by handshake step Optional properties are omitted from
/// the encoded JSON entirely (Codable synthesis uses encodeIfPresent), so
/// this one struct covers both the fresh-attestation and assertion payloads.
private struct GuestSessionAttestPayload: Encodable, Sendable {
let platform: String
let deviceId: String
let challenge: String
let keyId: String?
let attestation: String?
let assertion: String?
}
private struct GuestSessionPlaceholderPayload: Encodable, Sendable {
let platform: String
let deviceId: String
let attestation: String
}
/// Issues and caches the short-lived (15min) guest JWT used by the pre-login
/// public store locator. Deliberately separate from ApiService/TokenStore
/// a guest-session 401 must never be treated as the customer session
/// expiring (see docs/plans/app-migrate-atomenta-calls-to-pedifoods-bff.md).
///
/// iOS uses real App Attest (2-call protocol: challenge, then
/// attest-a-fresh-key the first time per device or sign-an-assertion with
/// the already-registered key every time after). If the server doesn't
/// recognize a previously-registered key (e.g. it lost its in-memory
/// registration), the assertion call fails and this falls back to
/// re-attesting with a brand new key rather than leaving the guest stuck.
actor GuestSessionService {
static let shared = GuestSessionService()
private var cachedToken: String?
private var expiresAt: Date?
private let client: ApiClient
private let store: GuestLocationStore
// Actors are reentrant across `await` points without this, two
// concurrent callers with no cached token yet (very possible: the
// location picker and Home's guest store load can both need a guest
// session near launch) would each independently run the App Attest
// flow, racing each other's key registration against the server. This
// makes every caller share the one in-flight handshake instead.
private var inFlightRefresh: Task<String, Error>?
init(client: ApiClient = ApiClient(), store: GuestLocationStore = .shared) {
self.client = client
self.store = store
}
func validToken() async throws -> String {
if let cachedToken, let expiresAt, expiresAt > Date() {
return cachedToken
}
return try await refreshToken()
}
/// Forces a fresh handshake, used for the silent retry-on-401 flow.
func invalidateAndRefresh() async throws -> String {
cachedToken = nil
expiresAt = nil
inFlightRefresh = nil
return try await refreshToken()
}
private func refreshToken() async throws -> String {
if let inFlightRefresh {
return try await inFlightRefresh.value
}
let task = Task { try await performRefresh() }
inFlightRefresh = task
defer { inFlightRefresh = nil }
return try await task.value
}
private func performRefresh() async throws -> String {
#if os(iOS)
return try await refreshTokenWithAppAttest()
#else
return try await refreshTokenWithPlaceholder()
#endif
}
#if os(iOS)
private func refreshTokenWithAppAttest() async throws -> String {
print("[GuestSessionService] DCAppAttestService.isSupported = \(DCAppAttestService.shared.isSupported), existingKeyId = \(store.appAttestKeyId ?? "nil")")
guard DCAppAttestService.shared.isSupported else {
// Simulator can never support App Attest (hardware limitation,
// not environment-specific) server has its own documented
// bypass for this case, gated by an admin toggle server-side.
let challenge = try await fetchChallenge()
return try await handshakeWithSimulatorBypass(challenge: challenge)
}
if let existingKeyId = store.appAttestKeyId {
do {
let challenge = try await fetchChallenge()
return try await handshakeWithAssertion(keyId: existingKeyId, challenge: challenge)
} catch let error as NetworkError where isKeyRejectedByServer(error) {
// Server explicitly rejected this key (403
// APP_ATTEST_VERIFICATION_FAILED e.g. it lost the
// credential registration) re-attest with a new key. Any
// other error (network blip, timeout, decode issue) must
// NOT wipe a perfectly valid registered key.
store.appAttestKeyId = nil
} catch let error as DCError {
// DeviceCheck itself rejects the key locally (e.g. the app
// was reinstalled and the Secure Enclave key backing this
// keyId no longer exists) - distinct from the server
// rejecting it, but equally unrecoverable without a fresh
// key. Without this, generateAssertion fails the same way
// forever since appAttestKeyId is never cleared.
print("[GuestSessionService] existing key rejected locally, re-attesting with a fresh key: \(error)")
store.appAttestKeyId = nil
}
}
let challenge = try await fetchChallenge()
return try await handshakeWithFreshAttestation(challenge: challenge)
}
private func isKeyRejectedByServer(_ error: NetworkError) -> Bool {
if case .httpError(403, _) = error { return true }
return false
}
private func fetchChallenge() async throws -> String {
let req = ApiRequest(
path: "/api/public/attest/challenge",
method: "GET",
module: .none,
requiresAuth: false,
queryItems: [URLQueryItem(name: "deviceId", value: store.deviceId)],
baseURLOverride: ApiConfig.pediFoodsBFFURL
)
let envelope: ApiEnvelope<ChallengeResult> = try await client.send(req)
guard envelope.error == false, let result = envelope.result else {
throw NetworkError.invalidResponse
}
return result.challenge
}
private func handshakeWithFreshAttestation(challenge: String) async throws -> String {
let keyId: String
do {
keyId = try await DCAppAttestService.shared.generateKey()
} catch {
print("[GuestSessionService] generateKey failed: \(error)")
throw error
}
let clientDataHash = Data(SHA256.hash(data: Data(challenge.utf8)))
let attestationObject: Data
do {
attestationObject = try await DCAppAttestService.shared.attestKey(keyId, clientDataHash: clientDataHash)
} catch {
print("[GuestSessionService] attestKey failed: \(error)")
throw error
}
let payload = GuestSessionAttestPayload(
platform: "ios",
deviceId: store.deviceId,
challenge: challenge,
keyId: keyId,
attestation: attestationObject.base64EncodedString(),
assertion: nil
)
let token = try await sendSessionRequest(body: JSONEncoder().encode(payload))
store.appAttestKeyId = keyId
return token
}
/// Simulator can never run real App Attest server accepts this literal
/// bypass value instead, gated by its own admin toggle (403
/// SIMULATOR_BYPASS_DISABLED if that toggle is off; not a client bug).
private func handshakeWithSimulatorBypass(challenge: String) async throws -> String {
let payload = GuestSessionAttestPayload(
platform: "ios",
deviceId: store.deviceId,
challenge: challenge,
keyId: nil,
attestation: "SIMULATOR_BYPASS",
assertion: nil
)
return try await sendSessionRequest(body: JSONEncoder().encode(payload))
}
private func handshakeWithAssertion(keyId: String, challenge: String) async throws -> String {
let clientDataHash = Data(SHA256.hash(data: Data(challenge.utf8)))
let assertionObject: Data
do {
assertionObject = try await DCAppAttestService.shared.generateAssertion(keyId, clientDataHash: clientDataHash)
} catch {
print("[GuestSessionService] generateAssertion failed: \(error)")
throw error
}
let payload = GuestSessionAttestPayload(
platform: "ios",
deviceId: store.deviceId,
challenge: challenge,
keyId: nil,
attestation: nil,
assertion: assertionObject.base64EncodedString()
)
return try await sendSessionRequest(body: JSONEncoder().encode(payload))
}
#endif
/// Only reachable on non-iOS builds (this app's macOS target is test-only,
/// never a real distribution target no documented contract for it, so
/// this stays a best-effort placeholder rather than matching a real spec.
private func refreshTokenWithPlaceholder() async throws -> String {
let payload = GuestSessionPlaceholderPayload(
platform: "ios",
deviceId: store.deviceId,
attestation: store.attestationPlaceholder
)
return try await sendSessionRequest(body: JSONEncoder().encode(payload))
}
private func sendSessionRequest(body: Data) async throws -> String {
let req = ApiRequest(
path: "/api/public/session",
method: "POST",
module: .none,
requiresAuth: false,
body: body,
baseURLOverride: ApiConfig.pediFoodsBFFURL
)
let envelope: ApiEnvelope<GuestSessionResult> = try await client.send(req)
guard envelope.error == false, let result = envelope.result else {
throw NetworkError.invalidResponse
}
cachedToken = result.guestToken
// Refresh a bit early so a request started near expiry doesn't race the server's own clock.
expiresAt = Date().addingTimeInterval(TimeInterval(result.expiresIn) - 30)
return result.guestToken
}
}

View File

@@ -0,0 +1,44 @@
import Foundation
enum ImageSourceResolver {
static func resolve(_ raw: String?) -> String? {
guard var normalized = raw?.trimmingCharacters(in: .whitespacesAndNewlines),
normalized.isEmpty == false else { return nil }
normalized = normalized.replacingOccurrences(of: "\\/", with: "/")
let lower = normalized.lowercased()
if lower.hasPrefix("http://") || lower.hasPrefix("https://") || lower.hasPrefix("data:image") {
return normalized
}
// if let base64DataURL = normalizedBase64DataURL(normalized) {
// return base64DataURL
// }
let base = ApiConfig.baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
let path = normalized.hasPrefix("/") ? normalized : "/\(normalized)"
return "\(base)\(path)"
}
private static func normalizedBase64DataURL(_ raw: String) -> String? {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.isEmpty == false else { return nil }
let payload: String
if let marker = trimmed.range(of: "base64,", options: [.caseInsensitive]) {
payload = String(trimmed[marker.upperBound...])
} else {
payload = trimmed
}
let sanitized = payload
.replacingOccurrences(of: "\n", with: "")
.replacingOccurrences(of: "\r", with: "")
.replacingOccurrences(of: " ", with: "")
guard sanitized.count >= 64 else { return nil }
guard Data(base64Encoded: sanitized, options: [.ignoreUnknownCharacters]) != nil else { return nil }
return "data:image/png;base64,\(sanitized)"
}
}

View File

@@ -0,0 +1,60 @@
import Foundation
#if os(iOS)
import Security
#endif
/// Small generic Keychain wrapper (iOS) with a UserDefaults fallback on other
/// platforms (macOS test target), namespaced by `service`+`key`.
enum KeychainStore {
static func save(_ value: String, service: String, key: String) {
#if os(iOS)
guard let data = value.data(using: .utf8) else { return }
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key
]
SecItemDelete(query as CFDictionary)
var attributes = query
attributes[kSecValueData as String] = data
SecItemAdd(attributes as CFDictionary, nil)
#else
UserDefaults.standard.set(value, forKey: "\(service).\(key)")
#endif
}
static func load(service: String, key: String) -> String? {
#if os(iOS)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess,
let data = result as? Data,
let value = String(data: data, encoding: .utf8) else {
return nil
}
return value
#else
UserDefaults.standard.string(forKey: "\(service).\(key)")
#endif
}
static func delete(service: String, key: String) {
#if os(iOS)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key
]
SecItemDelete(query as CFDictionary)
#else
UserDefaults.standard.removeObject(forKey: "\(service).\(key)")
#endif
}
}

View File

@@ -0,0 +1,193 @@
import Foundation
#if os(iOS)
import CoreLocation
/// The subset of `CLLocationManager` `LocationService` needs lets tests
/// substitute a fake instead of touching real hardware/OS permission state.
@MainActor
protocol LocationManaging: AnyObject {
var locationManagingDelegate: CLLocationManagerDelegate? { get set }
var desiredAccuracy: CLLocationAccuracy { get set }
var authorizationStatus: CLAuthorizationStatus { get }
var location: CLLocation? { get }
func requestWhenInUseAuthorization()
func requestLocation()
}
extension CLLocationManager: LocationManaging {
var locationManagingDelegate: CLLocationManagerDelegate? {
get { delegate }
set { delegate = newValue }
}
}
#endif
@MainActor
final class LocationService: NSObject {
typealias LocationResult = Result<(Double, Double), LocationError>
static let shared = LocationService()
#if os(iOS)
enum LocationError: Error {
case servicesDisabled
case denied
case unavailable
}
#else
enum LocationError: Error {
case denied
case unavailable
}
#endif
#if os(iOS)
private let manager: LocationManaging
private var completion: ((LocationResult) -> Void)?
#endif
#if os(iOS)
init(manager: LocationManaging = CLLocationManager()) {
self.manager = manager
super.init()
manager.locationManagingDelegate = self
manager.desiredAccuracy = kCLLocationAccuracyHundredMeters
}
#else
override init() {
super.init()
}
#endif
func requestLocation(_ completion: @escaping (LocationResult) -> Void) {
#if os(iOS)
self.completion = completion
handleAuthorizationStatus(manager.authorizationStatus)
#else
let defaults = UserDefaults.standard
if defaults.bool(forKey: "location_permission_denied") {
completion(.failure(.denied))
return
}
guard let latRaw = defaults.string(forKey: "last_location_lat"),
let lngRaw = defaults.string(forKey: "last_location_lng"),
let lat = Double(latRaw),
let lng = Double(lngRaw) else {
completion(.failure(.unavailable))
return
}
completion(.success((lat, lng)))
#endif
}
func cachedLocation() -> (Double, Double)? {
#if os(iOS)
guard let location = manager.location else {
return nil
}
return (location.coordinate.latitude, location.coordinate.longitude)
#else
let defaults = UserDefaults.standard
guard let latRaw = defaults.string(forKey: "last_location_lat"),
let lngRaw = defaults.string(forKey: "last_location_lng"),
let lat = Double(latRaw),
let lng = Double(lngRaw) else {
return nil
}
return (lat, lng)
#endif
}
/// Forward-geocodes a street address into coordinates. Used as a fallback
/// when a saved CustomerAddress has no lat/long (e.g. the CEP lookup at
/// creation time didn't return coordinates) without this, delivery fee
/// validation silently can't distinguish that address from any other.
static func geocodeAddress(
street: String?,
number: String?,
neighborhood: String?,
city: String?,
state: String?,
zip: String?
) async -> (Double, Double)? {
#if os(iOS)
let parts = [street, number, neighborhood, city, state, zip]
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { $0.isEmpty == false }
guard parts.isEmpty == false else { return nil }
let fullAddress = parts.joined(separator: ", ")
return await withCheckedContinuation { continuation in
CLGeocoder().geocodeAddressString(fullAddress) { placemarks, error in
guard error == nil, let coordinate = placemarks?.first?.location?.coordinate else {
continuation.resume(returning: nil)
return
}
continuation.resume(returning: (coordinate.latitude, coordinate.longitude))
}
}
#else
return nil
#endif
}
func requestLocationAsync(timeoutSeconds: TimeInterval = 8) async -> (Double, Double)? {
await withCheckedContinuation { continuation in
var hasResumed = false
func resumeOnce(_ value: (Double, Double)?) {
guard hasResumed == false else { return }
hasResumed = true
continuation.resume(returning: value)
}
requestLocation { result in
switch result {
case .success(let coordinate):
resumeOnce(coordinate)
case .failure:
resumeOnce(nil)
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + timeoutSeconds) {
resumeOnce(nil)
}
}
}
}
#if os(iOS)
extension LocationService: @preconcurrency CLLocationManagerDelegate {
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
handleAuthorizationStatus(manager.authorizationStatus)
}
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let location = locations.first else { return }
completion?(.success((location.coordinate.latitude, location.coordinate.longitude)))
completion = nil
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
completion?(.failure(.unavailable))
completion = nil
}
private func handleAuthorizationStatus(_ status: CLAuthorizationStatus) {
switch status {
case .notDetermined:
manager.requestWhenInUseAuthorization()
case .authorizedAlways, .authorizedWhenInUse:
manager.requestLocation()
case .denied, .restricted:
completion?(.failure(.denied))
completion = nil
@unknown default:
completion?(.failure(.unavailable))
completion = nil
}
}
}
#endif

View File

@@ -0,0 +1,250 @@
import Foundation
@MainActor
final class OrderRealtimeTracker {
// Keep realtime tracking on polling to avoid socket.io handshake failures
// on environments where websocket upgrade is not available.
private let useSocketRealtime = false
private var pollingTask: Task<Void, Never>? = nil
private var socketClient: OrderSocketClient? = nil
private var activeOrderId: String? = nil
var onOrderUpdated: ((PublicOrderResult) -> Void)?
func start(orderId: String, jwt: String?) {
stop()
activeOrderId = orderId
pollingTask = Task { @MainActor [weak self] in
guard let self else { return }
await self.runPollingLoop(orderId: orderId)
}
guard useSocketRealtime, let jwt, jwt.isEmpty == false else { return }
let socket = OrderSocketClient()
socket.onOrderUpdate = { [weak self] update in
guard let self else { return }
guard update.id == orderId else { return }
Task { @MainActor [weak self] in
guard let self else { return }
await self.fetchLatest(orderId: orderId)
}
}
socket.connect(jwt: jwt)
socketClient = socket
}
func stop() {
pollingTask?.cancel()
pollingTask = nil
socketClient?.disconnect()
socketClient = nil
activeOrderId = nil
}
private func runPollingLoop(orderId: String) async {
var elapsedSeconds = 0
while Task.isCancelled == false {
if activeOrderId != orderId { return }
let fetched = await fetchLatest(orderId: orderId)
if fetched?.isFinalStatus == true {
return
}
let delay = pollingDelay(for: elapsedSeconds)
elapsedSeconds += delay
do {
try await Task.sleep(nanoseconds: UInt64(delay) * 1_000_000_000)
} catch {
return
}
}
}
private func pollingDelay(for elapsedSeconds: Int) -> Int {
if elapsedSeconds < 60 { return 3 }
if elapsedSeconds < 180 { return 5 }
return 10
}
@discardableResult
private func fetchLatest(orderId: String) async -> PublicOrderResult? {
do {
logger.debug("OrderTracking poll request orderId=\(orderId)")
let response = try await ApiService().publicOrder(orderId: orderId)
guard response.error == false, let order = response.result else {
logger.error("OrderTracking poll API error orderId=\(orderId) message=\(response.message ?? "unknown")")
return nil
}
clearPendingCartIfNeeded(for: order)
logger.info("OrderTracking poll success orderId=\(orderId) status=\(order.status ?? "nil") paymentStatus=\(order.paymentStatus ?? "nil")")
onOrderUpdated?(order)
return order
} catch {
logger.error("OrderTracking poll failure orderId=\(orderId) error=\(error.localizedDescription)")
return nil
}
}
private func clearPendingCartIfNeeded(for order: PublicOrderResult) {
guard let pendingId = SessionStateStore.loadPendingCartOrderId() else { return }
let normalizedPending = pendingId.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if normalizedPending.isEmpty { return }
let ids = [order.id, order.realId]
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
guard ids.contains(normalizedPending) else { return }
if shouldClearCart(for: order) == false { return }
SessionStateStore.clearCart()
SessionStateStore.clearPendingCartOrder()
NotificationCenter.default.post(name: .cartDidReset, object: nil)
}
private func shouldClearCart(for order: PublicOrderResult) -> Bool {
if order.isPaymentConfirmed {
return true
}
let status = (order.status ?? "").uppercased()
if status.contains("COMPLETED") || status.contains("DELIVERED") || status.contains("RECEIVED") {
return true
}
return false
}
}
final class OrderSocketClient: @unchecked Sendable {
var onOrderUpdate: ((OrderRealtimeUpdate) -> Void)?
#if os(iOS) || os(macOS)
private var task: URLSessionWebSocketTask? = nil
private let session = URLSession(configuration: .default)
private var isConnected = false
private var pendingJWT: String? = nil
#endif
func connect(jwt: String) {
#if os(iOS) || os(macOS)
disconnect()
guard let url = makeSocketURL() else { return }
let wsTask = session.webSocketTask(with: url)
wsTask.resume()
task = wsTask
pendingJWT = jwt
receiveLoop()
#else
_ = jwt
#endif
}
func disconnect() {
#if os(iOS) || os(macOS)
isConnected = false
pendingJWT = nil
task?.cancel(with: .goingAway, reason: nil)
task = nil
#endif
}
#if os(iOS) || os(macOS)
private func receiveLoop() {
guard let task else { return }
task.receive { [weak self] result in
guard let self else { return }
switch result {
case .failure:
self.disconnect()
case .success(let message):
self.handleMessage(message)
self.receiveLoop()
}
}
}
private func handleMessage(_ message: URLSessionWebSocketTask.Message) {
let text: String
switch message {
case .string(let value):
text = value
case .data(let data):
text = String(data: data, encoding: .utf8) ?? ""
@unknown default:
return
}
guard text.isEmpty == false else { return }
if text == "2" {
task?.send(.string("3")) { _ in }
return
}
if text.hasPrefix("0"), let jwt = pendingJWT {
let authPacket = "40{\"token\":\"Bearer \(jwt)\"}"
task?.send(.string(authPacket)) { _ in }
pendingJWT = nil
return
}
if text.hasPrefix("40") {
isConnected = true
return
}
guard text.hasPrefix("42") else { return }
let eventPayload = String(text.dropFirst(2))
guard let data = eventPayload.data(using: .utf8) else { return }
if let rawArray = try? JSONSerialization.jsonObject(with: data) as? [Any],
rawArray.count >= 2,
let eventName = rawArray[0] as? String,
eventName == "order_update" {
let payloadAny = rawArray[1]
guard JSONSerialization.isValidJSONObject(payloadAny),
let payloadData = try? JSONSerialization.data(withJSONObject: payloadAny),
let update = try? JSONDecoder().decode(OrderRealtimeUpdate.self, from: payloadData) else {
return
}
onOrderUpdate?(update)
return
}
// Compat: alguns servidores podem encapsular o evento como objeto.
if let rawObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let eventName = (rawObject["event"] as? String)?.lowercased(),
eventName == "order_update",
let payloadAny = rawObject["data"],
JSONSerialization.isValidJSONObject(payloadAny),
let payloadData = try? JSONSerialization.data(withJSONObject: payloadAny),
let update = try? JSONDecoder().decode(OrderRealtimeUpdate.self, from: payloadData) {
onOrderUpdate?(update)
}
}
private func makeSocketURL() -> URL? {
guard var components = URLComponents(url: ApiConfig.baseURL, resolvingAgainstBaseURL: false) else {
return nil
}
if components.scheme == "https" {
components.scheme = "wss"
} else {
components.scheme = "ws"
}
components.path = "/socket.io/"
components.queryItems = [
URLQueryItem(name: "EIO", value: "4"),
URLQueryItem(name: "transport", value: "websocket")
]
return components.url
}
#endif
}

View File

@@ -0,0 +1,126 @@
import Foundation
// DTOs for the pre-login public store locator (pedifoods.com.br BFF).
// See docs/plans/app-migrate-atomenta-calls-to-pedifoods-bff.md for the
// exact contract these mirror.
struct GuestSessionResult: Decodable, Sendable {
let guestToken: String
let expiresIn: Int
}
/// `GET /api/public/locations` returns states+cities in one call, keyed by
/// state UF with an array of city names e.g. `{"SP": ["Aguaí", "Campinas"]}`.
typealias PublicLocationsResult = [String: [String]]
struct PublicStoreListItem: Decodable, Sendable, Identifiable {
let id: String
let storeId: String?
let name: String?
let logo: String?
let cover: String?
let category: String?
let isOpen: Bool?
let statusLabel: String?
let nextOpenLabel: String?
let rating: Double?
let totalReviews: Int?
let deliveryTime: String?
let deliveryFee: Double?
let minOrder: Double?
/// Kilometres from the picked city's centroid to the store - always
/// approximate, since the origin is the city rather than the visitor.
/// The server sends `0` for "unavailable" and never null; optional here
/// only so the app keeps decoding responses from before the field
/// shipped. See docs/plans/public-store-distance-consumer.md.
let distance: Double?
}
struct PublicStoreDetail: Decodable, Sendable {
let id: String
let storeId: String?
let slug: String?
let fantasyName: String?
let razaoSocial: String?
let logo: String?
let cover: String?
let specialty: String?
let phone: String?
let responsiblePhone: String?
let address: String?
let neighborhood: String?
let city: String?
let state: String?
let zipcode: String?
let isOpen: Bool?
let statusLabel: String?
let nextOpenLabel: String?
let averageRate: Double?
let totalReviews: Int?
let deliveryTime: String?
let deliveryFee: Double?
let deliveryPrice: Double?
let minOrder: Double?
let acceptPix: Bool?
/// Same contract as `PublicStoreListItem.distance`, but only populated
/// when the detail request carries `state`/`city`.
let distance: Double?
}
// Maps the public (anonymous) store-detail projection onto the same models
// StoreDetailView already renders for authenticated users, so the view
// itself doesn't need to know which source the data came from.
extension StoreInfoResult {
init(publicDetail: PublicStoreDetail) {
isOpen = publicDetail.isOpen
statusLabel = publicDetail.statusLabel
fantasyName = publicDetail.fantasyName
phone = publicDetail.phone
whatsapp = nil
logo = publicDetail.logo
cover = publicDetail.cover
deliveryTime = publicDetail.deliveryTime
minOrder = publicDetail.minOrder
address = StoreAddressInfo(publicDetail: publicDetail)
paymentMethods = StorePaymentMethodsInfo(acceptPix: publicDetail.acceptPix)
}
}
extension StoreAddressInfo {
init(publicDetail: PublicStoreDetail) {
street = publicDetail.address
number = nil
neighborhood = publicDetail.neighborhood
city = publicDetail.city
state = publicDetail.state
zipCode = publicDetail.zipcode
latitude = nil
longitude = nil
}
}
extension StorePaymentMethodsInfo {
/// The public projection only exposes whether Pix is accepted every
/// other payment flag is unknown until the user is authenticated and can
/// see it via the real store-info call.
init(acceptPix: Bool?) {
paymentOnDelivery = nil
paymentOnPickup = nil
self.acceptPix = acceptPix
acceptCash = nil
acceptCreditCard = nil
acceptDebitCard = nil
acceptCreditVisa = nil
acceptCreditMaster = nil
acceptCreditElo = nil
acceptCreditAmex = nil
acceptCreditHipercard = nil
acceptDebitVisa = nil
acceptDebitMaster = nil
acceptDebitElo = nil
acceptVoucherAlelo = nil
acceptVoucherSodexo = nil
acceptVoucherTicket = nil
acceptVoucherVR = nil
}
}

View File

@@ -0,0 +1,128 @@
import Foundation
/// Pre-login public store locator states/cities/stores-by-location, and
/// store detail. Always talks to `pedifoods.com.br` (the BFF), never
/// `atomenta.com.br` directly. See
/// docs/plans/app-migrate-atomenta-calls-to-pedifoods-bff.md.
///
/// Deliberately does not go through ApiService.sendEnvelope that treats
/// any session-expired-shaped response as the *customer* session expiring
/// (clears TokenStore, forces logout). A guest-session 401 here means the
/// short-lived guest JWT expired and must be silently refreshed instead.
final class PublicLocationService: @unchecked Sendable {
static let shared = PublicLocationService()
private let client: ApiClient
private let guestSession: GuestSessionService
init(client: ApiClient = ApiClient(), guestSession: GuestSessionService = .shared) {
self.client = client
self.guestSession = guestSession
}
/// States + their cities in one call keyed by UF, e.g. `{"SP": [...]}`.
func fetchLocations() async throws -> PublicLocationsResult {
try await sendGuestAuthed(path: "/api/public/locations")
}
func fetchStores(state: String, city: String) async throws -> [PublicStoreListItem] {
let query = [
URLQueryItem(name: "state", value: state),
URLQueryItem(name: "city", value: city)
]
return try await sendGuestAuthed(path: "/api/public/stores/by-location", query: query)
}
/// No guest token this route is fully public/unauthenticated per the doc.
///
/// `state`/`city` are optional on the server and are what let it resolve
/// the city centroid the store `distance` is measured from; without them
/// the response carries `distance: 0` (see
/// docs/plans/public-store-distance-consumer.md). Defaults to whatever the
/// visitor picked in "ENTREGAR EM:".
func fetchStoreDetail(
identifier: String,
state: String? = GuestLocationStore.shared.selectedState,
city: String? = GuestLocationStore.shared.selectedCity
) async throws -> PublicStoreDetail {
let encodedIdentifier = identifier.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? identifier
var query: [URLQueryItem] = []
if let state, state.isEmpty == false {
query.append(URLQueryItem(name: "state", value: state))
}
if let city, city.isEmpty == false {
query.append(URLQueryItem(name: "city", value: city))
}
let req = ApiRequest(
path: "/api/public/store/\(encodedIdentifier)",
method: "GET",
module: .none,
requiresAuth: false,
queryItems: query,
baseURLOverride: ApiConfig.pediFoodsBFFURL
)
let envelope: ApiEnvelope<PublicStoreDetail> = try await client.send(req)
guard envelope.error == false, let result = envelope.result else {
throw NetworkError.invalidResponse
}
return result
}
/// No guest token same open pattern as fetchStoreDetail. Reuses the
/// existing StoreCatalogCategory/StoreCatalogProduct models directly:
/// the public response is the same category+products shape (with extra
/// computed inventory/stockStatus fields the decoder just ignores).
func fetchStoreProducts(storeId: String) async throws -> [StoreCatalogCategory] {
let encodedStoreId = storeId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? storeId
let req = ApiRequest(
path: "/api/public/store/\(encodedStoreId)/products",
method: "GET",
module: .none,
requiresAuth: false,
baseURLOverride: ApiConfig.pediFoodsBFFURL
)
let envelope: ApiEnvelope<[StoreCatalogCategory]> = try await client.send(req)
guard envelope.error == false, let result = envelope.result else {
throw NetworkError.invalidResponse
}
return result
}
private func sendGuestAuthed<T: Decodable & Sendable>(path: String, query: [URLQueryItem] = []) async throws -> T {
let token = try await guestSession.validToken()
do {
return try await performGuestRequest(path: path, query: query, token: token)
} catch let error as NetworkError where isGuestSessionExpired(error) {
let refreshed = try await guestSession.invalidateAndRefresh()
return try await performGuestRequest(path: path, query: query, token: refreshed)
}
}
private func performGuestRequest<T: Decodable & Sendable>(path: String, query: [URLQueryItem], token: String) async throws -> T {
let req = ApiRequest(
path: path,
method: "GET",
module: .none,
requiresAuth: false,
queryItems: query,
baseURLOverride: ApiConfig.pediFoodsBFFURL,
customBearerToken: token
)
let envelope: ApiEnvelope<T> = try await client.send(req)
guard envelope.error == false, let result = envelope.result else {
throw NetworkError.invalidResponse
}
return result
}
private func isGuestSessionExpired(_ error: NetworkError) -> Bool {
switch error {
case .unauthorized:
return true
case .httpError(let statusCode, _):
return statusCode == 401
default:
return false
}
}
}

View File

@@ -0,0 +1,220 @@
import Foundation
/// Client-side half of docs/api/push-notifications-integration-guide.md.
/// Owns OS permission state, APNs device-token registration, the
/// "enable notifications" action shared by the profile toggle and the
/// order-tracking fallback prompt (§2b), foreground/tap notification
/// handling (§6), and campaign open tracking (§6a).
enum PushAuthorizationState {
case authorized
case denied
case notDetermined
}
/// Posted when a tapped push resolves to a navigable `DeepLinkDestination`
/// (§6) so `ContentView` can route without this service depending on
/// `AppState`. One name for every destination, present and future see
/// `DeepLinkDestination`.
extension Notification.Name {
static let pushDeepLinkReceived = Notification.Name("pushDeepLinkReceived")
}
#if os(iOS)
import UIKit
import UserNotifications
@MainActor
final class PushNotificationCoordinator: NSObject {
static let shared = PushNotificationCoordinator()
private var deviceTokenObserver: NSObjectProtocol?
private var didBecomeDelegate = false
private override init() {}
/// Call once at app launch. Listens for the device token `PediFoodsAppDelegate`
/// posts after `registerForRemoteNotifications()` resolves, and forwards it
/// to Atomenta (§2).
func startObservingDeviceToken() {
guard deviceTokenObserver == nil else { return }
deviceTokenObserver = NotificationCenter.default.addObserver(
forName: NSNotification.Name("didRegisterForRemoteNotificationsWithDeviceToken"),
object: nil,
queue: .main
) { notification in
guard let data = notification.userInfo?["deviceToken"] as? Data else { return }
let hexToken = data.map { String(format: "%02x", $0) }.joined()
Task { await PushNotificationCoordinator.shared.sendTokenToBackend(hexToken) }
}
}
/// Call once at app launch, before the first notification could possibly
/// arrive makes this the `UNUserNotificationCenterDelegate` so foreground
/// pushes actually display (§6) and taps get routed/tracked (§6, §6a).
func becomeNotificationCenterDelegate() {
guard didBecomeDelegate == false else { return }
didBecomeDelegate = true
UNUserNotificationCenter.current().delegate = self
}
/// Re-registers silently (no OS prompt) if the user already granted
/// authorization in a previous session tokens aren't guaranteed stable
/// across launches (§2, §3.3/§4.2 of the guide). Safe to call before login.
func refreshRegistrationIfAuthorized() async {
guard await currentAuthorizationState() == .authorized else { return }
UIApplication.shared.registerForRemoteNotifications()
}
func currentAuthorizationState() async -> PushAuthorizationState {
let settings = await UNUserNotificationCenter.current().notificationSettings()
switch settings.authorizationStatus {
case .authorized, .provisional, .ephemeral:
return .authorized
case .denied:
return .denied
case .notDetermined:
return .notDetermined
@unknown default:
return .notDetermined
}
}
/// Shows the OS permission dialog only if it hasn't been answered yet.
/// Always calls `registerForRemoteNotifications()` when authorized
/// including when authorization was already granted in a past session
/// so "enable" reliably produces a fresh device token this run instead of
/// relying solely on the once-per-launch refresh.
@discardableResult
private func requestAuthorizationIfNeeded() async -> Bool {
switch await currentAuthorizationState() {
case .authorized:
UIApplication.shared.registerForRemoteNotifications()
return true
case .denied:
return false
case .notDetermined:
let granted = (try? await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound])) ?? false
if granted {
UIApplication.shared.registerForRemoteNotifications()
}
return granted
}
}
/// Shared body of §2b's two touchpoints: request OS permission (if
/// undetermined), then flip Atomenta's `notificationsEnabled` flag.
/// Returns the server's authoritative post-update profile callers must
/// reflect `result.notificationsEnabled` from this, not assume `true`
/// just because the request succeeded.
func enableNotifications() async -> CustomerProfile? {
guard await requestAuthorizationIfNeeded() else { return nil }
do {
let response = try await ApiService().updateNotificationsEnabled(true)
return response.error == false ? response.result : nil
} catch {
logger.error("Failed to enable push notifications: \(error.localizedDescription)")
return nil
}
}
/// See docs/api/push-notifications-integration-guide.md §2a. Best-effort,
/// silent on failure Campaign `appVersion`/`attributes` targeting just
/// won't match this user until the next successful call. Call right after
/// login and once per app launch (covers an app update since last launch).
func syncCustomerAttributes() async {
guard DefaultTokenStore().jwt != nil else { return }
do {
_ = try await ApiService().updateCustomerAttributes(appVersion: currentAppVersion(), attributes: nil)
} catch {
logger.error("Failed to sync customer attributes: \(error.localizedDescription)")
}
}
private func currentAppVersion() -> String {
(Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? "0.0.0"
}
private func sendTokenToBackend(_ hexToken: String) async {
guard DefaultTokenStore().jwt != nil else { return }
do {
_ = try await ApiService().registerPushToken(hexToken, deviceId: GuestLocationStore.shared.deviceId, deviceOS: "ios")
} catch {
logger.error("Push token registration failed: \(error.localizedDescription)")
}
}
/// §6a fire on tap only, for `type: "campaign"` pushes. Idempotent
/// server-side, so no client-side "already reported" guard needed.
private func reportCampaignOpened(campaignId: String) async {
guard DefaultTokenStore().jwt != nil else { return }
do {
_ = try await ApiService().reportPushCampaignOpened(campaignId: campaignId)
} catch {
logger.error("Failed to report campaign open: \(error.localizedDescription)")
}
}
/// §6 routes on the tapped push's `data` payload. Campaign-open
/// reporting (§6a, a side effect, not a navigation target) is decided
/// directly on `type` here; navigation is delegated to
/// `PushDeepLinkParser` and forwarded to `ContentView` via
/// `NotificationCenter` (this service has no `AppState` binding of its
/// own). A push can do both e.g. a future campaign that also sets
/// `targetScreen` reports its open *and* navigates.
fileprivate func handleTap(userInfo: [AnyHashable: Any]) {
if let type = userInfo["type"] as? String, type == "campaign",
let campaignId = userInfo["campaignId"] as? String {
Task { await reportCampaignOpened(campaignId: campaignId) }
}
guard let destination = PushDeepLinkParser.parse(userInfo) else { return }
NotificationCenter.default.post(name: .pushDeepLinkReceived, object: nil, userInfo: ["destination": destination])
}
}
/// `UNNotification.userInfo` is `[AnyHashable: Any]`, which the compiler
/// can't prove `Sendable` but it's an immutable payload handed to us
/// once by the OS, so crossing the actor boundary with it is safe in
/// practice.
private struct UncheckedSendableBox<Value>: @unchecked Sendable {
let value: Value
}
extension PushNotificationCoordinator: UNUserNotificationCenterDelegate {
/// Without a delegate, iOS silently drops push notifications while the
/// app is foregrounded this is what makes them display as a banner too.
nonisolated func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
completionHandler([.banner, .list, .sound, .badge])
}
nonisolated func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
let userInfo = UncheckedSendableBox(value: response.notification.request.content.userInfo)
Task { @MainActor in
PushNotificationCoordinator.shared.handleTap(userInfo: userInfo.value)
}
completionHandler()
}
}
#else
@MainActor
final class PushNotificationCoordinator {
static let shared = PushNotificationCoordinator()
private init() {}
func startObservingDeviceToken() {}
func becomeNotificationCenterDelegate() {}
func refreshRegistrationIfAuthorized() async {}
func currentAuthorizationState() async -> PushAuthorizationState { .denied }
func enableNotifications() async -> CustomerProfile? { nil }
func syncCustomerAttributes() async {}
}
#endif

View File

@@ -0,0 +1,8 @@
import Foundation
extension Notification.Name {
static let sessionExpired = Notification.Name("SessionExpiredNotification")
static let cartDidReset = Notification.Name("CartDidResetNotification")
static let orderReviewDidSave = Notification.Name("OrderReviewDidSaveNotification")
static let appDidResume = Notification.Name("AppDidResumeNotification")
}

View File

@@ -0,0 +1,456 @@
import Foundation
private struct PersistedAddressState: Codable {
let selectedId: String?
let display: String
let latitude: Double?
let longitude: Double?
}
private struct PersistedCartAddonState: Codable {
let id: String
let name: String
let quantity: Int
let unitPrice: Double
}
private struct PersistedCartItemState: Codable {
let id: String
let productId: String
let storeId: String
let name: String
let imageURL: String?
let details: String?
let addons: [PersistedCartAddonState]
let quantity: Int
let unitPrice: Double
}
private struct PersistedCartState: Codable {
let storeId: String?
let storeName: String?
let items: [PersistedCartItemState]
let total: Double
}
private struct PersistedTrackedOrdersState: Codable {
let orders: [PublicOrderResult]
}
struct OrderReviewRecord: Codable, Identifiable, Hashable {
var id: String { orderId }
let orderId: String
let storeId: String?
let shortId: String?
let storeName: String?
let storeLogoURL: String?
let createdAt: String?
let submittedAt: String
let rating: Int
let comment: String
let orderPositiveTags: [String]?
let orderImprovementTags: [String]?
let deliverySentiment: String?
let deliveryPositiveTags: [String]?
let deliveryNegativeTags: [String]?
let appNps: Int?
let platform: String?
let editableUntil: String?
let storeReplyUntil: String?
let reviewWindowExpiresAt: String?
let storeReplyMessage: String?
let storeReplyAt: String?
}
struct OrderReviewDraftState: Codable, Hashable {
var orderId: String
var orderRate: Int
var orderComment: String
var orderPositiveTags: [String]
var orderImprovementTags: [String]
var deliverySentiment: String
var deliveryPositiveTags: [String]
var deliveryNegativeTags: [String]
var appNps: Int
var platform: String
}
private struct PersistedOrderReviewsState: Codable {
let reviews: [OrderReviewRecord]
}
enum SessionStateStore {
/// Swappable for tests (isolated `UserDefaults(suiteName:)`), defaults
/// to the real app defaults in production. `UserDefaults` is thread-safe
/// on its own; `nonisolated(unsafe)` only opts out of Swift 6's static-
/// mutable-state check for the var itself, tests set it once up front.
nonisolated(unsafe) static var defaults: UserDefaults = .standard
private static let legacyAddressKey = "session.address.state.v1"
private static let addressKeyPrefix = "session.address.state.v2."
private static let activeUserKey = "session.active.user.v1"
private static let cartKeyPrefix = "session.cart.state.v1."
private static let trackedOrdersKeyPrefix = "session.orders.tracking.v1."
private static let pendingCartOrderKeyPrefix = "session.cart.pending-order.v1."
private static let orderReviewsKeyPrefix = "session.orders.reviews.v1."
private static let orderReviewDraftKeyPrefix = "session.orders.review-draft.v1."
private static let pushOptInPromptKey = "session.push.opt-in.last-prompted.v1"
private static let pushOptInCooldown: TimeInterval = 60 * 60 * 24
/// See docs/api/push-notifications-integration-guide.md §2b avoid
/// re-prompting the order-tracking fallback alert on every screen visit.
static func shouldPromptPushOptIn() -> Bool {
guard let last = defaults.object(forKey: pushOptInPromptKey) as? Date else { return true }
return Date().timeIntervalSince(last) > pushOptInCooldown
}
static func recordPushOptInPrompted() {
defaults.set(Date(), forKey: pushOptInPromptKey)
}
static func makeUserKey(profileId: String?, email: String?) -> String? {
let id = (profileId ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
if id.isEmpty == false {
return "id:\(id)"
}
let mail = (email ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
if mail.isEmpty == false {
return "email:\(mail)"
}
return nil
}
static func setActiveUserKey(_ userKey: String?) {
let trimmed = (userKey ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
defaults.removeObject(forKey: activeUserKey)
} else {
defaults.set(trimmed, forKey: activeUserKey)
}
}
static func loadActiveUserKey() -> String? {
let value = defaults.string(forKey: activeUserKey)?
.trimmingCharacters(in: .whitespacesAndNewlines)
if let value, value.isEmpty == false {
return value
}
return nil
}
private static func addressStorageKey(for userKey: String?) -> String {
let key = (userKey ?? loadActiveUserKey() ?? "anonymous")
.trimmingCharacters(in: .whitespacesAndNewlines)
let safe = key.replacingOccurrences(of: " ", with: "_")
return addressKeyPrefix + safe
}
static func loadAddress() -> AddressState? {
let activeKey = loadActiveUserKey()
let scopedKey = addressStorageKey(for: activeKey)
if let data = defaults.data(forKey: scopedKey),
let decoded = try? JSONDecoder().decode(PersistedAddressState.self, from: data) {
return AddressState(
selectedId: decoded.selectedId,
display: decoded.display.isEmpty ? "Defina seu endereco" : decoded.display,
latitude: decoded.latitude,
longitude: decoded.longitude,
onboardingMessage: nil
)
}
// Backward-compatible fallback for data persisted before user scoping.
let anonymousKey = addressStorageKey(for: "anonymous")
if let data = defaults.data(forKey: anonymousKey),
let decoded = try? JSONDecoder().decode(PersistedAddressState.self, from: data) {
let recovered = AddressState(
selectedId: decoded.selectedId,
display: decoded.display.isEmpty ? "Defina seu endereco" : decoded.display,
latitude: decoded.latitude,
longitude: decoded.longitude,
onboardingMessage: nil
)
// Migrate anonymous cache into the current active user namespace.
if let activeKey, activeKey.isEmpty == false {
let payload = PersistedAddressState(
selectedId: recovered.selectedId,
display: recovered.display,
latitude: recovered.latitude,
longitude: recovered.longitude
)
if let migratedData = try? JSONEncoder().encode(payload) {
defaults.set(migratedData, forKey: scopedKey)
}
}
return recovered
}
guard let data = defaults.data(forKey: legacyAddressKey),
let decoded = try? JSONDecoder().decode(PersistedAddressState.self, from: data) else {
return nil
}
let migrated = AddressState(
selectedId: decoded.selectedId,
display: decoded.display.isEmpty ? "Defina seu endereco" : decoded.display,
latitude: decoded.latitude,
longitude: decoded.longitude,
onboardingMessage: nil
)
saveAddress(migrated)
defaults.removeObject(forKey: legacyAddressKey)
return migrated
}
static func saveAddress(_ state: AddressState) {
let payload = PersistedAddressState(
selectedId: state.selectedId,
display: state.display,
latitude: state.latitude,
longitude: state.longitude
)
guard let data = try? JSONEncoder().encode(payload) else { return }
defaults.set(data, forKey: addressStorageKey(for: nil))
}
static func clearAddress() {
defaults.removeObject(forKey: addressStorageKey(for: nil))
defaults.removeObject(forKey: legacyAddressKey)
}
static func clearActiveUser() {
defaults.removeObject(forKey: activeUserKey)
}
private static func cartStorageKey(for userKey: String?) -> String {
let key = (userKey ?? loadActiveUserKey() ?? "anonymous")
.trimmingCharacters(in: .whitespacesAndNewlines)
let safe = key.replacingOccurrences(of: " ", with: "_")
return cartKeyPrefix + safe
}
static func loadCart() -> CartState? {
let key = cartStorageKey(for: nil)
if let data = defaults.data(forKey: key),
let decoded = try? JSONDecoder().decode(PersistedCartState.self, from: data) {
return CartState(
storeId: decoded.storeId,
storeName: decoded.storeName,
items: decoded.items.map {
CartItemState(
id: $0.id,
productId: $0.productId,
storeId: $0.storeId,
name: $0.name,
imageURL: $0.imageURL,
details: $0.details,
addons: $0.addons.map {
CartItemAddonState(
id: $0.id,
name: $0.name,
quantity: $0.quantity,
unitPrice: $0.unitPrice
)
},
quantity: $0.quantity,
unitPrice: $0.unitPrice
)
},
total: decoded.total
)
}
return nil
}
static func saveCart(_ state: CartState) {
let payload = PersistedCartState(
storeId: state.storeId,
storeName: state.storeName,
items: state.items.map {
PersistedCartItemState(
id: $0.id,
productId: $0.productId,
storeId: $0.storeId,
name: $0.name,
imageURL: $0.imageURL,
details: $0.details,
addons: $0.addons.map {
PersistedCartAddonState(
id: $0.id,
name: $0.name,
quantity: $0.quantity,
unitPrice: $0.unitPrice
)
},
quantity: $0.quantity,
unitPrice: $0.unitPrice
)
},
total: state.total
)
guard let data = try? JSONEncoder().encode(payload) else { return }
defaults.set(data, forKey: cartStorageKey(for: nil))
}
static func clearCart() {
defaults.removeObject(forKey: cartStorageKey(for: nil))
}
private static func trackedOrdersStorageKey(for userKey: String?) -> String {
let key = (userKey ?? loadActiveUserKey() ?? "anonymous")
.trimmingCharacters(in: .whitespacesAndNewlines)
let safe = key.replacingOccurrences(of: " ", with: "_")
return trackedOrdersKeyPrefix + safe
}
private static func pendingCartOrderStorageKey(for userKey: String?) -> String {
let key = (userKey ?? loadActiveUserKey() ?? "anonymous")
.trimmingCharacters(in: .whitespacesAndNewlines)
let safe = key.replacingOccurrences(of: " ", with: "_")
return pendingCartOrderKeyPrefix + safe
}
private static func orderReviewsStorageKey(for userKey: String?) -> String {
let key = (userKey ?? loadActiveUserKey() ?? "anonymous")
.trimmingCharacters(in: .whitespacesAndNewlines)
let safe = key.replacingOccurrences(of: " ", with: "_")
return orderReviewsKeyPrefix + safe
}
private static func orderReviewDraftStorageKey(for orderId: String, userKey: String?) -> String {
let scope = (userKey ?? loadActiveUserKey() ?? "anonymous")
.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: " ", with: "_")
let id = orderId
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
return "\(orderReviewDraftKeyPrefix)\(scope).\(id)"
}
static func loadTrackedOrders() -> [PublicOrderResult] {
let key = trackedOrdersStorageKey(for: nil)
guard let data = defaults.data(forKey: key),
let decoded = try? JSONDecoder().decode(PersistedTrackedOrdersState.self, from: data) else {
return []
}
return decoded.orders
}
static func loadTrackedOrder(orderId: String) -> PublicOrderResult? {
loadTrackedOrders().first(where: { $0.id == orderId })
}
static func saveTrackedOrder(_ order: PublicOrderResult) {
var orders = loadTrackedOrders()
if let index = orders.firstIndex(where: { $0.id == order.id }) {
orders[index] = order
} else {
orders.insert(order, at: 0)
}
if orders.count > 60 {
orders = Array(orders.prefix(60))
}
let payload = PersistedTrackedOrdersState(orders: orders)
guard let data = try? JSONEncoder().encode(payload) else { return }
defaults.set(data, forKey: trackedOrdersStorageKey(for: nil))
}
static func clearTrackedOrders() {
defaults.removeObject(forKey: trackedOrdersStorageKey(for: nil))
}
static func savePendingCartOrderId(_ orderId: String) {
let clean = orderId.trimmingCharacters(in: .whitespacesAndNewlines)
guard clean.isEmpty == false else {
clearPendingCartOrder()
return
}
defaults.set(clean, forKey: pendingCartOrderStorageKey(for: nil))
}
static func loadPendingCartOrderId() -> String? {
let value = defaults.string(forKey: pendingCartOrderStorageKey(for: nil))?
.trimmingCharacters(in: .whitespacesAndNewlines)
if let value, value.isEmpty == false {
return value
}
return nil
}
static func clearPendingCartOrder() {
defaults.removeObject(forKey: pendingCartOrderStorageKey(for: nil))
}
static func loadOrderReviews() -> [OrderReviewRecord] {
let key = orderReviewsStorageKey(for: nil)
guard let data = defaults.data(forKey: key),
let decoded = try? JSONDecoder().decode(PersistedOrderReviewsState.self, from: data) else {
return []
}
return decoded.reviews.sorted { lhs, rhs in
lhs.submittedAt > rhs.submittedAt
}
}
static func loadOrderReview(orderId: String) -> OrderReviewRecord? {
let normalized = orderId.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard normalized.isEmpty == false else { return nil }
return loadOrderReviews().first { review in
review.orderId.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == normalized
}
}
static func hasOrderReview(orderId: String) -> Bool {
loadOrderReview(orderId: orderId) != nil
}
static func saveOrderReview(_ review: OrderReviewRecord) {
let cleanId = review.orderId.trimmingCharacters(in: .whitespacesAndNewlines)
guard cleanId.isEmpty == false else { return }
var reviews = loadOrderReviews()
if let index = reviews.firstIndex(where: {
$0.orderId.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == cleanId.lowercased()
}) {
reviews[index] = review
} else {
reviews.insert(review, at: 0)
}
let payload = PersistedOrderReviewsState(reviews: reviews)
guard let data = try? JSONEncoder().encode(payload) else { return }
defaults.set(data, forKey: orderReviewsStorageKey(for: nil))
}
static func loadOrderReviewDraft(orderId: String) -> OrderReviewDraftState? {
let key = orderReviewDraftStorageKey(for: orderId, userKey: nil)
guard let data = defaults.data(forKey: key),
let draft = try? JSONDecoder().decode(OrderReviewDraftState.self, from: data) else {
return nil
}
return draft
}
static func saveOrderReviewDraft(_ draft: OrderReviewDraftState) {
let key = orderReviewDraftStorageKey(for: draft.orderId, userKey: nil)
guard let data = try? JSONEncoder().encode(draft) else { return }
defaults.set(data, forKey: key)
}
static func clearOrderReviewDraft(orderId: String) {
let key = orderReviewDraftStorageKey(for: orderId, userKey: nil)
defaults.removeObject(forKey: key)
}
}

View File

@@ -0,0 +1,280 @@
import Foundation
enum StoreCatalogNormalizer {
static func sanitize(categories: [StoreCatalogCategory], storeId: String) -> [StoreCatalogCategory] {
var seenCategoryIds: Set<String> = []
return categories.enumerated().map { categoryIndex, category in
let categoryId = makeUniqueId(
rawValue: category.id,
fallback: "\(storeId)-category-\(categoryIndex)",
seenIds: &seenCategoryIds
)
let normalizedPizzaConfig = sanitize(
pizzaConfig: category.pizzaConfig,
categoryId: categoryId
)
var seenProductIds: Set<String> = []
let normalizedProducts = category.products.enumerated().map { productIndex, product in
sanitize(
product: product,
categoryId: categoryId,
productIndex: productIndex,
seenProductIds: &seenProductIds
)
}
return StoreCatalogCategory(
id: categoryId,
name: category.name,
isPizzaCategory: category.isPizzaCategory,
pizzaConfig: normalizedPizzaConfig,
products: normalizedProducts
)
}
}
static func preferredCategoryId(
from categories: [StoreCatalogCategory],
preferredId: String?
) -> String? {
guard let preferredId, preferredId.isEmpty == false else {
return categories.first?.id
}
if categories.contains(where: { $0.id == preferredId }) {
return preferredId
}
return categories.first?.id
}
private static func sanitize(
pizzaConfig: StorePizzaConfig?,
categoryId: String
) -> StorePizzaConfig? {
guard let pizzaConfig else { return nil }
var seenSizeIds: Set<String> = []
let normalizedSizes = pizzaConfig.sizes.enumerated().map { index, size in
StorePizzaSize(
id: makeUniqueId(
rawValue: size.id,
fallback: "\(categoryId)-size-\(index)",
seenIds: &seenSizeIds
),
name: size.name,
slices: size.slices,
maxFlavors: size.maxFlavors
)
}
var seenDoughIds: Set<String> = []
let normalizedDoughs = pizzaConfig.doughs.enumerated().map { index, dough in
StorePizzaDough(
id: makeUniqueId(
rawValue: dough.id,
fallback: "\(categoryId)-dough-\(index)",
seenIds: &seenDoughIds
),
name: dough.name,
active: dough.active
)
}
var seenCrustIds: Set<String> = []
let normalizedCrusts = pizzaConfig.crusts.enumerated().map { index, crust in
StorePizzaCrust(
id: makeUniqueId(
rawValue: crust.id,
fallback: "\(categoryId)-crust-\(index)",
seenIds: &seenCrustIds
),
name: crust.name,
active: crust.active,
priceModifier: crust.priceModifier
)
}
return StorePizzaConfig(
sizes: normalizedSizes,
doughs: normalizedDoughs,
crusts: normalizedCrusts
)
}
private static func sanitize(
product: StoreCatalogProduct,
categoryId: String,
productIndex: Int,
seenProductIds: inout Set<String>
) -> StoreCatalogProduct {
let productId = makeUniqueId(
rawValue: product.id,
fallback: "\(categoryId)-product-\(productIndex)",
seenIds: &seenProductIds
)
var seenGroupIds: Set<String> = []
var seenAddonItemIds: Set<String> = []
let normalizedAddonGroups = product.addonGroups.enumerated().map { groupIndex, group in
let groupId = makeUniqueId(
rawValue: group.id,
fallback: "\(productId)-group-\(groupIndex)",
seenIds: &seenGroupIds
)
let normalizedItems = group.items.enumerated().map { itemIndex, item in
StoreAddonItem(
id: makeUniqueId(
rawValue: item.id,
fallback: "\(groupId)-item-\(itemIndex)",
seenIds: &seenAddonItemIds
),
name: item.name,
price: item.price
)
}
return StoreAddonGroup(
id: groupId,
name: group.name,
minSelectors: group.minSelectors,
maxSelectors: group.maxSelectors,
items: normalizedItems
)
}
return StoreCatalogProduct(
id: productId,
type: product.type,
name: product.name,
description: product.description,
image: product.image,
price: product.price,
originalPrice: product.originalPrice,
pizzaPrices: product.pizzaPrices,
addonGroups: normalizedAddonGroups
)
}
private static func makeUniqueId(
rawValue: String,
fallback: String,
seenIds: inout Set<String>
) -> String {
let trimmedRawValue = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
let baseId = trimmedRawValue.isEmpty ? fallback : trimmedRawValue
if seenIds.contains(baseId) == false {
seenIds.insert(baseId)
return baseId
}
var suffix = 1
while true {
let candidate = "\(baseId)-\(suffix)"
if seenIds.contains(candidate) == false {
seenIds.insert(candidate)
return candidate
}
suffix += 1
}
}
}
extension StoreCatalogCategory {
init(
id: String,
name: String,
isPizzaCategory: Bool,
pizzaConfig: StorePizzaConfig?,
products: [StoreCatalogProduct]
) {
self.id = id
self.name = name
self.isPizzaCategory = isPizzaCategory
self.pizzaConfig = pizzaConfig
self.products = products
}
}
extension StoreCatalogProduct {
init(
id: String,
type: String?,
name: String,
description: String?,
image: String?,
price: Double?,
originalPrice: Double?,
pizzaPrices: [String: Double],
addonGroups: [StoreAddonGroup]
) {
self.id = id
self.type = type
self.name = name
self.description = description
self.image = image
self.price = price
self.originalPrice = originalPrice
self.pizzaPrices = pizzaPrices
self.addonGroups = addonGroups
}
}
extension StoreAddonGroup {
init(
id: String,
name: String,
minSelectors: Int?,
maxSelectors: Int?,
items: [StoreAddonItem]
) {
self.id = id
self.name = name
self.minSelectors = minSelectors
self.maxSelectors = maxSelectors
self.items = items
}
}
extension StoreAddonItem {
init(
id: String,
name: String,
price: Double?
) {
self.id = id
self.name = name
self.price = price
}
}
extension StorePizzaConfig {
init(
sizes: [StorePizzaSize],
doughs: [StorePizzaDough],
crusts: [StorePizzaCrust]
) {
self.sizes = sizes
self.doughs = doughs
self.crusts = crusts
}
}
extension StorePizzaCrust {
init(
id: String,
name: String?,
active: Bool?,
priceModifier: Double?
) {
self.id = id
self.name = name
self.active = active
self.priceModifier = priceModifier
}
}

View File

@@ -0,0 +1,40 @@
import Foundation
protocol TokenStore: AnyObject {
var jwt: String? { get set }
func clear()
}
final class DefaultTokenStore: TokenStore {
private let key = "auth_jwt"
private let serviceName = "com.br.pedifoods.app.auth"
private let defaults = UserDefaults.standard
var jwt: String? {
get {
#if os(iOS)
if let keychainValue = KeychainStore.load(service: serviceName, key: key) {
return keychainValue
}
#endif
return defaults.string(forKey: key)
}
set {
#if os(iOS)
if let newValue {
KeychainStore.save(newValue, service: serviceName, key: key)
} else {
KeychainStore.delete(service: serviceName, key: key)
}
#endif
defaults.set(newValue, forKey: key)
}
}
func clear() {
#if os(iOS)
KeychainStore.delete(service: serviceName, key: key)
#endif
defaults.removeObject(forKey: key)
}
}

View File

@@ -0,0 +1,291 @@
import Foundation
struct AppState {
var session = SessionState()
var profile = ProfileState()
var cart = CartState()
var address = AddressState()
var favorites = FavoritesState()
var featureFlags = FeatureFlagsState()
var homeFilters = HomeFiltersState()
var activeModal: AppModal? = nil
var shouldNavigateToOrders: Bool = false
/// Set when a push tap (§6 of the push notifications guide) targets a
/// specific order consumed once by `OrdersView`, which routes to it via
/// `OrderEntryDestinationView` and clears it.
var pendingOrderDeepLink: OrderRouteContext? = nil
}
enum FeatureFlagValue: Codable, Equatable {
case boolean(Bool)
case text(String)
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let boolValue = try? container.decode(Bool.self) {
self = .boolean(boolValue)
return
}
if let stringValue = try? container.decode(String.self) {
self = .text(stringValue)
return
}
if let intValue = try? container.decode(Int.self) {
self = .text(String(intValue))
return
}
if let doubleValue = try? container.decode(Double.self) {
self = .text(String(doubleValue))
return
}
self = .text("off")
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case .boolean(let value):
try container.encode(value)
case .text(let value):
try container.encode(value)
}
}
var boolValue: Bool {
switch self {
case .boolean(let value):
return value
case .text(let value):
return value.lowercased() == "on" || value.lowercased() == "true"
}
}
}
struct FeatureFlagsState: Codable, Equatable {
var configVersion: Int = 0
var evaluatedAt: String? = nil
var source: String = "default"
var values: [String: FeatureFlagValue] = [:]
var raw: [String: FeatureControlRawFlag] = [:]
func isEnabled(_ key: String, default defaultValue: Bool = false) -> Bool {
if let rawValue = raw[key] {
return rawValue.enabled || rawValue.variant.lowercased() == "on"
}
if let mapped = values[key] {
return mapped.boolValue
}
return defaultValue
}
}
enum AppModal: String, Identifiable {
case addressPicker
case filters
var id: String { rawValue }
}
struct SessionState {
var isAuthenticated: Bool = false
var jwt: String? = nil
}
struct ProfileState {
var id: String? = nil
var name: String = ""
var email: String = ""
var phone: String = ""
var profilePicture: String = ""
var cpf: String = ""
var notificationsEnabled: Bool = false
var faceIdEnabled: Bool = false
}
struct AddressState {
var selectedId: String? = nil
var display: String = "Defina seu endereco"
var latitude: Double? = nil
var longitude: Double? = nil
var onboardingMessage: String? = nil
}
struct FavoritesState {
var storeIds: Set<String> = []
}
enum HomeSortOption: String, CaseIterable, Identifiable {
case relevance
case rating
case deliveryTime
case price
var id: String { rawValue }
var title: String {
switch self {
case .relevance: return "Relevância"
case .rating: return "Avaliação"
case .deliveryTime: return "Tempo de entrega"
case .price: return "Preço"
}
}
var icon: String {
switch self {
case .relevance: return "checkmark.seal.fill"
case .rating: return "star.fill"
case .deliveryTime: return "clock.fill"
case .price: return "dollarsign"
}
}
}
enum HomePriceTier: String, CaseIterable, Identifiable {
case low = "$"
case medium = "$$"
case high = "$$$"
case veryHigh = "$$$$"
var id: String { rawValue }
}
struct HomeFiltersState {
var sortOption: HomeSortOption = .relevance
var selectedCategories: Set<String> = []
var selectedPriceTier: HomePriceTier? = nil
var maxDistanceKm: Double = 10
var availableCategories: [String] = []
mutating func reset() {
sortOption = .relevance
selectedCategories = []
selectedPriceTier = nil
maxDistanceKm = 10
}
}
struct CartState {
var storeId: String? = nil
var storeName: String? = nil
var items: [CartItemState] = []
var total: Double = 0
}
struct CartItemState: Identifiable {
let id: String
var productId: String
var storeId: String
var name: String
var imageURL: String? = nil
var details: String? = nil
var choices: [String]? = nil
var addons: [CartItemAddonState] = []
var quantity: Int
var unitPrice: Double
}
struct CartItemAddonState: Identifiable, Hashable {
let id: String
var name: String
var quantity: Int
var unitPrice: Double
}
extension CartState {
var totalItems: Int {
items.reduce(0) { $0 + $1.quantity }
}
mutating func recalculateTotal() {
total = items.reduce(0) { $0 + (Double($1.quantity) * $1.unitPrice) }
}
mutating func clear() {
storeId = nil
storeName = nil
items = []
total = 0
SessionStateStore.clearCart()
}
mutating func add(item: CartItemState) {
if let index = items.firstIndex(where: { $0.id == item.id }) {
items[index].quantity += item.quantity
} else {
items.append(item)
}
recalculateTotal()
SessionStateStore.saveCart(self)
}
mutating func set(item: CartItemState) {
if let index = items.firstIndex(where: { $0.id == item.id }) {
if item.quantity <= 0 {
items.remove(at: index)
} else {
items[index] = item
}
} else if item.quantity > 0 {
items.append(item)
}
if items.isEmpty {
storeId = nil
storeName = nil
}
recalculateTotal()
if items.isEmpty {
SessionStateStore.clearCart()
} else {
SessionStateStore.saveCart(self)
}
}
mutating func increment(itemId: String) {
guard let index = items.firstIndex(where: { $0.id == itemId }) else { return }
items[index].quantity += 1
recalculateTotal()
SessionStateStore.saveCart(self)
}
mutating func decrement(itemId: String) {
guard let index = items.firstIndex(where: { $0.id == itemId }) else { return }
items[index].quantity -= 1
if items[index].quantity <= 0 {
items.remove(at: index)
}
if items.isEmpty {
storeId = nil
storeName = nil
}
recalculateTotal()
if items.isEmpty {
SessionStateStore.clearCart()
} else {
SessionStateStore.saveCart(self)
}
}
func toOrderItemsPayload() -> [CreateOrderItemPayload] {
items.map { item in
CreateOrderItemPayload(
productId: item.productId,
name: item.name,
qty: item.quantity,
price: item.unitPrice,
addons: item.addons
.filter { $0.quantity > 0 }
.map {
CreateOrderAddonPayload(
addonId: $0.id,
name: $0.name,
qty: $0.quantity,
price: $0.unitPrice
)
},
choices: item.choices?.isEmpty == false ? item.choices : nil
)
}
}
}

View File

@@ -0,0 +1,94 @@
import Foundation
import SwiftUI
extension Notification.Name {
static let snackbarDidChange = Notification.Name("snackbarDidChange")
}
@MainActor
final class SnackbarCenter: ObservableObject {
static let shared = SnackbarCenter()
@Published var current: SnackbarMessage?
private var dismissTask: Task<Void, Never>?
func show(
title: String,
style: SnackbarStyle = .info,
icon: String? = nil,
duration: TimeInterval = 3.5,
isPersistent: Bool = false,
action: (() -> Void)? = nil
) {
dismissTask?.cancel()
dismissTask = nil
current = SnackbarMessage(
title: title,
style: style,
iconSystemName: icon,
duration: duration,
isPersistent: isPersistent,
action: action
)
guard isPersistent == false else { return }
dismissTask = Task { [weak self] in
let nanos = UInt64(max(0.2, duration) * 1_000_000_000)
try? await Task.sleep(nanoseconds: nanos)
guard !Task.isCancelled else { return }
self?.dismiss(animated: true)
}
}
func handleTap() {
guard current?.isPersistent != true else { return }
let action = current?.action
dismiss(animated: true)
action?()
}
func dismiss(animated: Bool) {
dismissTask?.cancel()
dismissTask = nil
if animated {
withAnimation(.spring(response: 0.3, dampingFraction: 0.9)) {
current = nil
}
} else {
current = nil
}
}
func dismissPersistent() {
guard current?.isPersistent == true else { return }
dismiss(animated: true)
}
}
enum SnackbarStyle: Sendable, Equatable {
case info
case success
case warning
case error
var backgroundColor: Color {
switch self {
case .info: return Color(hex: "#3B93F7")
case .success: return Color(hex: "#2E7D32")
case .warning: return Color(hex: "#C77700")
case .error: return Color(hex: "#C62828")
}
}
}
struct SnackbarMessage: Identifiable {
let id = UUID()
let title: String
let style: SnackbarStyle
let iconSystemName: String?
let duration: TimeInterval
let isPersistent: Bool
let action: (() -> Void)?
}

View File

@@ -0,0 +1,97 @@
import Foundation
import SwiftUI
import CoreGraphics
import UIKit
extension UIDevice {
static var appSafeAreaTop: CGFloat {
let scenes = UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
let windows = scenes.flatMap { $0.windows }
if let maxTop = windows.map({ $0.safeAreaInsets.top }).max(), maxTop > 0 {
return maxTop
}
if let fallbackMaxTop = UIApplication.shared.windows.map({ $0.safeAreaInsets.top }).max(), fallbackMaxTop > 0 {
return fallbackMaxTop
}
return 0
}
}
func appReadClipboardText() -> String? {
UIPasteboard.general.string
}
func appWriteClipboardText(_ value: String) {
UIPasteboard.general.string = value
}
extension View {
@ViewBuilder
func appInlineNavigationTitle() -> some View {
self
.navigationBarTitleDisplayMode(.inline)
.modifier(AppRoundedBackButtonModifier())
}
@ViewBuilder
func appHiddenNavigationBar() -> some View {
self
.toolbar(.hidden, for: .navigationBar)
.toolbarBackground(.hidden, for: .navigationBar)
}
@ViewBuilder
func appTopBarTrailingToolbar<Content: View>(@ViewBuilder content: () -> Content) -> some View {
self.toolbar {
ToolbarItem(placement: .topBarTrailing) {
content()
}
}
}
@ViewBuilder
func appContentShape<S: Shape>(_ shape: S) -> some View {
self.contentShape(shape)
}
@ViewBuilder
func appBottomSafeAreaInset<Content: View>(@ViewBuilder content: () -> Content) -> some View {
self.safeAreaInset(edge: .bottom) {
content()
}
}
@ViewBuilder
func appLayoutPriority(_ value: Double) -> some View {
self.layoutPriority(value)
}
@ViewBuilder
func appNamedCoordinateSpace(_ name: String) -> some View {
self.coordinateSpace(name: name)
}
}
private struct AppRoundedBackButtonModifier: ViewModifier {
@Environment(\.dismiss) private var dismiss
func body(content: Content) -> some View {
content
.navigationBarBackButtonHidden(true)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button(action: { dismiss() }) {
Image(systemName: "chevron.left")
.font(.system(size: 24, weight: .semibold))
.foregroundStyle(AppColors.textPrimary)
.frame(width: 52, height: 52)
.background(AppColors.surface)
.clipShape(Circle())
.shadow(color: .black.opacity(0.06), radius: 8, y: 2)
}
.buttonStyle(.plain)
}
}
}
}

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