Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@
- **Ruído removido:** `package-lock.json` (lockfile npm órfão, sem `package.json`, que ainda carregava `"name": "app-fisio2"`) apagado, e `.playwright-mcp/` removido do índice e adicionado ao `.gitignore` — artefato de sessão de ferramenta, não pertence ao repositório.

### Correções
- **Login no web não completava no iPhone, e no resto sempre pedia duas tentativas.** O fluxo antigo abria dois popups do Google: identidade pelo botão do GIS sobreposto ao desenho, e autorização dos escopos Drive/Sheets num segundo popup disparado automaticamente pelo stream `authenticationEvents` — sem clique nenhum nesta página, já que o clique original tinha sido consumido pela interação inteira com o primeiro popup. O Chrome tolerava isso na maioria das vezes (daí precisar tentar duas vezes); o WebKit do iOS — usado por todo navegador no iOS, Chrome incluso, por exigência da Apple — bloqueava sempre, impedindo o login por completo. Um cliente relatou não conseguir entrar de jeito nenhum pelo iPhone.
- **Login interativo no web virou um popup só.** `AutorizadorGoogleWeb.entrarInterativo` (`lib/servicos/autorizador_google_web.dart`) pede conta e consentimento dos escopos juntos, chamado sem nenhum `await` real entre o toque do usuário e `requestAccessToken()` — preserva a ativação do usuário que o navegador exige para não bloquear o popup. Identidade (nome/e-mail) passa a vir do próprio access token via `userinfo` do Google.
- **Removido o botão do GIS sobreposto ao desenho** (`componentes/botao_google_renderizado*.dart`, agora deletados): `_BotaoEntrarGoogle` virou um `GestureDetector` comum, sem platform view nem truques de `Opacity`.
- `entrarComGoogle` (`provedor_autenticacao.dart`) passou a pular a restauração silenciosa de sessão no web (o cache do token é em memória e quase nunca ajuda ali) e a rodar a limpeza de `planilha_id` em paralelo com o login em vez de antes — cada operação assíncrona antes do popup era risco de o WebKit não reconhecê-lo mais como originado do clique.
- Nova interface `AutorizadorGoogle.entrarInterativo`, implementada por plataforma; nativo (Android) mantém exatamente o comportamento anterior (`GoogleSignIn.authenticate()` + `authorizationClient`).
- `mensagemErroLoginGoogle` ganhou uma mensagem específica para popup bloqueado pelo navegador.
- Sem cobertura automatizada — o fluxo depende do SDK real do Google no navegador. Validar manualmente em Chrome desktop, Safari/iOS e Chrome/iOS: primeiro login, refresh de página, sessão acima de 1h.
- **Os links de Termos de Uso e Política de Privacidade na tela de login não abriam nada.** Os dois eram `TextSpan` apenas estilizados (cor da marca, negrito) dentro do `Text.rich` do consentimento — e `TextSpan` sem `recognizer` não recebe toque. Na prática o usuário tinha de aceitar os termos sem ter como lê-los, o oposto do que a tela promete e do que a LGPD exige. As páginas sempre existiram publicadas (`/termos.html` e `/privacidade.html`, com rewrites próprios no `firebase.json`); faltava o caminho até elas.
- `TapGestureRecognizer` em cada span, criado no `initState` e liberado no `dispose` — criá-lo no `build` vazaria um por quadro. Os links ganharam sublinhado, porque cor sozinha não sinaliza que algo é clicável.
- Abre com `LaunchMode.externalApplication`: nova aba no web, navegador do sistema no Android. Em nenhum dos dois o usuário perde a tela de login nem o estado do checkbox. Se o launcher falhar, um snackbar mostra a URL para acesso manual.
Expand Down
93 changes: 53 additions & 40 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ fisio-home-care/
│ └── mensagens_erro_google.dart # Mapear erros Google
│
├── test/
│ ├── unitarios/ # 173 testes — lógica pura
│ ├── unitarios/ # 174 testes — lógica pura
│ │ ├── auxiliares/
│ │ │ ├── fakes.dart # Mocks reutilizados
│ │ │ └── servidor_google_fake.dart # Drive/Sheets falso no nível do HTTP
Expand All @@ -130,7 +130,7 @@ fisio-home-care/
│ │ ├── validador_cpf_test.dart (9 testes)
│ │ ├── utilitarios_data_test.dart (23 testes)
│ │ ├── gerador_id_test.dart (8 testes — 100% cobertura)
│ │ └── mensagens_erro_google_test.dart (7 testes — diagnóstico de login)
│ │ └── mensagens_erro_google_test.dart (8 testes — diagnóstico de login)
│ │
│ └── widgets/ # 191 testes — UI + componentes
│ ├── componentes/
Expand Down Expand Up @@ -166,8 +166,8 @@ fisio-home-care/
│ ├── chaves.md # (no .gitignore) — credenciais
│ └── testes/
│ ├── README.md # Índice de testes
│ ├── VISAO_GERAL.md # Overview 364 testes
│ ├── UNITARIOS.md # Detalhe dos 173 unitários
│ ├── VISAO_GERAL.md # Overview 365 testes
│ ├── UNITARIOS.md # Detalhe dos 174 unitários
│ └── WIDGETS.md # Detalhe dos 191 widgets
│
├── QA/
Expand Down Expand Up @@ -309,17 +309,17 @@ Paciente.calcularIdade() // ✓ delega para UtilitariosData

---

## Testes (364 testes automatizados)
## Testes (365 testes automatizados)

### Estrutura

```
test/
├── unitarios/ (173 testes)
├── unitarios/ (174 testes)
│ ├── auxiliares/ — fakes.dart (mocks reutilizados)
│ ├── modelos/ — 25 testes (serialização, transformação)
│ ├── servicos/ — 50 testes (preferencias, Drive, Sheets, repositório)
│ └── utilitarios/ — 93 testes (validadores, data, CPF, gerador_id, erros do Google)
│ └── utilitarios/ — 94 testes (validadores, data, CPF, gerador_id, erros do Google)
│
└── widgets/ (191 testes)
├── telas/ — 13 telas principais (UI, interação)
Expand Down Expand Up @@ -420,41 +420,54 @@ flutter run
| Todas as telas principais possuem testes de widget | — | ✅ |
| Busca da planilha por `name contains` adotava cópias do Drive como base | Resolvido (nome exato + aviso de duplicata em Configurações) | ✅ |
| `lerVersaoEsquema` lia a linha inteira e tratava toda planilha como versão 1 | Resolvido (lê a primeira célula) | ✅ |
| Botão de login do web é o do Google (platform view) sobreposto ao desenho | Trade-off aceito para preservar o visual — ver regras abaixo | 🟡 |
| Login no web precisava de 2 popups (identidade + autorização); o 2º não nascia de um clique e o WebKit do iOS (todo navegador no iOS) bloqueava sempre — cliente relatou não conseguir entrar | Resolvido em 2026-08-13 (login interativo virou 1 popup só — ver regras abaixo) | ✅ |

### ⚠️ Login no web — regras que não podem ser quebradas

No web o Google Identity Services **não permite login iniciado pela aplicação**
(`GoogleSignIn.authenticate()` lança `UnsupportedError`). A `TelaLogin` sobrepõe
o botão do SDK, quase transparente, ao botão desenhado. Nada disso aparece nos
testes: fora do web `construirBotaoGoogleRenderizado` vira `SizedBox.shrink()`.

- **`Opacity` tem que ser > 0.** Com alpha 0 o Flutter não pinta o filho
(`RenderOpacity.paint`), e uma platform view só entra no DOM quando pintada —
o botão deixa de existir e o clique cai no vazio.
- **`IgnorePointer` não segura clique de platform view.** Ele só afeta o
hit-test do Flutter; o elemento DOM recebe o evento direto. Para bloquear de
verdade (ex.: enquanto os termos não são aceitos), **não construir o widget**.
- **O GIS limita o botão a 400px** e centraliza. O desenho por baixo tem que
respeitar a mesma largura, senão as extremidades não respondem ao clique.
- **A autorização no web não usa o `authorizationClient` do plugin.**
`google_sign_in_web` amarra hint e prompt (`src/gis_client.dart`):
`prompt: userHint == null ? '' : 'select_account'`. Com hint ele força o
seletor; sem hint manda `login_hint: null` e o Google pergunta a conta de
novo. Como o login web tem duas etapas (botão do GIS + token client), o
usuário escolhia a conta **duas vezes**. `lib/servicos/autorizador_google_web.dart`
monta o token client direto (`google_identity_services_web`) com
`login_hint: email` **e** `prompt: ''` — o par que o plugin não permite.
Fora do web, `autorizador_google_stub.dart` mantém `conta.authorizationClient`.
- **Cache de token é por e-mail e a requisição é deduplicada.** No web o token
vale 1h e não se renova sozinho; sem o cache por conta, trocar de usuário
reaproveitaria o token do anterior, e sem a deduplicação duas chamadas
simultâneas às APIs abririam dois popups.

Como diagnosticar: rodar o app e inspecionar o DOM — `flt-platform-view` para
saber se o botão existe, e `elementFromPoint(...).closest('[role="button"]')` nas
bordas e no centro para saber se ele recebe o clique. Screenshot não revela nada
disso.
Até 2026-08-13 o login no web usava dois popups: identidade pelo botão do GIS
(sobreposto, quase transparente, ao botão desenhado) e depois autorização dos
escopos por um token client separado, disparado automaticamente pelo stream
`authenticationEvents`. Esse segundo popup não nascia de um clique **nesta
página** — o clique original tinha sido consumido pela interação inteira com o
primeiro popup, que leva segundos. O Chrome tolerava isso na maior parte das
vezes (daí o app "funcionar" depois de tentar duas vezes); o WebKit do
iOS — usado por **todo** navegador no iOS, Chrome incluso, por exigência da
Apple — bloqueava sempre. Foi assim que um cliente ficou completamente incapaz
de entrar pelo iPhone.

A correção: `entrar()` agora pede conta **e** consentimento dos escopos num
popup só (`AutorizadorGoogleWeb.entrarInterativo`,
`lib/servicos/autorizador_google_web.dart`), chamado sem nenhum `await` na
frente — ainda dentro da mesma pilha síncrona do toque no botão do App
(`tela_login.dart` → `provedor_autenticacao.dart` → `servico_autenticacao_google.dart`
→ `autorizador_google_web.dart`, sem operação assíncrona real entre o clique e
`requestAccessToken()`). Identidade (nome/e-mail) vem do próprio access token,
via `https://www.googleapis.com/oauth2/v3/userinfo` — o escopo `email` já
estava entre os pedidos. Não existe mais botão do GIS sobreposto ao desenho;
`_BotaoEntrarGoogle` é um `GestureDetector` comum.

- **Nada de `await` real antes de `cliente.requestAccessToken()`.** Qualquer
operação de rede genuína nesse caminho (não uma `Future` já resolvida) corre
o risco de o Safari/WebKit deixar de reconhecer o popup como originado do
clique. É por isso que `entrarComGoogle()` (`provedor_autenticacao.dart`)
pula `tentarRestaurarSessao()` no web (ela quase nunca ajuda ali — o cache do
token é em memória e não sobrevive a um reload) e roda `limparPlanilhaId()`
em paralelo com `servico.entrar()` em vez de antes.
- **`prompt: 'select_account'` no popup único.** Como não há mais um primeiro
popup escolhendo a conta, este precisa perguntar sempre — é o único momento
em que o usuário escolhe entre contas.
- **`garantirAutorizacao`/`headers`/`headersSeAutorizado` continuam existindo**
só para **renovar** o token depois do login (expira em 1h no web). Aí sim
usam `login_hint: email` com `prompt: ''` — a conta já é conhecida e não há
necessidade de abrir UI. Não confundir com o login interativo.
- **Cache de token é por e-mail e a requisição é deduplicada.** Sem o cache por
conta, trocar de usuário reaproveitaria o token do anterior; sem a
deduplicação, duas chamadas simultâneas às APIs abririam dois popups.

Como diagnosticar um problema de login no web: reproduzir num navegador WebKit
(Safari, ou qualquer navegador no iOS) além do Chrome — é onde o bloqueio de
popup é mais rígido. Se o popup não abrir, checar se algum `await` real foi
reintroduzido no caminho entre o toque no botão e `requestAccessToken()`.

---

Expand Down Expand Up @@ -561,7 +574,7 @@ make release-prod # mescla develop → main → dispara deploy de produção (p
| `documentacao/ESPECIFICACOES_TELAS.md` | Requisitos funcionais das telas | ✅ |
| `documentacao/SEGURANCA_E_DADOS.md` | LGPD, OAuth, modelo BYODB | ✅ |
| `documentacao/IMPLEMENTAR.md` | Roadmap priorizado | ✅ |
| `documentacao/testes/` | 364 testes automatizados | ✅ |
| `documentacao/testes/` | 365 testes automatizados | ✅ |
| `documentacao/CI_CD.md` | Pipeline GitHub Actions: fluxo, secrets, uso e troubleshooting | ✅ |
| `QA/qa.md` | Script QA manual (não é E2E) | ✅ |

Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
[![Deploy de Produção](https://github.com/lacerdaRodrigo/alta/actions/workflows/deploy-prod.yml/badge.svg)](https://github.com/lacerdaRodrigo/alta/actions/workflows/deploy-prod.yml)
[![Deploy de Testes](https://github.com/lacerdaRodrigo/alta/actions/workflows/deploy-preview.yml/badge.svg)](https://github.com/lacerdaRodrigo/alta/actions/workflows/deploy-preview.yml)
[![Flutter](https://img.shields.io/badge/Flutter-3.44.1-02569B?logo=flutter&logoColor=white)](https://flutter.dev)
[![Testes](https://img.shields.io/badge/testes-364%20passando-success)](documentacao/testes/)
[![Testes](https://img.shields.io/badge/testes-365%20passando-success)](documentacao/testes/)
[![Licença](https://img.shields.io/badge/licen%C3%A7a-propriet%C3%A1ria-lightgrey)](LICENSE)

[**Acessar o aplicativo**](https://app-fisio-care-2.web.app) · [Documentação](documentacao/) · [Modelo de dados](documentacao/MODELO_DADOS.md) · [Segurança e LGPD](documentacao/SEGURANCA_E_DADOS.md)
Expand Down Expand Up @@ -165,7 +165,7 @@ make dev-web # http://localhost:5000

## Testes

**364 testes automatizados** — 173 unitários + 191 de widget, cobrindo as 14
**365 testes automatizados** — 174 unitários + 191 de widget, cobrindo as 14
telas, os 3 modelos, os serviços de Drive/Sheets/repositório e os validadores.

```bash
Expand Down Expand Up @@ -234,7 +234,7 @@ infraestrutura do mantenedor.
| [`documentacao/SEGURANCA_E_DADOS.md`](documentacao/SEGURANCA_E_DADOS.md) | LGPD, OAuth e o modelo BYODB |
| [`documentacao/CI_CD.md`](documentacao/CI_CD.md) | Pipeline, secrets e troubleshooting |
| [`documentacao/IMPLEMENTAR.md`](documentacao/IMPLEMENTAR.md) | Roadmap priorizado |
| [`documentacao/testes/`](documentacao/testes/) | Os 364 testes, um a um |
| [`documentacao/testes/`](documentacao/testes/) | Os 365 testes, um a um |
| [`QA/qa.md`](QA/qa.md) | Roteiro de QA manual |
| [`CHANGELOG.md`](CHANGELOG.md) | Histórico de versões |

Expand Down
2 changes: 1 addition & 1 deletion documentacao/IMPLEMENTAR.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
> registrado no [`CHANGELOG.md`](../CHANGELOG.md); o estado atual da arquitetura
> está no [`CLAUDE.md`](../CLAUDE.md).

**Status:** MVP funcional completo em produção (web) · 364 testes automatizados
**Status:** MVP funcional completo em produção (web) · 365 testes automatizados
**Atualizado em:** 2026-07-29

---
Expand Down
31 changes: 18 additions & 13 deletions documentacao/LOGIN_SCREEN_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,24 @@ Esta tela é a porta de entrada do aplicativo, responsável pela autenticação

## 1. Requisitos Funcionais
* **Autenticação:** Integração obrigatória com Google Sign-In[cite: 2, 3].
* Na Web, o login deve usar o botão oficial do Google renderizado pelo SDK.
**Como está implementado:** o botão do SDK é de fato quem dispara o login,
mas fica sobreposto (praticamente transparente) ao botão desenhado pelo
design system, para preservar a identidade visual da tela. É o SDK que
recebe o clique — o desenho por baixo é apenas aparência.
* Após selecionar a conta Google, a autorização de acesso ao Drive/Sheets ocorre em uma segunda ação explícita do usuário para evitar bloqueio de popup pelo navegador.
Essa etapa **não pode pedir nova escolha de conta**: o App monta o token
client do GIS com o e-mail escolhido como `login_hint` e `prompt` vazio
(`lib/servicos/autorizador_google_web.dart`), então o Google vai direto à
conta e nem exibe a janela quando o consentimento já existe. O
`authorizationClient` do `google_sign_in_web` não serve aqui — ele amarra
o hint a `prompt: 'select_account'`, e sem hint o Google pergunta a conta
de novo quando há mais de uma sessão no navegador.
* Na Web, o botão "Continuar com Google" é desenhado pelo próprio app (design
system) — não há mais botão do SDK sobreposto. Ao tocar, o App abre um
único popup do OAuth2 do Google Identity Services que resolve identidade
(escolha de conta) e consentimento dos escopos Drive/Sheets ao mesmo tempo
(`AutorizadorGoogleWeb.entrarInterativo`, `lib/servicos/autorizador_google_web.dart`).
Nome e e-mail vêm do próprio access token, consultando o endpoint
`userinfo` do Google.
* **Por que não usar dois popups (identidade separada de autorização):** a
versão anterior tinha um segundo popup, disparado automaticamente depois
da identidade chegar por um stream — sem clique nenhum nesta página. O
Chrome tolerava isso na maioria das vezes; o WebKit do iOS (todo
navegador no iOS, por exigência da Apple) bloqueava sempre, impedindo o
login por completo nesses dispositivos. Um popup só, chamado sem nenhum
`await` real entre o toque do usuário e a abertura do popup, evita o
problema — ver `CLAUDE.md` para o histórico e os detalhes técnicos.
* A renovação do token (expira em 1h no web) continua usando
`login_hint: email` com `prompt` vazio, sem abrir UI, já que a conta é
conhecida nesse ponto.
* **Links legais:** "Política de Privacidade" e "Termos de Uso" no texto do
consentimento abrem as páginas publicadas (`/privacidade.html` e
`/termos.html`, URLs em `lib/utilitarios/links_legais.dart`) em aba/navegador
Expand Down
2 changes: 1 addition & 1 deletion documentacao/SEGURANCA_E_DADOS.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ O App adota o modelo **Bring Your Own Database**: todos os dados são armazenado

| Camada | Mecanismo |
|---|---|
| **Login** | Google Sign-In (OAuth 2.0), `google_sign_in` 7.x. O App nunca acessa a senha do usuário. No web o login parte do botão renderizado pelo próprio Google (o SDK não permite fluxo iniciado pela aplicação); no Android, do fluxo nativo. |
| **Login** | Google Sign-In (OAuth 2.0), `google_sign_in` 7.x. O App nunca acessa a senha do usuário. No web, o toque no botão do App abre diretamente um popup do OAuth2 do Google (identidade + consentimento dos escopos num único passo); no Android, do fluxo nativo. |
| **Escopos** | `email`, `drive.file` e `spreadsheets`. `drive.file` restringe o acesso aos arquivos que o próprio App criou — não alcança outros arquivos, fotos ou pastas do Drive. `spreadsheets` é exigido pela Sheets API para ler e escrever na planilha do usuário. |
| **Token** | Access token temporário, resolvido a cada requisição pelo `AutorizadorGoogle` (`lib/servicos/autorizador_google.dart`). Não é armazenado de forma persistente pelo App: no Android vem de `conta.authorizationClient`; no web fica só em memória, indexado pelo e-mail da conta, e expira em 1h. |
| **Autenticação vs. autorização** | São etapas separadas: identificar o usuário não concede, por si só, acesso ao Drive/Sheets. O App só considera a sessão válida depois de confirmar a autorização dos escopos. |
Expand Down
2 changes: 1 addition & 1 deletion documentacao/testes/COBERTURA_ANALISE.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# 📊 Análise de Cobertura de Testes

> ⚠️ **ARQUIVADO — snapshot histórico de 2026-06-16.** Todas as 12 telas
> principais já têm testes de widget hoje (364 testes no total). Para os
> principais já têm testes de widget hoje (365 testes no total). Para os
> números atuais, veja [`VISAO_GERAL.md`](./VISAO_GERAL.md). Este documento
> fica só como registro de como a cobertura evoluiu (era 60% de telas
> testadas nessa data) — não reflete o estado atual do projeto.
Expand Down
Loading
Loading