# Kasy App

Flutter app com backend API REST — gerado pelo kasy.

---

## Documentação

A documentação completa do Kasy está em **[kasy.dev/docs](https://kasy.dev/docs)** — instalação, features, personalização, publicação e troubleshooting, passo a passo.

Neste projeto você também tem guias locais (funcionam offline):

| Guia | Conteúdo |
|------|----------|
| [docs/auth-setup.md](docs/auth-setup.md) | Ativar login com Google, Apple e Facebook |
| [docs/revenuecat-setup.md](docs/revenuecat-setup.md) | Ativar assinaturas (RevenueCat) do teste à produção |
| [docs/ad_mobs.md](docs/ad_mobs.md) | Anúncios (AdMob) e recompensas verificadas |
| [docs/ios-release.md](docs/ios-release.md) | Publicar no iOS com Mac (`kasy ios`) |
| [docs/codemagic-release.md](docs/codemagic-release.md) | Publicar sem Mac (`kasy codemagic`) |
| [docs/figma-workflow.md](docs/figma-workflow.md) | Fluxo Figma → Flutter para assistentes de IA |
| [docs/figma-guia.md](docs/figma-guia.md) | Guia Figma passo a passo (rebrand e telas) |
| [design/README.md](design/README.md) | Design system Figma (link Community + duplicate) |

---

## Como começar

```sh
kasy run             # recomendado — lê o .env e escolhe as chaves certas
kasy run --ios       # simulador iOS
kasy run --android   # emulador Android
kasy run --web       # web em localhost:5555
```

Alternativas: `make run` ou `flutter run` funcionam, mas sem os extras do `kasy run` (escolha automática de chave RevenueCat, log em `.kasy/run.log`, aviso de update).

**Dispositivo físico via cabo**
- iOS: conecte o iPhone → confie neste computador → Xcode → Window → Devices → parear
- Android: Configurações → Opções do desenvolvedor → ativar depuração USB

**URL do backend** está configurada como `BACKEND_URL` no `.env` da raiz.
Para atualizar a URL, edite o `.env` e rode `kasy run` de novo.

---

## Chaves e credenciais

Este projeto usa dois tipos de credenciais. Entender a diferença evita confusão na hora de configurar.

### Chaves do app (ficam no projeto)

Ficam no arquivo **`.env`** na raiz do projeto (cada chave tem um comentário explicando). O `kasy run` lê o `.env` e injeta os valores no build via `--dart-define`; o Flutter lê com `String.fromEnvironment()`. **Nunca vão para o servidor.**

| Variável | Módulo | Como obter |
|----------|--------|------------|
| `BACKEND_URL` | API REST | URL da sua API |
| `RC_TEST_KEY` | RevenueCat | Dashboard RevenueCat → Apps → Test Store (chave `test_`, vale iOS+Android, usada automaticamente em simulador) |
| `RC_IOS_PROD_KEY` | RevenueCat | Dashboard RevenueCat → Apps → App Store (chave `appl_`, usada automaticamente em iPhone físico) |
| `RC_ANDROID_PROD_KEY` | RevenueCat | Dashboard RevenueCat → Apps → Google Play (chave `goog_`, usada automaticamente em Android físico) |
| `SENTRY_DSN` | Sentry | Dashboard Sentry → Projeto → DSN |
| `MIXPANEL_TOKEN` | Mixpanel | Dashboard Mixpanel → Configurações → Token |
| `AI_CHAT_ENDPOINT` | IA Chat | URL do seu endpoint SSE de chat (ex.: `https://sua-api/ai-chat`) |

Para atualizar uma chave, edite o `.env` e rode `kasy run` de novo.

---

## Internacionalização (i18n)

O app suporta **3 idiomas**: inglês (`en`), português (`pt`) e espanhol (`es`).

### Como o idioma é escolhido

```
App abre
  ├─ Tem idioma salvo pelo usuário? → usa o salvo
  └─ Não tem → lê o idioma do celular/browser
                ├─ É en, pt ou es? → usa esse idioma
                └─ Não é nenhum desses → usa o idioma padrão (base_locale)
```

### Mudar o idioma padrão (fallback)

**`slang.yaml`**
```yaml
base_locale: pt   # trocar aqui: en | pt | es
```

Depois rodar:
```sh
dart run slang
```

### Adicionar ou editar traduções

Os arquivos ficam em `lib/i18n/`:
- `en.i18n.json` — inglês
- `pt.i18n.json` — português
- `es.i18n.json` — espanhol

Após editar qualquer `.i18n.json`, sempre rodar `dart run slang`.

---

## Admin (console interno)

O app tem um **console de admin** (aba Usuários, Solicitações, etc.) liberado só para quem tem `role == "admin"`. O `role` é um campo de **controle de acesso** que o **seu backend controla** — o app nunca pode escrever nele.

### Campo `role` no usuário

O seu endpoint de usuário (`GET /users/{id}`) deve devolver o `role` junto com os outros dados:

```json
{ "id": "...", "email": "ana@b.com", "name": "Ana", "onboarded": true, "role": "admin" }
```

- `role` ausente / `null` → usuário normal.
- `role: "admin"` → libera o console de admin.

**Regra de segurança (obrigatória):** o `role` só pode ser definido no servidor (banco/painel). O backend deve **rejeitar** qualquer tentativa do cliente de gravar `role` (ex.: num `PATCH /users/{id}`), senão qualquer pessoa vira admin. Defina-o manualmente no seu banco para promover alguém.

### Endpoint: listar usuários

```
GET /admin/users
  Auth: Authorization: Bearer <token>
  Query (todos opcionais):
    page=0              página (0-based)
    pageSize=10         itens por página (máx. 50)
    search=texto        filtra nome ou e-mail (contains)
    subscribersOnly=true
    sort=default|user|status|plan|joined
    sortAsc=true|false

  O servidor DEVE validar role == "admin" e responder 403 caso contrário.

  200 OK:
  {
    "users": [ { "id", "email", "name", "createdAt", "avatarPath", "subscriber" } ],
    "totalUsers": 142,
    "page": 0,
    "pageSize": 10,
    "pageCount": 15,
    "searchCapped": false
  }
```

```
GET /admin/users/overview
  Auth: Bearer (admin only)
  200 OK:
  {
    "totalUsers": 142,
    "subscribers": 38,
    "new7d": 12,
    "daily": [0,1,0,...],   // 14 buckets, mais antigo primeiro
    "firstDayMs": 1700000000000,
    "lastDayMs": 1700000000000
  }
```

O app pede **uma página por vez** (10 usuários por padrão). Busca, filtro e ordenação disparam nova chamada no servidor.

### Endpoint: moderar solicitações (aba Solicitações)

```
GET   /admin/feature-requests        → lista TODAS (ativas + ocultas), mais votadas primeiro
PATCH /admin/feature-requests/{id}   body: {"active": true|false}
PATCH /admin/feature-requests/{id}   body: {"title": {...}, "description": {...}}
```

Mesma regra: validar `role == "admin"` e responder 403 caso contrário. `title`/`description` são mapas por idioma (`{"en": "...", "pt": "...", "es": "..."}`).

### Endpoints: AI Chat (histórico de conversas)

O assistente guarda várias conversas por usuário, cada uma com várias mensagens.
O usuário é identificado pelo token `Authorization: Bearer`.

```
GET    /ai-conversations                  → lista as conversas do usuário, mais recente primeiro
POST   /ai-conversations                  → cria uma conversa vazia e devolve o objeto criado
DELETE /ai-conversations/{id}             → apaga a conversa e todas as mensagens dela
GET    /ai-conversations/{id}/messages    → mensagens da conversa, mais antiga primeiro
POST   /ai-conversations/{id}/messages    body: {"role": "...", "content": "...", "created_at": "..."}
```

Formato de uma conversa (o "última mensagem" é desnormalizado para a lista ficar barata):

```json
{
  "id": "...",
  "created_at": "2026-01-01T12:00:00Z",
  "updated_at": "2026-01-01T12:05:00Z",
  "last_message_role": "user" | "assistant" | null,
  "last_message_content": "..." | null
}
```

Ao salvar uma mensagem, o servidor deve atualizar `updated_at`, `last_message_role` e
`last_message_content` da conversa.

### Endpoint: AI Chat (resposta em streaming)

A resposta da IA é transmitida palavra a palavra via SSE por um endpoint separado,
cuja URL vem do `--dart-define=AI_CHAT_ENDPOINT=...` (quadro de credenciais acima).
Este endpoint **não persiste nada** — só faz o proxy para o provedor (OpenAI/Gemini)
e devolve o texto em stream. A chave do provedor fica **só no servidor**.

```
POST {AI_CHAT_ENDPOINT}
  Auth: Authorization: Bearer <token>   (enviado automaticamente pelo app)
  Content-Type: application/json
  body:
  {
    "message": "última mensagem do usuário",
    "history": [ { "role": "user" | "assistant", "content": "..." } ]
  }

  200 OK  (text/event-stream)
  → devolva o texto da resposta em chunks (stream); o app concatena e renderiza
    em tempo real. Mantenha a chave da IA (OPENAI/GEMINI) apenas no servidor.
```

Se `AI_CHAT_ENDPOINT` não estiver definido, o chat mostra o estado "não configurado"
(o app não quebra). Referência pronta: a Edge Function `ai-chat` do backend Supabase.

---

## Excluir conta

O app chama um endpoint para o usuário excluir a própria conta. **É obrigatório
para publicar na App Store e na Play Store**, então o seu backend precisa implementá-lo.

```
DELETE /users/me
  Auth: Authorization: Bearer <token>   (identifica o usuário; enviado pelo app)

  O servidor DEVE:
   1. Apagar o usuário do sistema de auth para que aquele login nunca mais entre.
   2. Apagar em cascata TODOS os dados do usuário: perfil, devices/tokens de push,
      conversas + mensagens de IA, votos de feature requests, assinaturas, avatar.
  → responda 2xx em caso de sucesso.
```

Sem esse endpoint, a exclusão de conta falha silenciosamente (404/405) num projeto
API novo.

---

## Notificações push (FCM)

Push **nativo (Android/iOS)** depende do seu servidor: o app registra o token do
device e o seu backend envia via **FCM HTTP v1**. Na **web**, push é no-op de propósito
(o app não registra token e não tenta enviar — só mostra as notificações que já existem).

A chave de Service Account do Firebase foi salva pelo `kasy new` em
`.kasy/fcm-service-account.json`. Carregue-a no servidor (ex.: variável
`FIREBASE_SERVICE_ACCOUNT_JSON`) e use-a para chamar a FCM HTTP v1. Referência pronta
de implementação: a Edge Function `send-push-notification` do backend Supabase.

### Endpoints: devices (tokens de push)

```
POST   /users/{userId}/devices                          → registra/atualiza um device (body com token, platform, etc.)
PUT    /devices/{deviceId}                               → atualiza um device existente
DELETE /devices/{deviceId}                               → remove um device
PATCH  /users/{userId}/devices/{installationId}/touch    → marca o device como ativo agora (last-seen)
POST   /users/{userId}/devices/cleanup-stale             → remove devices antigos/inválidos
DELETE /users/{userId}/devices                           → remove todos os devices do usuário (ex.: no logout)
```

> **Device sem token de push (importante):** o app registra o device **mesmo sem
> permissão de push** — nesse caso o `token` vem **vazio** (`""`). É de propósito:
> rastreia a instalação e dispara a boas-vindas (abaixo) sem depender do push; o
> token é preenchido depois, via `PUT /devices/{deviceId}`, quando o usuário ativar
> as notificações. Seu backend deve **aceitar token vazio** e, ao enviar push,
> **pular** os devices com token vazio (não os trate como inválidos nem os apague).

> **Notificação de boas-vindas:** crie-a **uma única vez por conta**, no primeiro
> registro de device (`POST /users/{userId}/devices`), de forma **independente do
> push** (vale para conta anônima também). Persista só no banco (sem disparar push) e
> use o `extra_data.deviceLocale` enviado pelo device para localizar a mensagem
> (`pt`/`es`/`en`). Referência exata: o trigger `trigger_welcome_notification` do
> backend Supabase e o `onFirstDeviceRegistered` do Firebase.

### Endpoints: notifications

```
GET    /users/{userId}/notifications?page=&pageSize=     → lista paginada, mais recente primeiro
PUT    /users/{userId}/notifications/{id}                → marca como lida
DELETE /users/{userId}/notifications/{id}                → apaga uma notificação
GET    /users/{userId}/notifications/unread              → SSE: stream da contagem de não-lidas (alimenta a "bolinha")
POST   /users/{userId}/notifications                     → cria/envia para UM usuário (body: title, body, image_url?, data.route?, type)
POST   /notifications/broadcast                          → envia para TODOS (mesmo body)
```

Todas exigem `Authorization: Bearer <token>` (enviado automaticamente). Ao criar uma
notificação, o servidor persiste o registro **e** dispara o push via FCM para os devices
do destinatário.

---

## Anúncios (AdMob) — recompensa verificada no servidor (SSV)

Os anúncios são **nativos (Android/iOS)** e rodam 100% no app via `google_mobile_ads`
(banner, intersticial, recompensado e recompensado-intersticial). Na **web** tudo é
no-op de propósito. A **única** parte que depende do seu backend é validar os anúncios
**recompensados** com segurança, o **SSV (Server-Side Verification)**: sem ele, um app
adulterado consegue forjar a recompensa.

Quando o usuário termina um anúncio recompensado, o **Google chama o seu endpoint**
com os dados da recompensa e uma assinatura. O seu servidor verifica a assinatura e
concede a recompensa **uma única vez** (idempotente por `transaction_id`).

```
GET /ads/verify-reward    (chamado pelo GOOGLE, não pelo app — endpoint público)
  Query params (enviados pelo Google):
    ad_network, ad_unit, custom_data, key_id, reward_amount, reward_item,
    signature, timestamp, transaction_id, user_id

  O servidor DEVE:
   1. Verificar a assinatura ECDSA (SHA-256) sobre a query string ATÉ (sem incluir)
      "&signature=". `signature` e `key_id` são sempre os dois últimos parâmetros.
      Use as chaves públicas do Google (cacheie por ~1h):
        https://gstatic.com/admob/reward/verifier-keys.json
      Ache a chave cujo keyId == key_id e valide a assinatura (base64url → DER).
   2. Se inválida → 403. Se válida e sem user_id/transaction_id → responda 200 (ack).
   3. Conceder a recompensa de forma IDEMPOTENTE: se já processou esse
      transaction_id, não conceda de novo; senão, credite o user_id (moedas, vidas,
      passe sem anúncios…) e marque o transaction_id como processado.
  → responda 200 em caso de sucesso (o Google reenvia em caso de erro).
```

Implementações de referência prontas (mesma lógica), copie de uma delas:
  - Firebase: `functions/src/ads/ads_functions.ts`
  - Supabase: Edge Function `verify-ad-reward` + função SQL `grant_ad_reward`

No app, configure a URL do seu endpoint como **SSV callback** de cada ad unit
recompensado no console do AdMob. O app já envia o `user_id` automaticamente (via
`setServerSideOptions`), então o seu endpoint sabe a quem creditar.

Opcional (para o app mostrar o saldo):

```
GET /users/{userId}/ad-rewards/balance    → { "balance": <número> }
  Auth: Authorization: Bearer <token>
```

---

## Drive (corrida) — contrato

O módulo Drive é o fluxo completo de um app de corrida (passageiro pede,
motorista aceita e navega). No backend Firebase/Supabase ele já vem pronto;
no backend API própria você implementa os endpoints abaixo seguindo o mesmo
comportamento. Nenhum deles é chamado direto pelo app sem passar por aqui —
o preço da corrida é sempre calculado no SERVIDOR (a partir de uma chamada à
Directions API do Mapbox no momento da criação), nunca aceito do cliente, ou
qualquer app adulterado poderia gravar o próprio preço.

```
POST /drive/rides                      (passageiro pede uma corrida)
  Body: { pickupLat, pickupLng, pickupAddress, dropoffLat, dropoffLng, dropoffAddress }
  Auth: Authorization: Bearer <token>
  O servidor DEVE:
   1. Chamar a Directions API do Mapbox com pickup/dropoff para obter
      distância e duração reais.
   2. Calcular o preço estimado: baseFare + (km * pricePerKm) + (min * pricePerMin)
      — coeficientes configuráveis (variáveis de ambiente do seu servidor,
      mesmos nomes usados nos outros backends: DRIVE_BASE_FARE,
      DRIVE_PRICE_PER_KM, DRIVE_PRICE_PER_MIN, DRIVE_CURRENCY).
   3. Persistir a corrida com status "requested" e notificar motoristas
      online próximos (raio configurável, ~5km).
  → { rideId, estimatedDistanceM, estimatedDurationS, estimatedPrice, currency }

POST /drive/rides/{id}/accept          (motorista aceita — operação atômica)
  Auth: Authorization: Bearer <token> (precisa ter perfil de motorista)
  O servidor DEVE garantir que só UM motorista vence: um UPDATE condicional
  (`WHERE status = 'requested'`) ou transação equivalente ao seu banco. Se
  outro motorista já aceitou, responda 409/erro claro — não silencie.
  Rejeite auto-aceite (mesmo uid passageiro e motorista) por padrão. Só
  permita em ambiente de dogfood explícito (equivalente ao
  DRIVE_ALLOW_SELF_ACCEPT do Firebase).
  → { accepted: true }  ou erro se a corrida não estava mais disponível

POST /drive/rides/{id}/start           (motorista embarcou o passageiro)
POST /drive/rides/{id}/complete        (corrida concluída no destino)
POST /drive/rides/{id}/cancel          { reason? }  (passageiro ou motorista cancela)
  Auth: Authorization: Bearer <token>; validar que o chamador é o
  passageiro/motorista daquela corrida antes de aplicar a transição.

POST /drive/rides/{id}/decline         (motorista recusa uma solicitação)
  Auth: precisa ter perfil de motorista.
  NÃO é um cancelamento: o status da corrida continua "requested" — todo
  outro motorista próximo ainda deve vê-la. Só grave o uid de quem recusou
  (ex.: array `declinedDriverIds`) pra esse motorista específico parar de
  vê-la em GET /drive/rides/requests/nearby, até o passageiro chamar retry.
  No-op silencioso se a corrida já saiu de "requested".
  → { declined: true }

POST /drive/rides/{id}/retry           (passageiro pede de novo, sem motorista)
  Auth: só o passageiro daquela corrida.
  Limpa os uids recusados anteriormente (motoristas que recusaram da
  primeira vez voltam a ver a solicitação) e atualiza o "pedido às" pra
  reiniciar a contagem do "há quanto tempo está esperando" no app. Válido só
  enquanto "requested" e com um limite de tentativas (2, mesmo valor usado
  nos outros dois backends) — depois do limite, responda erro/409 e o app
  para de oferecer "tentar novamente".
  → { retried: true } ou erro se a corrida já foi aceita/cancelada/concluída
  ou o limite de tentativas foi atingido.

GET  /drive/rides/{id}                 (ler uma corrida — o app faz polling
  disto pra acompanhar status/motorista em tempo real; ver nota abaixo)
  Auth: só o passageiro/motorista daquela corrida pode ler.

GET  /drive/rides/active?asDriver=<bool>
  Retorna a corrida ATIVA do chamador, se houver — mesma busca que
  GET /drive/rides/requests/nearby faz pro motorista, mas aqui é "a corrida
  que EU já tenho aberta", não "corridas pra eu aceitar". Filtra por
  passengerId (asDriver=false) ou driverId (asDriver=true) igual ao
  chamador autenticado, com status em ["requested","accepted","arriving",
  "in_progress"] (passageiro) ou ["accepted","arriving","in_progress"]
  (motorista — nunca tem uma corrida "requested" própria). É o que permite
  o app reconhecer "você já tem uma corrida aberta" em QUALQUER
  dispositivo/navegador logado na mesma conta, não só no que criou a
  corrida — sem isso, trocar de aparelho deixava pedir uma corrida
  duplicada.
  → corrida (mesmo formato do GET /drive/rides/{id}) ou 404/null se não
  houver nenhuma ativa.

GET  /drive/rides/requests/nearby
  Lista corridas com status "requested" perto do motorista CHAMADOR (use a
  localização salva do próprio perfil dele, nunca um lat/lng vindo do
  client). Só motorista online, senão retorne lista vazia.
  Por padrão, NÃO inclua corridas em que o chamador é o passageiro
  (mesmo filtro do Firebase/Supabase).
  → [{ rideId, pickupAddress, dropoffAddress,
       pickupLat, pickupLng, dropoffLat, dropoffLng,
       estimatedDistanceM, estimatedDurationS, estimatedPrice, currency }]
  As coordenadas alimentam o preview do trajeto no mapa do motorista
  (overlay de corrida recebida). Se omitir, o app esconde o preview.

GET  /drive/drivers/nearby?lat=&lng=&radiusM=
  Lista motoristas online dentro do raio, ordenados por distância. Usado
  internamente pelo POST /drive/rides acima; exponha só se o app precisar
  consultar diretamente.

GET  /drive/drivers/{id}               (ler o perfil/posição de um motorista
  — qualquer usuário autenticado pode ler, é assim que o passageiro
  acompanha o motorista no mapa)

PUT  /drive/drivers/{id}               { vehicleMake, vehicleModel, vehicleColor,
  vehiclePlate, photoUrl? }
  Cria/edita o perfil de motorista do próprio chamador (upsert). A
  existência desse registro É o que torna alguém motorista — sem
  aprovação/KYC nesta versão.

PATCH /drive/drivers/{id}/location     { lat, lng, status? }
  Motorista atualiza sua posição (e opcionalmente status online/offline/
  busy) periodicamente enquanto online. Validar que {id} é o próprio
  chamador.

PATCH /drive/drivers/{id}/status       { status }
  Liga/desliga o modo motorista (online/offline), sem exigir lat/lng.
```

**Sobre tempo real:** os outros dois backends usam listeners nativos
(Firestore snapshots / Supabase Realtime) para `GET /drive/rides/{id}` e
`GET /drive/drivers/{id}`. Numa API REST comum isso normalmente vira
polling no client (repetir o GET a cada poucos segundos) — é o que o
client Dart deste backend já faz por padrão, então nenhum requisito
adicional aqui além dos dois GETs acima existirem. Se seu servidor suporta
WebSocket/SSE, é uma otimização opcional, não obrigatória.

Implementações de referência prontas (mesma lógica), copie de uma delas:
  - Firebase: `functions/src/drive/drive_functions.ts` (callables) + `functions/src/drive/triggers.ts` (notificações); `declineRide`/`retryRide`/limite de tentativas em `functions/src/drive/repositories/ride_repository.ts`
  - Supabase: Edge Function `request-ride` (cálculo de preço) + funções SQL `accept_ride`/`start_ride`/`complete_ride`/`cancel_ride`/`find_nearby_drivers` em `20240101000018_drive.sql`; `decline_ride`/`retry_ride`/`declined_driver_ids`/`retry_count` em `20240101000021_drive_decline_retry.sql`

**O preço é só uma estimativa** — nenhum dos três backends processa o
pagamento de verdade nesta versão, só calcula e registra o valor. Cobrança
real (repasse ao motorista, comissão da plataforma) é um próximo passo
natural via o módulo Stripe do kit (`kasy add stripe`), não incluído aqui.

---

## Segurança

O `.gitignore` já exclui: `.env`, `.env.*`, `*.pem`, `*.keystore`.

Nunca comite credenciais no repositório.
