From a2a4a959545e2a5109ff5e06a1eed6a8697075f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 17:10:06 +0000 Subject: [PATCH] =?UTF-8?q?Corrige=20login=20web=20quebrado=20no=20iOS=20e?= =?UTF-8?q?=20a=20exig=C3=AAncia=20de=20duas=20tentativas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O login web usava 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 por um stream, sem clique nenhum nesta página. O Chrome tolerava isso na maioria das vezes (por isso "funcionava" só na segunda tentativa); o WebKit do iOS — usado por todo navegador no iOS, Chrome incluso — bloqueava sempre, impedindo o login por completo. AutorizadorGoogleWeb.entrarInterativo agora pede conta e consentimento dos escopos num popup só, chamado sem nenhum await real entre o toque do usuário e requestAccessToken(), preservando a ativação exigida pelo navegador. Identidade vem do próprio access token via userinfo do Google. O botão do GIS sobreposto ao desenho foi removido (arquivos componentes/botao_google_renderizado*.dart), e entrarComGoogle() deixou de atrasar o popup com a restauração de sessão (inútil no web) e a limpeza de planilha_id (agora roda em paralelo). Documentação e contagem de testes atualizadas. --- CHANGELOG.md | 7 + CLAUDE.md | 93 +++++++------ README.md | 6 +- documentacao/IMPLEMENTAR.md | 2 +- documentacao/LOGIN_SCREEN_SPEC.md | 31 +++-- documentacao/SEGURANCA_E_DADOS.md | 2 +- documentacao/testes/COBERTURA_ANALISE.md | 2 +- documentacao/testes/README.md | 6 +- documentacao/testes/UNITARIOS.md | 7 +- documentacao/testes/VISAO_GERAL.md | 8 +- lib/componentes/botao_google_renderizado.dart | 13 -- .../botao_google_renderizado_stub.dart | 7 - .../botao_google_renderizado_web.dart | 21 --- lib/provedores/provedor_autenticacao.dart | 54 +++++--- lib/servicos/autorizador_google_base.dart | 24 ++++ lib/servicos/autorizador_google_stub.dart | 13 ++ lib/servicos/autorizador_google_web.dart | 128 ++++++++++++++--- lib/servicos/servico_autenticacao_google.dart | 40 ++++-- lib/telas/tela_login.dart | 130 +++++++----------- lib/utilitarios/mensagens_erro_google.dart | 6 + .../mensagens_erro_google_test.dart | 8 ++ 21 files changed, 360 insertions(+), 248 deletions(-) delete mode 100644 lib/componentes/botao_google_renderizado.dart delete mode 100644 lib/componentes/botao_google_renderizado_stub.dart delete mode 100644 lib/componentes/botao_google_renderizado_web.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index eeee19e..12a5765 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index fa311e2..53da08c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -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/ @@ -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/ @@ -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) @@ -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()`. --- @@ -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) | ✅ | diff --git a/README.md b/README.md index 1e3862e..e8c2ccb 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 @@ -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 | diff --git a/documentacao/IMPLEMENTAR.md b/documentacao/IMPLEMENTAR.md index bc76e54..d6ee8c2 100644 --- a/documentacao/IMPLEMENTAR.md +++ b/documentacao/IMPLEMENTAR.md @@ -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 --- diff --git a/documentacao/LOGIN_SCREEN_SPEC.md b/documentacao/LOGIN_SCREEN_SPEC.md index 1db128c..086369a 100644 --- a/documentacao/LOGIN_SCREEN_SPEC.md +++ b/documentacao/LOGIN_SCREEN_SPEC.md @@ -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 diff --git a/documentacao/SEGURANCA_E_DADOS.md b/documentacao/SEGURANCA_E_DADOS.md index 39ad42e..ce3ed3e 100644 --- a/documentacao/SEGURANCA_E_DADOS.md +++ b/documentacao/SEGURANCA_E_DADOS.md @@ -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. | diff --git a/documentacao/testes/COBERTURA_ANALISE.md b/documentacao/testes/COBERTURA_ANALISE.md index 25fabed..b58e156 100644 --- a/documentacao/testes/COBERTURA_ANALISE.md +++ b/documentacao/testes/COBERTURA_ANALISE.md @@ -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. diff --git a/documentacao/testes/README.md b/documentacao/testes/README.md index 0294e44..c58552a 100644 --- a/documentacao/testes/README.md +++ b/documentacao/testes/README.md @@ -1,13 +1,13 @@ # 📚 Documentação de Testes -Guia completo dos 364 testes automatizados do Alta. +Guia completo dos 365 testes automatizados do Alta. --- ## 📖 Documentos 1. **[VISAO_GERAL.md](./VISAO_GERAL.md)** — Overview, estrutura, como rodar -2. **[UNITARIOS.md](./UNITARIOS.md)** — 173 testes unitários (validadores, modelos, serviços) +2. **[UNITARIOS.md](./UNITARIOS.md)** — 174 testes unitários (validadores, modelos, serviços) 3. **[WIDGETS.md](./WIDGETS.md)** — 191 testes de widget (telas, componentes, utilitários) --- @@ -39,7 +39,7 @@ flutter test test/unitarios/utilitarios/validadores_test.dart | Unit — Serviços | 50 | 14% | | Widget — Telas | 164 | 45% | | Widget — Componentes/Utilitários | 27 | 8% | -| **TOTAL** | **364** | **100%** | +| **TOTAL** | **365** | **100%** | --- diff --git a/documentacao/testes/UNITARIOS.md b/documentacao/testes/UNITARIOS.md index 4e71208..ad10390 100644 --- a/documentacao/testes/UNITARIOS.md +++ b/documentacao/testes/UNITARIOS.md @@ -1,12 +1,12 @@ -# 📁 Testes Unitários (173 testes) +# 📁 Testes Unitários (174 testes) Lógica pura: validação de entrada, transformação de dados, cálculos. --- -## test/unitarios/utilitarios/ (93 testes) +## test/unitarios/utilitarios/ (94 testes) -### mensagens_erro_google_test.dart (7 testes) +### mensagens_erro_google_test.dart (8 testes) Tradução de erros do Google Sign-In para mensagens acionáveis na UI. @@ -17,6 +17,7 @@ Tradução de erros do Google Sign-In para mensagens acionáveis na UI. ✓ erro 10 aponta para o cliente OAuth, não para o Firebase ✓ erro 12500 orienta configurar o provedor Google ✓ permissão negada orienta a lista de usuários de teste +✓ popup bloqueado pelo navegador orienta liberar pop-ups ✓ erro desconhecido cai na mensagem genérica ``` diff --git a/documentacao/testes/VISAO_GERAL.md b/documentacao/testes/VISAO_GERAL.md index 71704e6..3fe4c1a 100644 --- a/documentacao/testes/VISAO_GERAL.md +++ b/documentacao/testes/VISAO_GERAL.md @@ -1,6 +1,6 @@ # 📋 Testes Automatizados — Visão Geral -**Status:** ✅ 364 testes passando +**Status:** ✅ 365 testes passando --- @@ -15,7 +15,7 @@ O projeto utiliza **apenas testes unitários e de widget** (sem E2E automatizado | **Unit — Serviços** | 50 | 14% | Preferências, Drive, Sheets e repositório de dados (via `ServidorGoogleFake`) | | **Widget — Telas** | 164 | 45% | UI, interação, estados visuais | | **Widget — Componentes/Utilitários** | 27 | 8% | Modal de detalhes, rodapé versão, ações de agendamento | -| **TOTAL** | **364** | **100%** | — | +| **TOTAL** | **365** | **100%** | — | --- @@ -23,7 +23,7 @@ O projeto utiliza **apenas testes unitários e de widget** (sem E2E automatizado ``` 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 @@ -69,7 +69,7 @@ test/ ## Como Rodar ```bash -# Todos os 364 testes +# Todos os 365 testes flutter test # Apenas unitários diff --git a/lib/componentes/botao_google_renderizado.dart b/lib/componentes/botao_google_renderizado.dart deleted file mode 100644 index 67a8fe9..0000000 --- a/lib/componentes/botao_google_renderizado.dart +++ /dev/null @@ -1,13 +0,0 @@ -/// Botão de login renderizado pelo Google Identity Services. -/// -/// No web, o GIS só aceita login iniciado pelo widget que ele mesmo renderiza — -/// `GoogleSignIn.authenticate()` lança `UnsupportedError` e -/// `supportsAuthenticate()` devolve `false`. Fora do web nada é renderizado, -/// porque o login é disparado programaticamente por `entrar()`. -/// -/// O import condicional mantém `package:google_sign_in_web` fora do build -/// Android/iOS e dos testes, que rodam na VM do Dart. -library; - -export 'botao_google_renderizado_stub.dart' - if (dart.library.js_interop) 'botao_google_renderizado_web.dart'; diff --git a/lib/componentes/botao_google_renderizado_stub.dart b/lib/componentes/botao_google_renderizado_stub.dart deleted file mode 100644 index c7ab39d..0000000 --- a/lib/componentes/botao_google_renderizado_stub.dart +++ /dev/null @@ -1,7 +0,0 @@ -import 'package:flutter/widgets.dart'; - -/// Fora do web não há botão do Google para renderizar: `entrar()` dispara o -/// fluxo nativo diretamente. Ver `botao_google_renderizado.dart`. -Widget construirBotaoGoogleRenderizado({double? larguraMinima}) { - return const SizedBox.shrink(); -} diff --git a/lib/componentes/botao_google_renderizado_web.dart b/lib/componentes/botao_google_renderizado_web.dart deleted file mode 100644 index b9bd3af..0000000 --- a/lib/componentes/botao_google_renderizado_web.dart +++ /dev/null @@ -1,21 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:google_sign_in_web/web_only.dart' as google_web; - -/// Botão oficial do Google Identity Services. Ver `botao_google_renderizado.dart`. -/// -/// Renderizado no tamanho grande e retangular para preencher a área do botão -/// visual do app, que fica por baixo: o clique precisa cair sobre este widget, -/// não sobre o desenho. -Widget construirBotaoGoogleRenderizado({double? larguraMinima}) { - return google_web.renderButton( - configuration: google_web.GSIButtonConfiguration( - type: google_web.GSIButtonType.standard, - theme: google_web.GSIButtonTheme.outline, - size: google_web.GSIButtonSize.large, - text: google_web.GSIButtonText.continueWith, - shape: google_web.GSIButtonShape.rectangular, - logoAlignment: google_web.GSIButtonLogoAlignment.left, - minimumWidth: larguraMinima, - ), - ); -} diff --git a/lib/provedores/provedor_autenticacao.dart b/lib/provedores/provedor_autenticacao.dart index 071a680..de8bfdc 100644 --- a/lib/provedores/provedor_autenticacao.dart +++ b/lib/provedores/provedor_autenticacao.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:developer' as developer; +import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../servicos/preferencias.dart'; @@ -96,10 +97,11 @@ class AutenticacaoNotificador extends Notifier { } void _autenticarComSessao(SessaoGoogle sessao) { - // Não limpar `planilha_id` aqui: `entrarComGoogle` já faz isso (com - // `await`) antes do login. Uma limpeza solta e não aguardada pode terminar - // depois de `obterPlanilhaId()` ter gravado o ID da planilha recém-criada, - // apagando a referência e forçando uma nova busca no Drive. + // Não limpar `planilha_id` aqui: `entrarComGoogle` já garante (com + // `await`) que a limpeza terminou antes de qualquer sessão ser publicada. + // Uma limpeza solta e não aguardada pode terminar depois de + // `obterPlanilhaId()` ter gravado o ID da planilha recém-criada, apagando + // a referência e forçando uma nova busca no Drive. state = state.copiarCom( estaAutenticado: true, estaCarregando: false, @@ -121,31 +123,43 @@ class AutenticacaoNotificador extends Notifier { } state = state.copiarCom(estaCarregando: true, mensagemErro: null); - await Preferencias.limparPlanilhaId(); final servico = ref.read(provedorServicoAutenticacaoGoogle); try { - // Tenta restaurar a sessão anterior antes do fluxo interativo. Na API - // 7.x, `tentarRestaurarSessao` já valida a autorização dos escopos e - // devolve `null` quando só há identidade sem access token — o app não - // precisa mais checar isso por fora. - final sessaoAnterior = await servico.tentarRestaurarSessao(); - if (sessaoAnterior != null) { - _autenticarComSessao(sessaoAnterior); - return; + // Tenta restaurar a sessão anterior antes do fluxo interativo — mas só + // no nativo. No web esse cache fica em memória e nunca sobrevive a um + // recarregamento de página (é sempre `null` no caso comum), então a + // checagem quase nunca evita o popup; ela só atrasaria, com uma + // operação de rede de verdade, o toque do usuário até `servico.entrar()` + // — e no Safari/WebKit do iOS isso já é o bastante para o navegador + // deixar de reconhecer o popup como originado do clique e bloqueá-lo. + // Na API 7.x, `tentarRestaurarSessao` já valida a autorização dos + // escopos e devolve `null` quando só há identidade sem access token — + // o app não precisa mais checar isso por fora. + if (!kIsWeb) { + final sessaoAnterior = await servico.tentarRestaurarSessao(); + if (sessaoAnterior != null) { + await Preferencias.limparPlanilhaId(); + _autenticarComSessao(sessaoAnterior); + return; + } } - // No web o login não pode ser disparado por código: o Google Identity - // Services exige que parta do botão que ele mesmo renderiza. A - // `TelaLogin` sobrepõe esse botão ao visual do app, e o login chega pelo - // stream `sessoesConectadas`. Aqui só encerramos o estado de carregamento - // para o botão do Google voltar a ficar utilizável. if (!servico.suportaLoginProgramatico) { state = state.copiarCom(estaCarregando: false); return; } + // `servico.entrar()` abre o popup de login do Google (no web) — precisa + // ser chamado o quanto antes, sem `await` na frente, para não arriscar + // que o navegador deixe de reconhecer a chamada como originada do toque + // do usuário e bloqueie o popup (é rígido demais nisso no Safari/WebKit + // do iOS). Por isso a limpeza de `planilha_id` roda em paralelo com o + // login em vez de antes dele — mas ainda é aguardada até o fim antes de + // qualquer sessão ser publicada, preservando a garantia descrita em + // `_autenticarComSessao`. + // // `servico.entrar()` já publica a sessão em `sessoesConectadas`, que o // listener registrado em `build()` consome chamando `_autenticarComSessao` // (atualiza `state` uma única vez). Definir `state` de novo aqui causaria @@ -154,7 +168,9 @@ class AutenticacaoNotificador extends Notifier { // desmontada com o carregamento de dados ainda em andamento, e o uso de // `ref` depois do `await` nesse carregamento lança // "Bad state: ... unmounted". - await servico.entrar(); + final entrando = servico.entrar(); + await Preferencias.limparPlanilhaId(); + await entrando; } catch (e, stackTrace) { developer.log( 'Erro ao fazer login com Google', diff --git a/lib/servicos/autorizador_google_base.dart b/lib/servicos/autorizador_google_base.dart index 26c4a96..fefdc64 100644 --- a/lib/servicos/autorizador_google_base.dart +++ b/lib/servicos/autorizador_google_base.dart @@ -33,4 +33,28 @@ abstract class AutorizadorGoogle { GoogleSignInAccount conta, List escopos, ); + + /// Login interativo completo — identidade + autorização dos escopos — num + /// único fluxo, disparado pelo toque do usuário no botão do App. + /// + /// Cada plataforma resolve à sua maneira: no nativo, `GoogleSignIn.authenticate()` + /// seguido de `authorizationClient`, sem UI extra. No web, um único popup do + /// OAuth2 do GIS cobre escolha de conta **e** consentimento — ver + /// `autorizador_google_web.dart` para o porquê de não usar mais o botão de + /// identidade renderizado pelo GIS. + Future entrarInterativo(List escopos); +} + +/// Resultado de [AutorizadorGoogle.entrarInterativo]: identidade da conta e +/// como obter cabeçalhos de autorização para ela. +class ContaAutenticada { + final String nomeUsuario; + final String email; + final Future> Function() obterHeaders; + + const ContaAutenticada({ + required this.nomeUsuario, + required this.email, + required this.obterHeaders, + }); } diff --git a/lib/servicos/autorizador_google_stub.dart b/lib/servicos/autorizador_google_stub.dart index b9284f3..e4a20f6 100644 --- a/lib/servicos/autorizador_google_stub.dart +++ b/lib/servicos/autorizador_google_stub.dart @@ -44,4 +44,17 @@ class AutorizadorGoogleNativo implements AutorizadorGoogle { } return headers; } + + @override + Future entrarInterativo(List escopos) async { + final conta = await GoogleSignIn.instance.authenticate( + scopeHint: escopos, + ); + await garantirAutorizacao(conta, escopos); + return ContaAutenticada( + nomeUsuario: conta.displayName ?? conta.email, + email: conta.email, + obterHeaders: () => headers(conta, escopos), + ); + } } diff --git a/lib/servicos/autorizador_google_web.dart b/lib/servicos/autorizador_google_web.dart index 4b39628..fccf982 100644 --- a/lib/servicos/autorizador_google_web.dart +++ b/lib/servicos/autorizador_google_web.dart @@ -1,37 +1,37 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:developer' as developer; import 'package:google_identity_services_web/oauth2.dart' as oauth2; import 'package:google_sign_in/google_sign_in.dart'; +import 'package:http/http.dart' as http; import 'autorizador_google_base.dart'; /// Autorização no web falando direto com o Google Identity Services. /// -/// **Por que não usar o `authorizationClient` do `google_sign_in`:** o plugin -/// monta o token client amarrando o hint de usuário ao `prompt` -/// (`google_sign_in_web/lib/src/gis_client.dart`): +/// **Por que o login interativo não usa o botão de identidade do GIS +/// (`GoogleSignIn`/`google_sign_in_web`):** esse botão só resolve +/// identidade — para autorizar Drive/Sheets seria preciso um **segundo** +/// popup, disparado depois que a identidade chega pelo stream +/// `authenticationEvents`. Esse segundo popup não nasce de um clique nesta +/// página (o clique original foi consumido pela interação inteira com o +/// primeiro popup, que leva segundos): o Chrome tolera isso na maioria das +/// vezes, mas o WebKit do iOS — usado por **todo** navegador no iOS, Chrome +/// incluso, por exigência da Apple — bloqueia esse popup sempre. Foi isso que +/// impedia o login de completar no iPhone. /// -/// ```dart -/// prompt: userHint == null ? '' : 'select_account', -/// login_hint: userHint, -/// ``` +/// A correção é não ter um segundo popup: [entrarInterativo] pede conta + +/// consentimento dos escopos num popup só, chamado sem nenhum `await` antes +/// — ainda dentro da mesma pilha síncrona do toque do usuário no botão do +/// App — e depois busca nome/e-mail com o próprio access token +/// (`https://www.googleapis.com/oauth2/v3/userinfo`), já que o escopo +/// `email` está entre os pedidos. /// -/// Os dois caminhos possíveis mostram o seletor de contas de novo quando o -/// navegador tem mais de uma sessão Google aberta: -/// - `GoogleSignIn.instance.authorizationClient` (sem hint) manda `prompt: ''` -/// mas também `login_hint: null` — o Google não sabe qual conta usar e -/// pergunta; -/// - `conta.authorizationClient` manda o hint, e por isso o plugin força -/// `prompt: 'select_account'` — que é literalmente "abra o seletor". -/// -/// Como no web o login tem duas etapas obrigatórias (identidade pelo botão do -/// GIS + autorização pelo token client), o usuário acabava escolhendo a conta -/// duas vezes por login. Aqui montamos o par que o plugin não permite — -/// `login_hint` com `prompt` vazio — então o segundo passo vai direto para a -/// conta escolhida no botão e nem aparece quando o consentimento já existe. -/// -/// No Android nada disso se aplica: ver `autorizador_google_stub.dart`. +/// `garantirAutorizacao`/`headers`/`headersSeAutorizado` continuam existindo +/// para **renovar** o token depois do login (ele expira em 1h no web) — aí +/// sim usamos `login_hint` com `prompt` vazio, porque a conta já é conhecida +/// e não há necessidade de abrir UI nenhuma. AutorizadorGoogle criarAutorizadorGoogle({required String clientIdWeb}) => AutorizadorGoogleWeb(clientIdWeb); @@ -94,6 +94,90 @@ class AutorizadorGoogleWeb implements AutorizadorGoogle { return _cabecalhos(await _token(conta.email, escopos)); } + @override + Future entrarInterativo(List escopos) { + final resultado = Completer(); + + Future concluir( + String token, + int? expiresIn, + List escoposConcedidos, + ) async { + final resposta = await http.get( + Uri.parse('https://www.googleapis.com/oauth2/v3/userinfo'), + headers: {'Authorization': 'Bearer $token'}, + ); + if (resposta.statusCode != 200) { + throw StateError('Não foi possível obter os dados da conta Google.'); + } + final identidade = jsonDecode(resposta.body) as Map; + final email = identidade['email'] as String; + final nome = identidade['name'] as String? ?? email; + + _tokens[email] = _TokenEmCache( + token: token, + expiraEm: DateTime.now().add( + Duration(seconds: expiresIn ?? 3600) - _margemExpiracao, + ), + escopos: escoposConcedidos.toSet(), + ); + + return ContaAutenticada( + nomeUsuario: nome, + email: email, + obterHeaders: () => _token(email, escopos).then(_cabecalhos), + ); + } + + // Único popup do login: pede conta e consentimento dos escopos juntos. + // `requestAccessToken()` é chamado abaixo sem nenhum `await` antes — + // continua dentro da mesma pilha síncrona do toque do usuário, o que o + // navegador exige para não tratar o popup como bloqueado. + final cliente = oauth2.oauth2.initTokenClient( + oauth2.TokenClientConfig( + client_id: _clientId, + scope: escopos, + prompt: 'select_account', + include_granted_scopes: true, + callback: (resposta) { + final erro = resposta.error; + final token = resposta.access_token; + if (erro != null || token == null) { + resultado.completeError( + StateError( + 'Não foi possível obter autorização do Google' + '${erro == null ? '' : ' ($erro)'}.', + ), + ); + return; + } + unawaited( + concluir( + token, + resposta.expires_in, + resposta.scope, + ).then(resultado.complete, onError: resultado.completeError), + ); + }, + error_callback: (erro) { + developer.log( + 'Login interativo do Google falhou: ${erro?.type} ${erro?.message}', + name: 'AutorizadorGoogleWeb', + ); + resultado.completeError( + StateError( + 'Não foi possível obter autorização do Google ' + '(${erro?.message ?? erro?.type.name ?? 'erro desconhecido'}).', + ), + ); + }, + ), + ); + + cliente.requestAccessToken(); + return resultado.future; + } + Map _cabecalhos(String token) => { 'Authorization': 'Bearer $token', }; diff --git a/lib/servicos/servico_autenticacao_google.dart b/lib/servicos/servico_autenticacao_google.dart index 774af52..bf5e3b6 100644 --- a/lib/servicos/servico_autenticacao_google.dart +++ b/lib/servicos/servico_autenticacao_google.dart @@ -49,9 +49,13 @@ abstract class ServicoAutenticacaoGoogle { Stream get contasConectadas; Stream get sessoesConectadas; - /// `false` no web: o Google Identity Services só aceita login iniciado pelo - /// widget que ele mesmo renderiza, então `entrar()` não pode ser chamado — - /// a `TelaLogin` mostra o botão do Google e o login chega pelos streams. + /// Se `entrar()` pode ser chamado diretamente pelo toque no botão do App. + /// + /// `true` em todas as plataformas suportadas hoje: no nativo via + /// `GoogleSignIn.authenticate()`, no web via um popup do OAuth2 do GIS + /// (`autorizador_google_web.dart`). Existe como sinal defensivo para uma + /// futura plataforma que exija outro fluxo — quando isso acontecer, + /// `TelaLogin` já sabe recuar sem travar o botão. bool get suportaLoginProgramatico; Future inicializar(); @@ -80,8 +84,7 @@ class ServicoAutenticacaoGoogleReal implements ServicoAutenticacaoGoogle { Stream get sessoesConectadas => _sessoesController.stream; @override - bool get suportaLoginProgramatico => - GoogleSignIn.instance.supportsAuthenticate(); + bool get suportaLoginProgramatico => true; @override Future inicializar() { @@ -197,17 +200,28 @@ class ServicoAutenticacaoGoogleReal implements ServicoAutenticacaoGoogle { Future entrar() async { await inicializar(); - if (!suportaLoginProgramatico) { - throw StateError( - 'Nesta plataforma o login só pode ser iniciado pelo botão do Google.', + final conta = await _autorizador.entrarInterativo(escoposGoogleFisio); + final sessao = SessaoGoogle( + nomeUsuario: conta.nomeUsuario, + email: conta.email, + obterHeaders: conta.obterHeaders, + ); + + // No nativo, `GoogleSignIn.authenticate()` (chamado dentro de + // `entrarInterativo`) já dispara `authenticationEvents`, que + // `_tratarEvento` escuta e publica sozinho via `_publicarSessao` — + // publicar de novo aqui duplicaria a notificação. No web não existe + // esse evento automático: o login não passa mais pelo `GoogleSignIn` + // (ver `autorizador_google_web.dart`), então é aqui que a sessão + // precisa ser publicada. + if (kIsWeb) { + _contasController.add( + ContaGoogleConectada(nomeUsuario: conta.nomeUsuario, email: conta.email), ); + _sessoesController.add(sessao); } - final conta = await GoogleSignIn.instance.authenticate( - scopeHint: escoposGoogleFisio, - ); - await _garantirAutorizacao(conta); - return _criarSessao(conta); + return sessao; } @override diff --git a/lib/telas/tela_login.dart b/lib/telas/tela_login.dart index add6af8..7deda18 100644 --- a/lib/telas/tela_login.dart +++ b/lib/telas/tela_login.dart @@ -4,7 +4,6 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:url_launcher/url_launcher.dart'; -import '../componentes/botao_google_renderizado.dart'; import '../componentes/design_system.dart'; import '../provedores/provedor_autenticacao.dart'; import '../utilitarios/links_legais.dart'; @@ -333,18 +332,10 @@ class _TelaLoginState extends ConsumerState { /// Botão "Continuar com Google". /// -/// No web o Google Identity Services só aceita login iniciado pelo widget que -/// ele mesmo renderiza (`GoogleSignIn.authenticate()` lança `UnsupportedError` -/// e `supportsAuthenticate()` devolve `false`). Para preservar o visual do app, -/// o botão do Google fica invisível **sobre** o desenho, capturando o toque. -/// -/// ⚠️ A sobreposição depende do widget do GIS ocupar a área do botão desenhado. -/// Se uma atualização do SDK mudar o tamanho ou o DOM do botão, o toque para de -/// cair no lugar certo — nesse caso, a saída suportada é exibir o botão do -/// Google diretamente, sem o desenho por baixo. -/// -/// Fora do web `construirBotaoGoogleRenderizado` devolve um `SizedBox.shrink()` -/// e o toque é tratado pelo `GestureDetector` normalmente. +/// Dispara `onTap` diretamente: no web, `entrarComGoogle` abre um único popup +/// do OAuth2 do Google (identidade + consentimento dos escopos juntos) a +/// partir deste mesmo toque — ver `autorizador_google_web.dart` para o porquê +/// de não haver mais um botão do GIS sobreposto ao desenho. class _BotaoEntrarGoogle extends StatelessWidget { final bool carregando; final bool termosAceitos; @@ -356,90 +347,61 @@ class _BotaoEntrarGoogle extends StatelessWidget { required this.onTap, }); - /// O GIS renderiza o botão com no máximo 400px e o centraliza no espaço - /// disponível. Limitar o desenho à mesma largura mantém a área clicável e a - /// área visível iguais — sem isso, numa janela larga as extremidades do botão - /// desenhado ficam sobre o container vazio do GIS e não respondem ao clique. static const _larguraMaxima = 400.0; @override Widget build(BuildContext context) { final habilitado = !carregando && termosAceitos; - final largura = MediaQuery.of(context).size.width - 44; - final larguraBotao = largura < _larguraMaxima ? largura : _larguraMaxima; return ConstrainedBox( constraints: const BoxConstraints(maxWidth: _larguraMaxima), - child: Stack( - alignment: Alignment.center, - children: [ - GestureDetector( - key: const Key('btn_entrar_google'), - onTap: habilitado ? onTap : null, - child: Opacity( - opacity: termosAceitos ? 1 : 0.55, - child: Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(vertical: 15), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(15), - border: Border.all(color: const Color(0xFFE2E8F0)), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.05), - blurRadius: 16, - offset: const Offset(0, 8), - ), - ], + child: GestureDetector( + key: const Key('btn_entrar_google'), + onTap: habilitado ? onTap : null, + child: Opacity( + opacity: termosAceitos ? 1 : 0.55, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 15), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(15), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 16, + offset: const Offset(0, 8), ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (carregando) - const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2.4, - color: FisioCores.primary, - ), - ) - else - const _GoogleG(), - const SizedBox(width: 11), - const Text( - 'Continuar com Google', - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w700, - color: FisioCores.textPrimary, - ), + ], + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (carregando) + const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2.4, + color: FisioCores.primary, ), - ], + ) + else + const _GoogleG(), + const SizedBox(width: 11), + const Text( + 'Continuar com Google', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: FisioCores.textPrimary, + ), ), - ), + ], ), ), - // O botão do Google só é construído quando o login está liberado. - // `IgnorePointer` NÃO serve aqui: ele só afeta o hit-test do Flutter, e - // a platform view é um elemento DOM que recebe o clique direto — com - // ele, dava para entrar sem aceitar os termos de uso. - if (habilitado) - Positioned.fill( - // NÃO usar opacity 0: `RenderOpacity.paint` retorna sem pintar o - // filho quando o alpha é zero (`proxy_box.dart`), e uma platform - // view só é anexada ao DOM quando pintada — o botão do Google - // simplesmente não existiria na página e o clique cairia no vazio. - // 0.01 vira alpha 3/255: imperceptível, mas pintado de verdade. - child: Opacity( - opacity: 0.01, - child: construirBotaoGoogleRenderizado( - larguraMinima: larguraBotao, - ), - ), - ), - ], + ), ), ); } diff --git a/lib/utilitarios/mensagens_erro_google.dart b/lib/utilitarios/mensagens_erro_google.dart index b35109d..9164ceb 100644 --- a/lib/utilitarios/mensagens_erro_google.dart +++ b/lib/utilitarios/mensagens_erro_google.dart @@ -44,5 +44,11 @@ String mensagemErroLoginGoogle(Object erro) { 'na Tela de consentimento OAuth (Google Cloud Console).'; } + if (texto.toLowerCase().contains('popup')) { + return 'O navegador bloqueou a janela de login do Google. Permita ' + 'pop-ups para este site nas configurações do navegador e tente ' + 'novamente.'; + } + return 'Falha ao autenticar. Verifique sua conexão e tente novamente.'; } diff --git a/test/unitarios/utilitarios/mensagens_erro_google_test.dart b/test/unitarios/utilitarios/mensagens_erro_google_test.dart index db0558d..6f64479 100644 --- a/test/unitarios/utilitarios/mensagens_erro_google_test.dart +++ b/test/unitarios/utilitarios/mensagens_erro_google_test.dart @@ -56,6 +56,14 @@ void main() { expect(mensagem, contains('usuário de teste')); }); + test('popup bloqueado pelo navegador orienta liberar pop-ups', () { + final mensagem = mensagemErroLoginGoogle( + StateError('Não foi possível obter autorização do Google (popup_closed).'), + ); + expect(mensagem, contains('bloqueou')); + expect(mensagem, contains('pop-ups')); + }); + test('erro desconhecido cai na mensagem genérica', () { expect( mensagemErroLoginGoogle(Exception('socket fechou')),