Merge pull request 'main' (#45) from main into develop
Reviewed-on: Loverde-Company-LTDA/Pedi-Foods-Skip#45
67
.gitea/workflows/beta.yml
Normal 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
|
||||
85
.gitea/workflows/release.yml
Normal 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
@@ -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
|
||||
8
pedi-foods/.gitignore → .gitignore
vendored
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -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
@@ -0,0 +1,86 @@
|
||||

|
||||
|
||||
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
|
||||
31
NotificationServiceExtension/Info.plist
Normal 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>
|
||||
60
NotificationServiceExtension/NotificationService.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
1324
PediFoods.xcodeproj/project.pbxproj
Normal file
@@ -2,6 +2,6 @@
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "group:Darwin/PediFoods.xcodeproj">
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
</Workspace>
|
||||
143
PediFoods.xcodeproj/xcshareddata/xcschemes/PediFoods.xcscheme
Normal 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>
|
||||
38
PediFoods/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal 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
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 35 KiB |
@@ -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
|
||||
}
|
||||
}
|
||||
12
PediFoods/Assets.xcassets/alelocard_logo.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "alelocard_logo.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
PediFoods/Assets.xcassets/alelocard_logo.imageset/alelocard_logo.png
vendored
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
12
PediFoods/Assets.xcassets/amexcard_logo.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "amexcard_logo.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
PediFoods/Assets.xcassets/amexcard_logo.imageset/amexcard_logo.png
vendored
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
12
PediFoods/Assets.xcassets/hipercard_logo.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "hipercard_logo.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
PediFoods/Assets.xcassets/hipercard_logo.imageset/hipercard_logo.png
vendored
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
12
PediFoods/Assets.xcassets/icon_pix.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "icon_pix.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
PediFoods/Assets.xcassets/icon_pix.imageset/icon_pix.png
vendored
Normal file
|
After Width: | Height: | Size: 835 B |
12
PediFoods/Assets.xcassets/mastercard_logo.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "mastercard_logo.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
PediFoods/Assets.xcassets/mastercard_logo.imageset/mastercard_logo.png
vendored
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
52
PediFoods/Assets.xcassets/pedifoods.imageset/Contents.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
PediFoods/Assets.xcassets/pedifoods.imageset/pedifoods.png
vendored
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
PediFoods/Assets.xcassets/pedifoods.imageset/pedifoods_dark.png
vendored
Normal file
|
After Width: | Height: | Size: 82 KiB |
52
PediFoods/Assets.xcassets/pin_image_app.imageset/Contents.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
PediFoods/Assets.xcassets/pin_image_app.imageset/pin_image_app.png
vendored
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
PediFoods/Assets.xcassets/pin_image_app.imageset/pin_image_app_dark.png
vendored
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
12
PediFoods/Assets.xcassets/placeholder-pizza.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "placeholder-pizza.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
PediFoods/Assets.xcassets/placeholder-pizza.imageset/placeholder-pizza.png
vendored
Normal file
|
After Width: | Height: | Size: 667 KiB |
12
PediFoods/Assets.xcassets/placeholder-product.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "placeholder-product.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
PediFoods/Assets.xcassets/placeholder-product.imageset/placeholder-product.png
vendored
Normal file
|
After Width: | Height: | Size: 59 KiB |
12
PediFoods/Assets.xcassets/sodexo_logo.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "sodexo_logo.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
PediFoods/Assets.xcassets/sodexo_logo.imageset/sodexo_logo.png
vendored
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
12
PediFoods/Assets.xcassets/tracking-canceled.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "tracking-canceled.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
PediFoods/Assets.xcassets/tracking-canceled.imageset/tracking-canceled.png
vendored
Normal file
|
After Width: | Height: | Size: 406 KiB |
12
PediFoods/Assets.xcassets/tracking-completed.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "tracking-completed.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
PediFoods/Assets.xcassets/tracking-completed.imageset/tracking-completed.png
vendored
Normal file
|
After Width: | Height: | Size: 924 KiB |
12
PediFoods/Assets.xcassets/tracking-delivering.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "tracking-delivering.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
PediFoods/Assets.xcassets/tracking-delivering.imageset/tracking-delivering.png
vendored
Normal file
|
After Width: | Height: | Size: 951 KiB |
12
PediFoods/Assets.xcassets/tracking-pending.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "tracking-pending.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
PediFoods/Assets.xcassets/tracking-pending.imageset/tracking-pending.png
vendored
Normal file
|
After Width: | Height: | Size: 940 KiB |
12
PediFoods/Assets.xcassets/tracking-preparing.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "tracking-preparing.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
PediFoods/Assets.xcassets/tracking-preparing.imageset/tracking-preparing.png
vendored
Normal file
|
After Width: | Height: | Size: 926 KiB |
12
PediFoods/Assets.xcassets/tracking-ready.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "tracking-ready.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
PediFoods/Assets.xcassets/tracking-ready.imageset/tracking-ready.png
vendored
Normal file
|
After Width: | Height: | Size: 799 KiB |
12
PediFoods/Assets.xcassets/visacard_logo.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "visacard_logo.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
PediFoods/Assets.xcassets/visacard_logo.imageset/visacard_logo.png
vendored
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
13
PediFoods/Components/AppBackButtonIcon.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
70
PediFoods/Components/Buttons.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
146
PediFoods/Components/CachedRemoteImage.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
43
PediFoods/Components/Inputs.swift
Normal 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
38
PediFoods/Components/SnackbarOverlay.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
178
PediFoods/Components/StoreCard.swift
Normal 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]
|
||||
}
|
||||
25
PediFoods/Components/TextFieldCompat.swift
Normal 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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
10
PediFoods/Coordinators/AppCoordinator.swift
Normal file
@@ -0,0 +1,10 @@
|
||||
enum RootFlow: Hashable {
|
||||
case auth
|
||||
case main
|
||||
}
|
||||
|
||||
enum MainTab: Hashable {
|
||||
case home
|
||||
case cart
|
||||
case profile
|
||||
}
|
||||
49
PediFoods/Coordinators/DeepLinkDestination.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
125
PediFoods/DesignSystem/DesignSystem.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
43
PediFoods/LaunchScreen.storyboard
Normal 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>
|
||||
16
PediFoods/PediFoods.entitlements
Normal 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>
|
||||
63
PediFoods/PediFoodsApp.swift
Normal 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])
|
||||
}
|
||||
}
|
||||
1297
PediFoods/Resources/Localizable.xcstrings
Normal file
83
PediFoods/Services/ApiCardModels.swift
Normal 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?
|
||||
}
|
||||
498
PediFoods/Services/ApiClient.swift
Normal 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
|
||||
}
|
||||
}
|
||||
71
PediFoods/Services/ApiConfig.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
122
PediFoods/Services/ApiCustomerPayloadModels.swift
Normal 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
|
||||
}
|
||||
}
|
||||
6
PediFoods/Services/ApiFavoriteModels.swift
Normal file
@@ -0,0 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
struct CustomerFavoritesMutationResult: Decodable {
|
||||
let favorites: [String]
|
||||
let store: StoreSummary?
|
||||
}
|
||||
552
PediFoods/Services/ApiModels.swift
Normal 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
|
||||
}
|
||||
}
|
||||
196
PediFoods/Services/ApiOrderModels.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
715
PediFoods/Services/ApiOrderTrackingModels.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
80
PediFoods/Services/ApiPizzaModels.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
268
PediFoods/Services/ApiReviewModels.swift
Normal 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 = []
|
||||
}
|
||||
}
|
||||
650
PediFoods/Services/ApiService.swift
Normal 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
|
||||
}
|
||||
}
|
||||
131
PediFoods/Services/AppCache.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
278
PediFoods/Services/FeatureControlService.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
77
PediFoods/Services/GuestLocationStore.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
251
PediFoods/Services/GuestSessionService.swift
Normal 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
|
||||
}
|
||||
}
|
||||
44
PediFoods/Services/ImageSourceResolver.swift
Normal 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)"
|
||||
}
|
||||
}
|
||||
60
PediFoods/Services/KeychainStore.swift
Normal 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
|
||||
}
|
||||
}
|
||||
193
PediFoods/Services/LocationService.swift
Normal 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
|
||||
250
PediFoods/Services/OrderRealtimeTracker.swift
Normal 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
|
||||
}
|
||||
126
PediFoods/Services/PublicLocationModels.swift
Normal 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
|
||||
}
|
||||
}
|
||||
128
PediFoods/Services/PublicLocationService.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
220
PediFoods/Services/PushNotificationCoordinator.swift
Normal 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
|
||||
8
PediFoods/Services/SessionEvents.swift
Normal 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")
|
||||
}
|
||||
456
PediFoods/Services/SessionStateStore.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
280
PediFoods/Services/StoreCatalogNormalizer.swift
Normal 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
|
||||
}
|
||||
}
|
||||
40
PediFoods/Services/TokenStore.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
291
PediFoods/State/AppState.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
94
PediFoods/State/SnackbarCenter.swift
Normal 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)?
|
||||
}
|
||||
97
PediFoods/Support/PlatformCompat.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||