From b4981b95b6fc8c7fe19abbf79c10935cf971b3d1 Mon Sep 17 00:00:00 2001 From: Rodrigo Lacerda Date: Wed, 29 Jul 2026 23:46:26 -0300 Subject: [PATCH] Corrigindo docs pendentes e corrigindo bug de termos --- CHANGELOG.md | 19 + CLAUDE.md | 60 ++- QA/qa.md | 2 +- documentacao/IMPLEMENTAR.md | 63 +-- documentacao/LOGIN_SCREEN_SPEC.md | 16 +- documentacao/SEGURANCA_E_DADOS.md | 2 +- documentacao/testes/COBERTURA_ANALISE.md | 2 +- documentacao/testes/README.md | 18 +- documentacao/testes/UNITARIOS.md | 110 ++++- documentacao/testes/VISAO_GERAL.md | 34 +- documentacao/testes/WIDGETS.md | 39 +- lib/provedores/provedores_dados.dart | 18 + lib/servicos/autorizador_google.dart | 10 + lib/servicos/autorizador_google_base.dart | 36 ++ lib/servicos/autorizador_google_stub.dart | 47 ++ lib/servicos/autorizador_google_web.dart | 166 +++++++ lib/servicos/servico_autenticacao_google.dart | 55 +-- lib/servicos/servico_google_drive.dart | 49 +- lib/servicos/servico_google_sheets.dart | 9 +- lib/servicos/servico_repositorio_dados.dart | 33 +- lib/telas/tela_configuracoes.dart | 46 ++ lib/telas/tela_login.dart | 71 ++- lib/utilitarios/links_legais.dart | 12 + pubspec.lock | 2 +- pubspec.yaml | 4 + .../auxiliares/servidor_google_fake.dart | 148 +++++++ .../servicos/servico_google_drive_test.dart | 75 ++++ .../servicos/servico_google_sheets_test.dart | 123 ++++++ .../servico_repositorio_dados_test.dart | 418 ++++++++++++++++++ .../telas/tela_configuracoes_test.dart | 65 +++ test/widgets/telas/tela_login_test.dart | 124 ++++++ 31 files changed, 1710 insertions(+), 166 deletions(-) create mode 100644 lib/servicos/autorizador_google.dart create mode 100644 lib/servicos/autorizador_google_base.dart create mode 100644 lib/servicos/autorizador_google_stub.dart create mode 100644 lib/servicos/autorizador_google_web.dart create mode 100644 lib/utilitarios/links_legais.dart create mode 100644 test/unitarios/auxiliares/servidor_google_fake.dart create mode 100644 test/unitarios/servicos/servico_google_drive_test.dart create mode 100644 test/unitarios/servicos/servico_google_sheets_test.dart create mode 100644 test/unitarios/servicos/servico_repositorio_dados_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 938e122..72a4281 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,19 @@ ## [Não lançado] ### Correções +- **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. + - Novo `lib/utilitarios/links_legais.dart` como fonte única das URLs. + - +4 testes em `tela_login_test.dart`, inclusive um que garante que **tocar no link não marca o checkbox** — o link fica na mesma linha dele, e aceitar os termos sem intenção seria pior do que não abrir a página. +- **Cópia da planilha no Drive virava a base ativa em silêncio.** `ServicoGoogleDrive.buscarPlanilhaBanco()` consultava `name contains '__saas_fisio_db__'` ordenando por `modifiedTime desc` e pegava o primeiro resultado — então `__saas_fisio_db__ (1)` ou `Cópia de __saas_fisio_db__`, que o Drive cria sozinho ao duplicar ou restaurar, entrava no páreo e virava o destino das gravações assim que fosse a mais recente. A base antiga apenas "parava de receber sessões", sem erro nenhum. + - A busca passou a usar **nome exato** (`name = '__saas_fisio_db__'`), o que elimina as cópias; e o novo `buscarPlanilhasBanco()` devolve a lista completa em vez de só o primeiro ID. + - **Quando ainda assim houver mais de uma planilha com o nome exato**, o app segue usando a mais recente — mas agora avisa: `RepositorioDadosGoogle.planilhasDuplicadas` chega via `DadosCarregados` ao novo `provedorPlanilhasDuplicadas` e a tela de Configurações mostra um alerta no card "Planilha conectada" pedindo para apagar ou renomear as outras. Silêncio aqui é o que tornava o problema invisível. +- **Toda planilha era tratada como esquema versão 1.** `ServicoGoogleSheets.lerVersaoEsquema` fazia `valores[0].toString()` sobre a resposta da Sheets API, que vem como lista de linhas: o texto resultante era `"[2]"`, o `int.tryParse` falhava e o `catch` devolvia 1. Uma planilha de versão futura passava por `validarVersao` sem reclamar, justamente o cenário que essa validação existe para barrar. Passou a ler a primeira célula da primeira linha. +- **O login no web pedia para escolher a conta duas vezes.** No web o GIS separa identidade de autorização, então um login sempre passa por dois passos: o botão do Google (ID token) e o token client dos escopos Drive/Sheets. O segundo reabria o seletor de contas porque `google_sign_in_web` amarra hint e prompt num único ternário (`src/gis_client.dart:135`): `prompt: userHint == null ? '' : 'select_account'`. Os dois caminhos oferecidos pelo plugin falham quando o navegador tem mais de uma sessão Google — `conta.authorizationClient` manda o hint e por isso ganha `select_account`; `GoogleSignIn.instance.authorizationClient` (o que estava em uso) evita o `select_account` mas manda `login_hint: null`, e aí o Google pergunta qual conta usar. Não aparecia no Android, onde `conta.authorizationClient` autoriza sem UI nenhuma. + - **Novo `lib/servicos/autorizador_google.dart`** (+ `_base`, `_stub`, `_web`), no mesmo padrão de import condicional do `botao_google_renderizado.dart`. No web a autorização passa a falar direto com o GIS via `google_identity_services_web` — que já era dependência transitiva e agora é direta — montando o token client com `login_hint: ` **e** `prompt: ''`: o par que o plugin não permite. O segundo passo vai direto para a conta escolhida no botão e não aparece quando o consentimento já existe. Fora do web nada muda: o stub mantém `conta.authorizationClient`. + - **Token cacheado por e-mail, com margem de 1 min antes da expiração e requisição deduplicada.** No web o access token vale 1h e não se renova sozinho; cachear por conta impede que trocar de usuário reaproveite o token do anterior, e a deduplicação impede que duas chamadas simultâneas às APIs abram dois popups de autorização. + - Sem cobertura automatizada — o fluxo depende do SDK real do Google no navegador. Validar manualmente: primeiro login com conta sem consentimento prévio, navegador com duas contas Google logadas, refresh de página e sessão acima de 1h. - **Clicar numa evolução na tela "Evoluções" não abria nada.** O card da lista (`_CardEvolucaoGeral`, em `tela_historico_geral_evolucoes.dart`) era um `FisioCard` sem `onTap` — e `FisioCard` só vira `InkWell` quando recebe um. Não era um toque que falhava: não havia toque nenhum. Como o card corta o texto clínico em `maxLines: 3`, evolução mais longa que isso ficava **impossível de ler por completo** a partir dessa tela. - **Novo `lib/componentes/modal_detalhes_evolucao.dart`:** bottom sheet somente leitura, no padrão do `modal_detalhes_paciente.dart`. Mostra paciente, data, condição, status de presença, horário real, dor, local, PA/FC e o texto clínico **inteiro** — em `SelectableText` rolável, sem `maxLines`, para poder copiar. Ligado ao toque nas duas visualizações da tela ("Lista" e "Por paciente"). - **A janela de 24h nunca escondeu conteúdo — mas o caminho até ele sumia.** A regra sempre bloqueou só a *edição*: `TelaRegistroEvolucao` renderiza o formulário completo preenchido mesmo passadas 24h, com banner e campos `readOnly`. O problema é que a única porta para ela, o botão "Editar" da timeline, **desaparecia** depois das 24h, deixando o registro antigo sem entrada nenhuma. O modal agora abre **sempre**; a janela decide apenas se aparece o botão "Editar" ou o rodapé `Registro fechado — mais de 24h`. Sem paciente na lista carregada a edição também não é oferecida — `TelaRegistroEvolucao` exige um `Paciente` não-nulo. @@ -14,6 +27,12 @@ - **Teste `filtro Vencidas` quebrava no CI de madrugada.** Em `tela_sessoes_test.dart`, o teste "deve listar sessões vencidas" montava a sessão com a data de hoje e a hora fixa `08:00` do helper. Como `Agendamento.estaAtrasado` compara `inicioPrevisto` (data **e** hora) com o relógio, a sessão só ficava vencida depois das 08:00 — o runner do GitHub em UTC via `Found 0 widgets with text "Vencida"`. O horário passou a ser derivado de `DateTime.now()` (um minuto atrás, com clamp na meia-noite) por meio dos novos helpers `_instanteJaVencido`/`_hhmm`, que também substituem o cálculo já duplicado no grupo "filtros por período e status". - **Frase do header reescrita:** `Você tem hoje, 26 de julho` deixava a vírgula pendurada, porque o complemento ("1 sessão") vem numa linha separada e em corpo muito maior. Agora lê `Hoje, 26 de julho, você tem` seguido do número — a frase fecha em si. Total **295 testes**. +### Testes +- **Camada de serviço deixou de ser um ponto cego** — eram 5 testes (só `preferencias_test.dart`) para os serviços que falam com a rede. Novo `test/unitarios/auxiliares/servidor_google_fake.dart`: um Drive/Sheets falso no nível do HTTP (`MockClient` do `package:http`), já que `DriveApi`/`SheetsApi` são construídos a partir de um `http.Client` e esse é o único ponto de injeção existente. A vantagem sobre um fake de classe é exercitar o que de fato quebrou nesta camada: a query do Drive, o range de cada aba e a ordem dos valores gravados. +- **+48 testes (312 → 360):** `servico_google_drive_test.dart` (6 — nome exato, ordenação, arquivos sem id), `servico_google_sheets_test.dart` (10 — versão do esquema, criação de abas, cabeçalhos, `USER_ENTERED`), `servico_repositorio_dados_test.dart` (29 — cache/preferências/criação de planilha, duplicatas, parsing de linha curta, valor com vírgula, auditoria com `GeradorId`, ranges de atualização) e 3 na `tela_configuracoes_test.dart` para o aviso de duplicata. +- Dois testes fixam regressões que já custaram caro: o ID da planilha continua persistido mesmo se gravar a aba `Versao` falhar (senão a tentativa seguinte cria **outra** planilha), e `registrarAuditoria` gera `L008` a partir do maior ID existente, não de `length + 1`. +- **Links legais na tela de login (4):** cada link abre sua página, o toque não marca o checkbox, e falha do launcher mostra a URL no snackbar. O toque é dado por coordenada sobre o span (`getOffsetForCaret`) — `tester.tap(find.text(...))` acertaria o `RichText` inteiro e passaria mesmo sem `recognizer` nenhum. + ## [1.1.1] — 2026-07-26 > Versão *minor* planejada como 1.1.0, publicada como **1.1.1**: `pubspec.yaml` e diff --git a/CLAUDE.md b/CLAUDE.md index 8ae5565..cd916a7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -91,6 +91,10 @@ fisio-home-care/ │ │ │ ├── servicos/ │ │ ├── servico_autenticacao_google.dart # Google Sign-In wrapper +│ │ ├── autorizador_google.dart # Tokens Drive/Sheets (ponte condicional) +│ │ ├── autorizador_google_base.dart # Contrato do autorizador +│ │ ├── autorizador_google_stub.dart # Nativo: conta.authorizationClient +│ │ ├── autorizador_google_web.dart # Web: token client do GIS sem seletor │ │ ├── servico_repositorio_dados.dart # Google Sheets CRUD │ │ ├── servico_google_drive.dart # Localizar planilha │ │ ├── servico_google_sheets.dart # Wrapper Google Sheets API @@ -108,15 +112,19 @@ fisio-home-care/ │ └── mensagens_erro_google.dart # Mapear erros Google │ ├── test/ -│ ├── unitarios/ # 128 testes — lógica pura +│ ├── unitarios/ # 173 testes — lógica pura │ │ ├── auxiliares/ -│ │ │ └── fakes.dart # Mocks reutilizados +│ │ │ ├── fakes.dart # Mocks reutilizados +│ │ │ └── servidor_google_fake.dart # Drive/Sheets falso no nível do HTTP │ │ ├── modelos/ │ │ │ ├── paciente_test.dart (9 testes) │ │ │ ├── agendamento_test.dart (10 testes) │ │ │ └── evolucao_test.dart (11 testes) │ │ ├── servicos/ -│ │ │ └── preferencias_test.dart (5 testes) +│ │ │ ├── preferencias_test.dart (5 testes) +│ │ │ ├── servico_google_drive_test.dart (6 testes — busca por nome exato) +│ │ │ ├── servico_google_sheets_test.dart (10 testes — versão do esquema, abas) +│ │ │ └── servico_repositorio_dados_test.dart (29 testes — planilha, parsing, escrita) │ │ └── utilitarios/ │ │ ├── validadores_test.dart (46 testes) │ │ ├── validador_cpf_test.dart (9 testes) @@ -124,7 +132,7 @@ fisio-home-care/ │ │ ├── gerador_id_test.dart (8 testes — 100% cobertura) │ │ └── mensagens_erro_google_test.dart (7 testes — diagnóstico de login) │ │ -│ └── widgets/ # 181 testes — UI + componentes +│ └── widgets/ # 191 testes — UI + componentes │ ├── componentes/ │ │ ├── modal_detalhes_evolucao_test.dart (6 testes) │ ├── modal_detalhes_paciente_test.dart (12 testes) @@ -133,8 +141,8 @@ fisio-home-care/ │ │ └── acoes_agendamento_test.dart (6 testes) │ └── telas/ │ ├── tela_splash_test.dart (7 testes — abertura animada + handoff para o login) -│ ├── tela_login_test.dart (9 testes — navegação única + login não-programático) -│ ├── tela_dashboard_test.dart (16 testes) +│ ├── tela_login_test.dart (13 testes — navegação única + login não-programático) +│ ├── tela_dashboard_test.dart (19 testes) │ ├── tela_cadastro_paciente_test.dart (23 testes) │ ├── tela_editar_paciente_test.dart (6 testes — campos travados + atualização) │ ├── tela_editar_sessao_test.dart (7 testes — editar/reagendar sessão) @@ -158,9 +166,9 @@ fisio-home-care/ │ ├── chaves.md # (no .gitignore) — credenciais │ └── testes/ │ ├── README.md # Índice de testes -│ ├── VISAO_GERAL.md # Overview 309 testes -│ ├── UNITARIOS.md # Detalhe dos 123 unitários -│ └── WIDGETS.md # Detalhe dos 181 widgets +│ ├── VISAO_GERAL.md # Overview 364 testes +│ ├── UNITARIOS.md # Detalhe dos 173 unitários +│ └── WIDGETS.md # Detalhe dos 191 widgets │ ├── QA/ │ └── qa.md # Script QA manual (NOT E2E automatizado) @@ -301,19 +309,19 @@ Paciente.calcularIdade() // ✓ delega para UtilitariosData --- -## Testes (309 testes automatizados) +## Testes (364 testes automatizados) ### Estrutura ``` test/ -├── unitarios/ (128 testes) +├── unitarios/ (173 testes) │ ├── auxiliares/ — fakes.dart (mocks reutilizados) │ ├── modelos/ — 25 testes (serialização, transformação) -│ ├── servicos/ — 5 testes (preferencias) +│ ├── servicos/ — 50 testes (preferencias, Drive, Sheets, repositório) │ └── utilitarios/ — 93 testes (validadores, data, CPF, gerador_id, erros do Google) │ -└── widgets/ (181 testes) +└── widgets/ (191 testes) ├── telas/ — 13 telas principais (UI, interação) ├── componentes/ — modal de detalhes do paciente + rodapé versão └── utilitarios/ — ações de agendamento @@ -346,7 +354,7 @@ flutter test --coverage ✅ **Validação de entrada** — 55 testes (CPF, telefone, nome, data) ✅ **Modelos** — 25 testes (serialização, cópia, status) ✅ **Utilitários** — 31 testes (idade, formatação, geração de ID) -✅ **UI + Interação** — 181 testes (13 telas principais + componentes/utilitários) +✅ **UI + Interação** — 191 testes (13 telas principais + componentes/utilitários) ❌ **Não coberto:** - Google Sheets API real (usaria quota, seria lento) @@ -410,6 +418,8 @@ flutter run | `BackdropFilter` reimplementado inline em telas | Resolvido (o redesign removeu todos os usos) | ✅ | | Lógica de popup duplicada em dashboard/sessoes | Resolvido (centralizado em `acoes_agendamento.dart`) | ✅ | | 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 — regras que não podem ser quebradas @@ -427,9 +437,19 @@ testes: fora do web `construirBotaoGoogleRenderizado` vira `SizedBox.shrink()`. 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. -- **Não passar hint de usuário na autorização no web.** `conta.authorizationClient` - envia a conta como hint, e o plugin traduz isso em `prompt: 'select_account'`, - reabrindo o seletor de contas. Usar `GoogleSignIn.instance.authorizationClient`. +- **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 @@ -539,7 +559,7 @@ make release-prod # mescla develop → master → dispara deploy de produção | `documentacao/ESPECIFICACOES_TELAS.md` | Requisitos funcionais das telas | ✅ | | `documentacao/SEGURANCA_E_DADOS.md` | LGPD, OAuth, modelo BYODB | ✅ | | `documentacao/IMPLEMENTAR.md` | Roadmap priorizado | ✅ | -| `documentacao/testes/` | 309 testes automatizados | ✅ | +| `documentacao/testes/` | 364 testes automatizados | ✅ | | `documentacao/CI_CD.md` | Pipeline GitHub Actions: fluxo, secrets, uso e troubleshooting | ✅ | | `QA/qa.md` | Script QA manual (não é E2E) | ✅ | @@ -584,6 +604,6 @@ Para questões sobre estrutura, padrões ou decisões técnicas, **SEMPRE consul --- -**Última atualização:** 2026-07-26 -**Versão:** 1.0.18 (produção) — 1.1.0 pendente de deploy +**Última atualização:** 2026-07-29 +**Versão:** 1.1.1 (`pubspec.yaml` e `web/version.json`) — o deploy de `master` publica como 1.1.2 **Branches:** master, develop, identidade-visual diff --git a/QA/qa.md b/QA/qa.md index 8f84e50..f5e7039 100644 --- a/QA/qa.md +++ b/QA/qa.md @@ -18,7 +18,7 @@ Você é um Analista de Qualidade Sênior. Sua tarefa é criar scripts de teste **Regras de negócio relevantes:** - **Login:** O botão "Entrar com Google" permanece desabilitado até o aceite explícito do termo LGPD (checkbox obrigatório). -- **Autenticação:** Integração obrigatória com Google Sign-In; na Web, a autorização de acesso ao Drive/Sheets ocorre em ação explícita separada do login. +- **Autenticação:** Integração obrigatória com Google Sign-In; na Web, a autorização de acesso ao Drive/Sheets ocorre em ação explícita separada do login. Essa segunda etapa **não pode pedir para escolher a conta de novo** — testar com duas contas Google logadas no navegador, que é o cenário em que a escolha dupla aparecia. - **Armazenamento:** Dados clínicos persistidos exclusivamente na planilha `__saas_fisio_db__` da conta Google do profissional; escopo OAuth restrito (`drive.file`). - **Isolamento:** Cada fisioterapeuta possui sua própria planilha; não há acesso cruzado entre contas. - **Agenda do dia:** Exibe somente sessões com `Situacao = "Agendado"` na data atual; sessões antigas sem desfecho aparecem em **Pendências**. diff --git a/documentacao/IMPLEMENTAR.md b/documentacao/IMPLEMENTAR.md index 9a5630a..bc76e54 100644 --- a/documentacao/IMPLEMENTAR.md +++ b/documentacao/IMPLEMENTAR.md @@ -4,8 +4,8 @@ > 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) · 277 testes automatizados -**Atualizado em:** 2026-07-23 +**Status:** MVP funcional completo em produção (web) · 364 testes automatizados +**Atualizado em:** 2026-07-29 --- @@ -21,35 +21,7 @@ não invalida a credencial. - [ ] Decidir se vale reescrever o histórico (`git filter-repo`) ou apenas rotacionar e seguir -### 2. Cenários de login ainda não exercitados - -A migração para `google_sign_in` 7.2.0 (branch `migracao-google-sign-in-7`) foi -validada manualmente em **2026-07-23**: login funcionando no web e no Android, -com uma única escolha de conta. Nenhum teste automatizado cobre o SDK real do -Google, então dois cenários seguem sem verificação: - -- [ ] **Sessão com mais de 1h.** No web o access token expira em 3600s e **não é - renovado** pelo plugin. `obterHeaders()` chama `authorizationHeaders()` a - cada requisição justamente para renovar; falta confirmar que funciona na - prática, deixando o app aberto além desse tempo. -- [ ] **Primeiro login com conta Google sem consentimento prévio.** O escopo - `spreadsheets` foi restaurado nesta migração, mas contas que já usaram o - app carregam o consentimento de antes e mascaram o problema. Testar com - conta limpa: deve criar a planilha e carregar os dados. - -> ⚠️ A `TelaLogin` sobrepõe o botão do GIS (quase invisível) ao botão desenhado -> do app, porque no web `authenticate()` lança `UnsupportedError`. A sobreposição -> foi validada no navegador (botão presente no DOM, largura alinhada, área -> clicável cobrindo todo o desenho, e ausente enquanto os termos não são aceitos), -> mas **nenhum teste automatizado cobre isso** — fora do web o componente vira um -> `SizedBox.shrink()`. Três detalhes a preservar em qualquer mexida: -> `Opacity` precisa ser > 0 (com alpha 0 o Flutter não pinta e a platform view não -> entra no DOM); `IgnorePointer` não segura o clique de uma platform view; e o -> GIS limita o botão a 400px, então o desenho tem que respeitar a mesma largura. -> Se uma atualização do SDK mudar o tamanho ou o DOM do botão, a saída suportada -> é exibir o botão do Google diretamente, sem o desenho por baixo. - -### 3. Publicação nas lojas +### 2. Publicação nas lojas **Android** - [ ] Registrar SHA-1 debug no Firebase e baixar `google-services.json` atualizado @@ -64,54 +36,37 @@ Google, então dois cenários seguem sem verificação: ## 🟡 Média prioridade -### 4. Tratamento de erro e retry +### 3. Tratamento de erro e retry - [ ] Retry automático em falhas da API Google - [ ] Timeout handling - [ ] Feedback visual consistente em todas as telas -### 5. WhatsApp para confirmação e contato rápido +### 4. WhatsApp para confirmação e contato rápido - **Onde:** modal de detalhes do paciente e cards de sessão - **O que:** abrir conversa com mensagem pronta de confirmação, remarcação ou lembrete - **Exemplo:** `Olá, confirmando sua sessão de fisioterapia no dia X às Y.` - **Valor:** reduz faltas, agiliza confirmação e combina bem com atendimento domiciliar -### 6. Testes de serviço - -`test/unitarios/servicos/` cobre apenas `preferencias_test.dart`. Os serviços que -falam com a rede só aparecem nos testes via fakes — nenhum tem teste próprio. - -- [ ] `servico_repositorio_dados.dart` (parsing de linhas, `obterPlanilhaId`, retry de 404) -- [ ] `servico_autenticacao_google.dart` -- [ ] Fluxo de integração com a Sheets API - -### 7. Duplicata de planilha - -`ServicoGoogleDrive.buscarPlanilhaBanco()` consulta `name contains -'__saas_fisio_db__'` ordenando por `modifiedTime desc` e usa a primeira. Havendo -mais de uma planilha, escolhe silenciosamente a mais recente. - -- [ ] Usar correspondência exata de nome, ou avisar o usuário quando houver duplicatas - -### 8. Cache local offline +### 5. Cache local offline - [ ] Escolher e adicionar dependências de armazenamento local (só quando for implementar) - [ ] Salvar `SessaoGoogle` (tokens) localmente para login offline - [ ] Cache dos dados da planilha para leitura sem internet - [ ] Indicador visual de conectividade -### 9. Relatório do paciente (PDF) +### 6. Relatório do paciente (PDF) Perfil, dados clínicos principais, histórico de evoluções, dor, condição clínica e sessões realizadas. Útil para encaminhamentos, prestação de contas e auditoria. -### 10. Exportar dados +### 7. Exportar dados - [ ] Agenda em CSV - [ ] Financeiro em CSV -### 11. Backup automático para Google Drive +### 8. Backup automático para Google Drive --- diff --git a/documentacao/LOGIN_SCREEN_SPEC.md b/documentacao/LOGIN_SCREEN_SPEC.md index eb9b9b1..1db128c 100644 --- a/documentacao/LOGIN_SCREEN_SPEC.md +++ b/documentacao/LOGIN_SCREEN_SPEC.md @@ -10,9 +10,19 @@ Esta tela é a porta de entrada do aplicativo, responsável pela autenticação 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. - Quando a conta já concedeu os escopos, essa etapa não pede nova escolha de - conta — o App solicita a autorização sem enviar hint de usuário, o que faz - o Google reaproveitar a sessão ativa. + 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. +* **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 + externo, sem sair da tela nem perder o estado do checkbox. **Não basta + estilizar o `TextSpan`:** sem `recognizer` ele é só texto colorido, e o + usuário precisa aceitar os termos sem ter como lê-los. * **LGPD e Consentimento:** * O sistema deve apresentar o "Termo de Consentimento para Tratamento de Dados Pessoais e de Saúde". * **Regra de Negócio:** O acesso à funcionalidade de login deve ser condicionado ao aceite explícito do termo através de um campo de verificação (checkbox). diff --git a/documentacao/SEGURANCA_E_DADOS.md b/documentacao/SEGURANCA_E_DADOS.md index 085522c..39ad42e 100644 --- a/documentacao/SEGURANCA_E_DADOS.md +++ b/documentacao/SEGURANCA_E_DADOS.md @@ -31,7 +31,7 @@ O App adota o modelo **Bring Your Own Database**: todos os dados são armazenado |---|---| | **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. | | **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 via `authorizationHeaders()`. Não é armazenado de forma persistente pelo App. No web expira em 1h. | +| **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. | | **Consentimento de termos** | Checkbox obrigatório na tela de login. Enquanto não for aceito, o botão de login do Google não é sequer construído — não basta desabilitá-lo visualmente, porque no web ele é um elemento DOM que receberia o clique por fora do controle do App. | diff --git a/documentacao/testes/COBERTURA_ANALISE.md b/documentacao/testes/COBERTURA_ANALISE.md index 545cf2f..25fabed 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 (309 testes no total). Para os +> principais já têm testes de widget hoje (364 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 218ad2c..0294e44 100644 --- a/documentacao/testes/README.md +++ b/documentacao/testes/README.md @@ -1,14 +1,14 @@ # 📚 Documentação de Testes -Guia completo dos 309 testes automatizados do Alta. +Guia completo dos 364 testes automatizados do Alta. --- ## 📖 Documentos 1. **[VISAO_GERAL.md](./VISAO_GERAL.md)** — Overview, estrutura, como rodar -2. **[UNITARIOS.md](./UNITARIOS.md)** — 128 testes unitários (validadores, modelos, serviços) -3. **[WIDGETS.md](./WIDGETS.md)** — 181 testes de widget (telas, componentes, utilitários) +2. **[UNITARIOS.md](./UNITARIOS.md)** — 173 testes unitários (validadores, modelos, serviços) +3. **[WIDGETS.md](./WIDGETS.md)** — 191 testes de widget (telas, componentes, utilitários) --- @@ -34,12 +34,12 @@ flutter test test/unitarios/utilitarios/validadores_test.dart | Tipo | Quantidade | % | |---|---|---| -| Unit — Utilitários | 93 | 30% | -| Unit — Modelos | 30 | 10% | -| Unit — Serviços | 5 | 2% | -| Widget — Telas | 154 | 50% | -| Widget — Componentes/Utilitários | 27 | 9% | -| **TOTAL** | **309** | **100%** | +| Unit — Utilitários | 93 | 26% | +| Unit — Modelos | 30 | 8% | +| Unit — Serviços | 50 | 14% | +| Widget — Telas | 164 | 45% | +| Widget — Componentes/Utilitários | 27 | 8% | +| **TOTAL** | **364** | **100%** | --- diff --git a/documentacao/testes/UNITARIOS.md b/documentacao/testes/UNITARIOS.md index ab84dd4..4e71208 100644 --- a/documentacao/testes/UNITARIOS.md +++ b/documentacao/testes/UNITARIOS.md @@ -1,4 +1,4 @@ -# 📁 Testes Unitários (128 testes) +# 📁 Testes Unitários (173 testes) Lógica pura: validação de entrada, transformação de dados, cálculos. @@ -156,7 +156,7 @@ existente — evita a race condition do antigo `length + 1`. --- -## test/unitarios/servicos/ (5 testes) +## test/unitarios/servicos/ (50 testes) ### preferencias_test.dart (5 testes) @@ -172,6 +172,112 @@ Persistência local via SharedPreferences (ID da planilha). --- +### servico_google_drive_test.dart (6 testes) + +Localização da planilha `__saas_fisio_db__` no Drive do usuário. + +```dart +✓ Busca pelo nome exato da base, não por "contains" +✓ Pede a lista ordenada da mais recente para a mais antiga +✓ Converte os arquivos preservando id, nome e data +✓ Descarta arquivos sem id +✓ buscarPlanilhaBanco devolve null quando não há nenhuma +✓ buscarPlanilhaBanco devolve a primeira (mais recente) +``` + +> **Por que "nome exato" tem teste próprio:** com `name contains`, as cópias que +> o Drive cria sozinho (`__saas_fisio_db__ (1)`, `Cópia de ...`) entravam no +> resultado e, sendo mais recentes, viravam a base ativa em silêncio. + +--- + +### servico_google_sheets_test.dart (10 testes) + +Wrapper da Sheets API: leitura de abas, versão do esquema e escrita. + +```dart +// Leitura +✓ lerAba descarta linhas totalmente vazias +✓ lerAba pede o intervalo a partir da segunda linha (pula cabeçalho) + +// Versão do esquema +✓ Lê o número dentro da linha, não a linha inteira +✓ Assume versão 1 quando a aba Versao está vazia +✓ validarVersao aceita a versão suportada +✓ validarVersao recusa planilha de versão futura + +// Escrita +✓ criarPlanilhaBanco cria todas as abas, inclusive Versao +✓ criarPlanilhaBanco grava os cabeçalhos de cada aba +✓ garantirEstrutura cria só as abas que faltam +✓ inserirLinha manda append com USER_ENTERED +``` + +> `valores[0].toString()` sobre a resposta da API dava `"[2]"`, o parse falhava +> e **toda** planilha era tratada como versão 1 — inclusive uma de versão +> futura, que `validarVersao` deveria recusar. + +--- + +### servico_repositorio_dados_test.dart (29 testes) + +CRUD sobre a planilha: resolução do ID, carregamento e gravação. + +```dart +// obterPlanilhaId (7) +✓ Usa o ID salvo nas preferências sem consultar o Drive +✓ Descarta o ID salvo quando a planilha tem versão incompatível +✓ Adota a planilha existente no Drive e a persiste +✓ Cria uma planilha quando não existe nenhuma +✓ Persiste o ID mesmo se gravar a versão falhar +✓ Reutiliza o ID em memória sem buscar de novo no Drive +✓ limparCache apaga o ID persistido + +// Planilhas duplicadas (3) +✓ Usa a mais recente e reporta as demais +✓ Não reporta duplicata quando só existe uma planilha +✓ carregarTudo propaga as duplicatas para a UI + +// carregarTudo (6) +✓ Converte linhas curtas sem estourar índice +✓ Lê o valor da sessão com vírgula decimal +✓ Carrega evoluções pela mesma regra do modelo +✓ Usa o valor padrão da sessão vindo de Configuracoes +✓ Cai em 150,00 quando a configuração não existe +✓ Logs de auditoria do mais recente para o mais antigo + +// Escrita (12) +✓ salvarPaciente grava as 19 colunas na ordem do cabeçalho +✓ salvarPaciente registra auditoria da operação +✓ registrarAuditoria gera o próximo ID a partir do maior existente +✓ atualizarSituacaoAgendamento troca só a coluna Situacao +✓ atualizarSituacaoAgendamento ignora ID inexistente +✓ arquivarPaciente marca a situação preservando o resto da linha +✓ restaurarPaciente devolve a situação para Ativo +✓ atualizarPaciente falha alto quando o paciente sumiu da planilha +✓ atualizarEvolucao não escreve nada se o ID não existe +✓ salvarValorSessaoPadrao insere a chave quando ela não existe +✓ salvarValorSessaoPadrao atualiza a linha existente +✓ salvarAgendamento grava as 9 colunas da Agenda + +// Utilidades (1) +✓ urlPlanilha aponta para o documento no Drive +``` + +> **`ServidorGoogleFake`** (`test/unitarios/auxiliares/servidor_google_fake.dart`) +> responde no nível do HTTP, via `MockClient` do `package:http`. É o único ponto +> de injeção que existe — `DriveApi`/`SheetsApi` são construídos a partir de um +> `http.Client` — e tem a vantagem de exercitar o que de fato quebra nesta +> camada: a query enviada ao Drive, o range de cada aba e a ordem dos valores +> gravados. Um fake de classe passaria por cima justamente disso. +> +> Dois testes fixam regressões conhecidas: o ID da planilha continua persistido +> mesmo quando gravar a aba `Versao` falha (senão a tentativa seguinte cria +> **outra** planilha), e `registrarAuditoria` usa `GeradorId` (max+1, `L008`), +> não `length + 1`, que sobrescreveria linhas. + +--- + ## test/unitarios/modelos/ (30 testes) Serialização, transformação e comportamento dos modelos de dados. diff --git a/documentacao/testes/VISAO_GERAL.md b/documentacao/testes/VISAO_GERAL.md index 89b33ce..71704e6 100644 --- a/documentacao/testes/VISAO_GERAL.md +++ b/documentacao/testes/VISAO_GERAL.md @@ -1,6 +1,6 @@ # 📋 Testes Automatizados — Visão Geral -**Status:** ✅ 309 testes passando +**Status:** ✅ 364 testes passando --- @@ -10,12 +10,12 @@ O projeto utiliza **apenas testes unitários e de widget** (sem E2E automatizado | Categoria | Quantidade | % | Focos | |---|---|---|---| -| **Unit — Utilitários** | 93 | 30% | Validadores, formatadores, gerador de IDs | -| **Unit — Modelos** | 30 | 10% | Serialização, transformação, cópia | -| **Unit — Serviços** | 5 | 2% | Preferências (SharedPreferences) | -| **Widget — Telas** | 154 | 50% | UI, interação, estados visuais | -| **Widget — Componentes/Utilitários** | 27 | 9% | Modal de detalhes, rodapé versão, ações de agendamento | -| **TOTAL** | **309** | **100%** | — | +| **Unit — Utilitários** | 93 | 26% | Validadores, formatadores, gerador de IDs | +| **Unit — Modelos** | 30 | 8% | Serialização, transformação, cópia | +| **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%** | — | --- @@ -23,21 +23,25 @@ O projeto utiliza **apenas testes unitários e de widget** (sem E2E automatizado ``` test/ -├── unitarios/ (128 testes — lógica pura) +├── unitarios/ (173 testes — lógica pura) │ ├── auxiliares/ -│ │ └── fakes.dart — Mocks reutilizados +│ │ ├── fakes.dart — Mocks reutilizados +│ │ └── servidor_google_fake.dart — Drive/Sheets falso no nível do HTTP │ ├── modelos/ │ │ ├── agendamento_test.dart (10 testes) │ │ ├── evolucao_test.dart (11 testes) │ │ └── paciente_test.dart (9 testes) │ ├── servicos/ -│ │ └── preferencias_test.dart (5 testes) +│ │ ├── preferencias_test.dart (5 testes) +│ │ ├── servico_google_drive_test.dart (6 testes) +│ │ ├── servico_google_sheets_test.dart (10 testes) +│ │ └── servico_repositorio_dados_test.dart (29 testes) │ └── utilitarios/ │ ├── utilitarios_data_test.dart (23 testes) │ ├── validador_cpf_test.dart (9 testes) │ ├── validadores_test.dart (46 testes) │ └── gerador_id_test.dart (8 testes) -└── widgets/ (181 testes — UI + componentes) +└── widgets/ (191 testes — UI + componentes) ├── componentes/ │ ├── modal_detalhes_evolucao_test.dart (6 testes) │ ├── modal_detalhes_paciente_test.dart (12 testes) @@ -46,13 +50,13 @@ test/ │ └── acoes_agendamento_test.dart (6 testes) └── telas/ ├── tela_splash_test.dart (7 testes — abertura animada) - ├── tela_login_test.dart (9 testes) - ├── tela_dashboard_test.dart (16 testes) + ├── tela_login_test.dart (13 testes) + ├── tela_dashboard_test.dart (19 testes) ├── tela_cadastro_paciente_test.dart (23 testes) ├── tela_editar_paciente_test.dart (6 testes — campos travados + atualização) ├── tela_editar_sessao_test.dart (7 testes — editar/reagendar sessão) ├── tela_financeiro_test.dart (8 testes — resumo financeiro mensal) - ├── tela_configuracoes_test.dart (14 testes) + ├── tela_configuracoes_test.dart (17 testes) ├── tela_historico_geral_evolucoes_test.dart (10 testes) ├── tela_pacientes_test.dart (12 testes) ├── tela_registro_evolucao_test.dart (23 testes — inclui timeline e ditado por voz) @@ -65,7 +69,7 @@ test/ ## Como Rodar ```bash -# Todos os 309 testes +# Todos os 364 testes flutter test # Apenas unitários diff --git a/documentacao/testes/WIDGETS.md b/documentacao/testes/WIDGETS.md index a4b4c47..972fe01 100644 --- a/documentacao/testes/WIDGETS.md +++ b/documentacao/testes/WIDGETS.md @@ -1,11 +1,11 @@ -# 🎨 Testes de Widget (181 testes) +# 🎨 Testes de Widget (191 testes) Testes de componentes visuais: telas, componentes reutilizáveis e utilitários de UI, interação do usuário e estados visuais. --- -## test/widgets/telas/ (154 testes | 13 arquivos) +## test/widgets/telas/ (164 testes | 13 arquivos) Cada arquivo de teste representa uma tela da aplicação. @@ -41,7 +41,7 @@ traçado de ECG e handoff para o login. --- -### tela_dashboard_test.dart (16 testes) +### tela_dashboard_test.dart (19 testes) **Tela:** Início (home após login) — cabeçalho, stat tiles, agenda do dia, pendências, navegação inferior e FAB. @@ -57,14 +57,18 @@ pendências, navegação inferior e FAB. ✓ Stat tiles exibem pacientes ativos e pendências ✓ Agenda vazia exibe estado vazio com mensagem ✓ Link "Ver tudo" navega para aba Sessões -✓ Link Histórico de evoluções navega para a tela +✓ Início não exibe mais o card de histórico de evoluções // Navegação inferior e FAB ✓ Barra inferior alterna abas ✓ FAB na aba Pacientes abre cadastro +✓ Tocar em paciente abre modal de detalhes // Agenda e pendências ✓ Lista sessões de hoje na agenda +✓ Sessão com horário passado aparece como Vencida +✓ Sessão de hoje ainda por vir não conta como pendência +✓ Card da agenda responde ao toque ✓ Contador de sessões do dia exibido no header ✓ Pendências são contabilizadas no stat tile @@ -117,7 +121,7 @@ valor e observações. --- -### tela_login_test.dart (9 testes) +### tela_login_test.dart (13 testes) **Tela:** Login com Google — checkbox LGPD, links legais, botão de entrada. @@ -131,6 +135,12 @@ valor e observações. ✓ Tocar de novo enquanto carrega não chama o serviço uma segunda vez ✓ Login que emite conta e sessão navega para o Dashboard uma única vez ✓ Onde o login não é programático (web) não chama entrar nem trava o botão + +// Links legais +✓ Tocar em "Termos de Uso" abre a página publicada +✓ Tocar em "Política de Privacidade" abre a página publicada +✓ Abrir o link não marca o checkbox de termos +✓ Falha ao abrir o link mostra a URL no snackbar ``` > Usa `ServicoAutenticacaoGoogleControlavel` (fake com `Completer`) para @@ -142,6 +152,13 @@ valor e observações. > estado de carregamento precisa ser desfeito, senão o botão do Google ficaria > coberto por um `IgnorePointer` permanente. > +> **Os links legais são tocados por coordenada**, não por `find.text`: o texto do +> consentimento é um único `RichText`, então `tester.tap(find.text(...))` acerta o +> parágrafo inteiro e passaria mesmo sem `recognizer` nenhum — que era exatamente +> o bug (span colorido, sem toque). O helper usa `getOffsetForCaret` para cair +> sobre o span certo. O mock do canal `plugins.flutter.io/url_launcher` registra +> as URLs pedidas. +> > `ServicoAutenticacaoGoogleDuplaEmissao` reproduz o pior caso do serviço real: > um único login publica em `sessoesConectadas` **e** em `contasConectadas`, > gerando duas mudanças de estado com `estaAutenticado == true`. Como @@ -372,7 +389,7 @@ realizadas, filtro por mês e lista de sessões com visão por paciente. --- -### tela_configuracoes_test.dart (14 testes) +### tela_configuracoes_test.dart (17 testes) **Tela:** Configurações — base de dados (planilha), valor padrão da sessão, preferências, conta (logout) e logs de auditoria. @@ -394,6 +411,9 @@ preferências, conta (logout) e logs de auditoria. ✓ Confirmar logout navega para o login ✓ Logs de auditoria são copiados ao tocar exportar ✓ Abrir planilha com falha exibe snackbar +✓ Sem duplicatas não exibe aviso na base de dados +✓ Duplicata no Drive vira aviso visível +✓ Duas duplicatas usam a redação no plural ``` > Usa `FakeRepoConfig` / `RepoConfigQueFalha` para os caminhos de sucesso e @@ -401,6 +421,11 @@ preferências, conta (logout) e logs de auditoria. > auditoria e `_PlanilhaIdFixo` para o ID da planilha. A abertura da planilha > é testada via mock do canal `plugins.flutter.io/url_launcher` retornando > `false` (falha de abertura). +> +> O aviso de duplicata vem de `provedorPlanilhasDuplicadas` +> (`_PlanilhasDuplicadasFixas`). Ele existe porque o app grava sempre na +> planilha mais recente: sem alerta, a base antiga apenas "para de receber +> sessões" e nada na tela explica o porquê. --- @@ -577,4 +602,4 @@ flutter test test/widgets/telas/tela_pacientes_test.dart | Modal Detalhes Paciente | ✅ | 12 | — | | Rodapé Versão | ✅ | 3 | Overlay de versão | | Ações de Agendamento | ✅ | 6 | — | -| **Total** | **✅** | **181** | Telas principais + componentes/utilitários | +| **Total** | **✅** | **191** | Telas principais + componentes/utilitários | diff --git a/lib/provedores/provedores_dados.dart b/lib/provedores/provedores_dados.dart index 740c70b..e7c0fe5 100644 --- a/lib/provedores/provedores_dados.dart +++ b/lib/provedores/provedores_dados.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../modelos/agendamento.dart'; import '../modelos/evolucao.dart'; import '../modelos/paciente.dart'; +import '../servicos/servico_google_drive.dart'; import '../servicos/servico_repositorio_dados.dart'; import 'provedor_autenticacao.dart'; @@ -105,6 +106,14 @@ class PlanilhaIdNotifier extends Notifier { void definir(String? id) => state = id; } +/// Planilhas com o nome da base que existem no Drive além da que está em uso. +class PlanilhasDuplicadasNotifier extends Notifier> { + @override + List build() => const []; + + void definir(List planilhas) => state = planilhas; +} + final provedorRepositorioDados = Provider((ref) { final sessao = ref.watch(provedorAutenticacao).sessao; if (sessao == null) { @@ -143,6 +152,10 @@ final provedorLogsAuditoria = final provedorPlanilhaId = NotifierProvider( PlanilhaIdNotifier.new, ); +final provedorPlanilhasDuplicadas = + NotifierProvider>( + PlanilhasDuplicadasNotifier.new, + ); void limparDados(WidgetRef ref) { ref.read(provedorCarregamentoDados.notifier).resetar(); @@ -153,6 +166,7 @@ void limparDados(WidgetRef ref) { ref.read(provedorValorSessaoPadrao.notifier).definir('150,00'); ref.read(provedorLogsAuditoria.notifier).definir([]); ref.read(provedorPlanilhaId.notifier).definir(null); + ref.read(provedorPlanilhasDuplicadas.notifier).definir(const []); } /// Referências resolvidas uma única vez, antes de qualquer `await`. @@ -171,6 +185,7 @@ class _DependenciasCarregamento { final ValorSessaoPadraoNotifier valorSessaoPadrao; final LogsAuditoriaNotifier logsAuditoria; final PlanilhaIdNotifier planilhaId; + final PlanilhasDuplicadasNotifier planilhasDuplicadas; final CarregamentoDadosNotifier carregamento; const _DependenciasCarregamento({ @@ -182,6 +197,7 @@ class _DependenciasCarregamento { required this.valorSessaoPadrao, required this.logsAuditoria, required this.planilhaId, + required this.planilhasDuplicadas, required this.carregamento, }); @@ -195,6 +211,7 @@ class _DependenciasCarregamento { valorSessaoPadrao: ref.read(provedorValorSessaoPadrao.notifier), logsAuditoria: ref.read(provedorLogsAuditoria.notifier), planilhaId: ref.read(provedorPlanilhaId.notifier), + planilhasDuplicadas: ref.read(provedorPlanilhasDuplicadas.notifier), carregamento: ref.read(provedorCarregamentoDados.notifier), ); } @@ -248,6 +265,7 @@ Future _executarCarregamento(_DependenciasCarregamento deps) async { deps.valorSessaoPadrao.definir(dados.valorSessaoPadrao); deps.logsAuditoria.definir(dados.logsAuditoria); deps.planilhaId.definir(dados.planilhaId); + deps.planilhasDuplicadas.definir(dados.planilhasDuplicadas); deps.carregamento.sucesso(); } diff --git a/lib/servicos/autorizador_google.dart b/lib/servicos/autorizador_google.dart new file mode 100644 index 0000000..6f88fa8 --- /dev/null +++ b/lib/servicos/autorizador_google.dart @@ -0,0 +1,10 @@ +/// Ponte para a implementação de autorização de cada plataforma. +/// +/// O import condicional mantém `package:google_identity_services_web` (e o +/// `dart:js_interop` que ele usa) fora do build Android/iOS e dos testes, que +/// rodam na VM do Dart. +library; + +export 'autorizador_google_base.dart'; +export 'autorizador_google_stub.dart' + if (dart.library.js_interop) 'autorizador_google_web.dart'; diff --git a/lib/servicos/autorizador_google_base.dart b/lib/servicos/autorizador_google_base.dart new file mode 100644 index 0000000..26c4a96 --- /dev/null +++ b/lib/servicos/autorizador_google_base.dart @@ -0,0 +1,36 @@ +import 'package:google_sign_in/google_sign_in.dart'; + +/// Obtém os tokens de acesso às APIs do Google (Drive/Sheets) para uma conta +/// já autenticada. +/// +/// Existe porque **autenticação e autorização são etapas separadas no web** e +/// cada plataforma resolve a segunda de um jeito: no Android o cliente da +/// própria conta (`conta.authorizationClient`) faz tudo sem UI extra; no web o +/// caminho do plugin sempre acaba reabrindo o seletor de contas — ver +/// `autorizador_google_web.dart` para o porquê. +abstract class AutorizadorGoogle { + /// Autoriza os escopos, abrindo UI se necessário. + /// + /// Chamado logo após o login para que a sessão só seja publicada quando já + /// puder chamar as APIs. + Future garantirAutorizacao( + GoogleSignInAccount conta, + List escopos, + ); + + /// Cabeçalhos de autorização **sem** abrir UI, ou `null` se os escopos ainda + /// não estão autorizados. Usado ao restaurar sessão. + Future?> headersSeAutorizado( + GoogleSignInAccount conta, + List escopos, + ); + + /// Cabeçalhos de autorização para uma requisição às APIs do Google. + /// + /// Lança se não for possível obter o token — no web ele expira em 1h e é + /// renovado aqui, por isso os headers são resolvidos a cada requisição. + Future> headers( + GoogleSignInAccount conta, + List escopos, + ); +} diff --git a/lib/servicos/autorizador_google_stub.dart b/lib/servicos/autorizador_google_stub.dart new file mode 100644 index 0000000..b9284f3 --- /dev/null +++ b/lib/servicos/autorizador_google_stub.dart @@ -0,0 +1,47 @@ +import 'package:google_sign_in/google_sign_in.dart'; + +import 'autorizador_google_base.dart'; + +/// Implementação para as plataformas nativas (e para os testes, que rodam na +/// VM do Dart). +/// +/// Aqui `conta.authorizationClient` é o caminho recomendado e já validado: o +/// Credential Manager devolve a conta e a autorização sai sem nenhuma tela +/// adicional quando o consentimento já foi dado. +AutorizadorGoogle criarAutorizadorGoogle({required String clientIdWeb}) => + AutorizadorGoogleNativo(); + +class AutorizadorGoogleNativo implements AutorizadorGoogle { + @override + Future garantirAutorizacao( + GoogleSignInAccount conta, + List escopos, + ) async { + final cliente = conta.authorizationClient; + final existente = await cliente.authorizationForScopes(escopos); + if (existente != null) return; + await cliente.authorizeScopes(escopos); + } + + @override + Future?> headersSeAutorizado( + GoogleSignInAccount conta, + List escopos, + ) { + return conta.authorizationClient.authorizationHeaders(escopos); + } + + @override + Future> headers( + GoogleSignInAccount conta, + List escopos, + ) async { + final headers = await conta.authorizationClient.authorizationHeaders( + escopos, + ); + if (headers == null) { + throw StateError('Não foi possível obter autorização do Google.'); + } + return headers; + } +} diff --git a/lib/servicos/autorizador_google_web.dart b/lib/servicos/autorizador_google_web.dart new file mode 100644 index 0000000..4b39628 --- /dev/null +++ b/lib/servicos/autorizador_google_web.dart @@ -0,0 +1,166 @@ +import 'dart:async'; +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 '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`): +/// +/// ```dart +/// prompt: userHint == null ? '' : 'select_account', +/// login_hint: userHint, +/// ``` +/// +/// 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`. +AutorizadorGoogle criarAutorizadorGoogle({required String clientIdWeb}) => + AutorizadorGoogleWeb(clientIdWeb); + +/// Margem de segurança antes da expiração do access token: pedir um novo antes +/// de o antigo vencer evita que uma requisição longa comece com token válido e +/// termine com ele vencido. +const _margemExpiracao = Duration(minutes: 1); + +class _TokenEmCache { + final String token; + final DateTime expiraEm; + final Set escopos; + + const _TokenEmCache({ + required this.token, + required this.expiraEm, + required this.escopos, + }); + + bool cobre(List pedidos, DateTime agora) => + agora.isBefore(expiraEm) && pedidos.every(escopos.contains); +} + +class AutorizadorGoogleWeb implements AutorizadorGoogle { + AutorizadorGoogleWeb(this._clientId); + + final String _clientId; + + /// Cache por e-mail: trocar de conta não pode reaproveitar o token da + /// anterior. + final Map _tokens = {}; + + /// Uma requisição em andamento por conta — sem isso, duas chamadas + /// simultâneas às APIs abririam dois popups de autorização. + final Map> _pendentes = {}; + + @override + Future garantirAutorizacao( + GoogleSignInAccount conta, + List escopos, + ) async { + await _token(conta.email, escopos); + } + + @override + Future?> headersSeAutorizado( + GoogleSignInAccount conta, + List escopos, + ) async { + final cache = _tokens[conta.email]; + if (cache == null || !cache.cobre(escopos, DateTime.now())) return null; + return _cabecalhos(cache.token); + } + + @override + Future> headers( + GoogleSignInAccount conta, + List escopos, + ) async { + return _cabecalhos(await _token(conta.email, escopos)); + } + + Map _cabecalhos(String token) => { + 'Authorization': 'Bearer $token', + }; + + Future _token(String email, List escopos) { + final cache = _tokens[email]; + if (cache != null && cache.cobre(escopos, DateTime.now())) { + return Future.value(cache.token); + } + return _pendentes[email] ??= _solicitarToken(email, escopos).whenComplete( + () => _pendentes.remove(email), + ); + } + + Future _solicitarToken(String email, List escopos) { + final resultado = Completer(); + + final cliente = oauth2.oauth2.initTokenClient( + oauth2.TokenClientConfig( + client_id: _clientId, + scope: escopos, + // O par que resolve a dupla escolha de conta: o hint leva o Google + // direto à conta que o usuário já escolheu no botão, e o `prompt` + // vazio impede que o seletor seja reaberto. Trocar `prompt` por + // 'select_account' (ou remover o hint) traz o bug de volta. + login_hint: email, + prompt: '', + include_granted_scopes: true, + callback: (resposta) { + final erro = resposta.error; + final token = resposta.access_token; + if (erro != null || token == null) { + _tokens.remove(email); + resultado.completeError( + StateError( + 'Não foi possível obter autorização do Google' + '${erro == null ? '' : ' ($erro)'}.', + ), + ); + return; + } + _tokens[email] = _TokenEmCache( + token: token, + expiraEm: DateTime.now().add( + Duration(seconds: resposta.expires_in ?? 3600) - _margemExpiracao, + ), + escopos: resposta.scope.toSet(), + ); + resultado.complete(token); + }, + error_callback: (erro) { + _tokens.remove(email); + developer.log( + 'Autorização 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; + } +} diff --git a/lib/servicos/servico_autenticacao_google.dart b/lib/servicos/servico_autenticacao_google.dart index 5636408..774af52 100644 --- a/lib/servicos/servico_autenticacao_google.dart +++ b/lib/servicos/servico_autenticacao_google.dart @@ -4,6 +4,7 @@ import 'dart:developer' as developer; import 'package:flutter/foundation.dart'; import 'package:google_sign_in/google_sign_in.dart'; +import 'autorizador_google.dart'; import 'cliente_google_autenticado.dart'; const googleOAuthClientIdWeb = String.fromEnvironment( @@ -65,6 +66,13 @@ class ServicoAutenticacaoGoogleReal implements ServicoAutenticacaoGoogle { StreamSubscription? _assinaturaEventos; Future? _inicializacao; + /// Quem obtém os tokens das APIs Google. A implementação vem por import + /// condicional: no web fala direto com o GIS para não reabrir o seletor de + /// contas; fora dele usa `conta.authorizationClient`. + final AutorizadorGoogle _autorizador = criarAutorizadorGoogle( + clientIdWeb: googleOAuthClientIdWeb, + ); + @override Stream get contasConectadas => _contasController.stream; @@ -143,50 +151,20 @@ class ServicoAutenticacaoGoogleReal implements ServicoAutenticacaoGoogle { _sessoesController.add(_criarSessao(conta)); } - /// Cliente usado para pedir os tokens de autorização. - /// - /// No web usamos o cliente **sem** vínculo com a conta de propósito: quando - /// recebe um hint de usuário, `google_sign_in_web` monta o token client com - /// `prompt: 'select_account'` (ver `src/gis_client.dart`), o que reabre a tela - /// de escolha de conta logo depois de o usuário já ter escolhido a conta no - /// botão do Google — duas seleções para um único login. Sem o hint, o - /// `prompt` fica vazio e o Google reaproveita a sessão ativa, só mostrando o - /// seletor quando existe ambiguidade real entre contas conectadas. - /// - /// Fora do web mantemos o cliente da conta, que é a forma recomendada e já - /// funciona bem no fluxo nativo. - GoogleSignInAuthorizationClient _clienteAutorizacao( - GoogleSignInAccount conta, - ) { - return kIsWeb - ? GoogleSignIn.instance.authorizationClient - : conta.authorizationClient; - } - /// Garante que os escopos estejam autorizados antes de considerar a sessão /// válida. Autenticação (quem é o usuário) e autorização (acesso ao /// Drive/Sheets) são etapas separadas na API 7.x — foi exatamente essa /// distinção que faltava na 6.x e deixava a sessão sem access token. - Future _garantirAutorizacao(GoogleSignInAccount conta) async { - final cliente = _clienteAutorizacao(conta); - final existente = await cliente.authorizationForScopes(escoposGoogleFisio); - if (existente != null) return; - await cliente.authorizeScopes(escoposGoogleFisio); + Future _garantirAutorizacao(GoogleSignInAccount conta) { + return _autorizador.garantirAutorizacao(conta, escoposGoogleFisio); } SessaoGoogle _criarSessao(GoogleSignInAccount conta) { // Resolvido a cada requisição: no web o access token expira em 1h e não é - // renovado sozinho, então pedir os headers na hora do uso permite que - // `authorizationHeaders` devolva um token novo quando o antigo vencer. - Future> obterHeaders() async { - final headers = await _clienteAutorizacao( - conta, - ).authorizationHeaders(escoposGoogleFisio); - if (headers == null) { - throw StateError('Não foi possível obter autorização do Google.'); - } - return headers; - } + // renovado sozinho, então pedir os headers na hora do uso permite obter um + // token novo quando o antigo vencer. + Future> obterHeaders() => + _autorizador.headers(conta, escoposGoogleFisio); return SessaoGoogle( nomeUsuario: conta.displayName ?? conta.email, @@ -206,9 +184,10 @@ class ServicoAutenticacaoGoogleReal implements ServicoAutenticacaoGoogle { // A restauração leve devolve identidade; a autorização pode ter expirado. // Validar aqui evita entregar uma sessão que falharia na primeira chamada // à API. - final headers = await _clienteAutorizacao( + final headers = await _autorizador.headersSeAutorizado( conta, - ).authorizationForScopes(escoposGoogleFisio); + escoposGoogleFisio, + ); if (headers == null) return null; return _criarSessao(conta); diff --git a/lib/servicos/servico_google_drive.dart b/lib/servicos/servico_google_drive.dart index 2d97344..62e1bc5 100644 --- a/lib/servicos/servico_google_drive.dart +++ b/lib/servicos/servico_google_drive.dart @@ -1,6 +1,19 @@ import 'package:googleapis/drive/v3.dart' as drive; import 'package:http/http.dart' as http; +/// Planilha candidata a ser a base do app, encontrada no Drive do usuário. +class PlanilhaBanco { + final String id; + final String nome; + final DateTime? modificadaEm; + + const PlanilhaBanco({ + required this.id, + required this.nome, + this.modificadaEm, + }); +} + class ServicoGoogleDrive { static const nomeBanco = '__saas_fisio_db__'; @@ -8,17 +21,43 @@ class ServicoGoogleDrive { ServicoGoogleDrive(http.Client cliente) : _api = drive.DriveApi(cliente); - Future buscarPlanilhaBanco() async { + /// Lista as planilhas do usuário cujo nome é **exatamente** [nomeBanco], + /// da mais recente para a mais antiga. + /// + /// A busca era por `name contains`, o que trazia junto as cópias criadas pelo + /// próprio Drive (`__saas_fisio_db__ (1)`, `Cópia de __saas_fisio_db__`). + /// Como o resultado vinha ordenado por `modifiedTime` e o app pegava o + /// primeiro, bastava abrir uma cópia para que os dados novos passassem a ser + /// gravados nela — em silêncio. Nome exato elimina esse caso; quando ainda + /// assim houver mais de uma, quem chama decide o que fazer (ver + /// `RepositorioDadosGoogle.planilhasDuplicadas`). + Future> buscarPlanilhasBanco() async { final resultado = await _api.files.list( - q: "name contains '$nomeBanco' and mimeType='application/vnd.google-apps.spreadsheet' and trashed=false", + q: + "name = '$nomeBanco' and " + "mimeType='application/vnd.google-apps.spreadsheet' and " + 'trashed=false', spaces: 'drive', pageSize: 10, orderBy: 'modifiedTime desc', $fields: 'files(id,name,modifiedTime)', ); - final arquivos = resultado.files ?? []; - if (arquivos.isEmpty) return null; - return arquivos.first.id; + return [ + for (final arquivo in resultado.files ?? const []) + if (arquivo.id != null) + PlanilhaBanco( + id: arquivo.id!, + nome: arquivo.name ?? nomeBanco, + modificadaEm: arquivo.modifiedTime, + ), + ]; + } + + /// A planilha mais recente com o nome da base, ou `null` se não houver + /// nenhuma. + Future buscarPlanilhaBanco() async { + final planilhas = await buscarPlanilhasBanco(); + return planilhas.isEmpty ? null : planilhas.first.id; } } diff --git a/lib/servicos/servico_google_sheets.dart b/lib/servicos/servico_google_sheets.dart index 3f07751..ffa923f 100644 --- a/lib/servicos/servico_google_sheets.dart +++ b/lib/servicos/servico_google_sheets.dart @@ -194,7 +194,14 @@ class ServicoGoogleSheets { return 1; } - final versaoStr = valores[0].toString().trim(); + // `values` vem como lista de linhas: [[valor]]. Usar `valores[0]` direto + // dava "[1]" no `toString()`, o parse falhava e **toda** planilha era + // tratada como versão 1 — inclusive uma de versão futura, que deveria + // ser recusada por `validarVersao`. + final primeiraLinha = valores.first; + final versaoStr = primeiraLinha.isEmpty + ? '' + : primeiraLinha.first.toString().trim(); final versao = int.tryParse(versaoStr); if (versao == null) { diff --git a/lib/servicos/servico_repositorio_dados.dart b/lib/servicos/servico_repositorio_dados.dart index 7930f73..ad423af 100644 --- a/lib/servicos/servico_repositorio_dados.dart +++ b/lib/servicos/servico_repositorio_dados.dart @@ -20,6 +20,13 @@ class DadosCarregados { final List logsAuditoria; final String planilhaId; + /// Planilhas com o nome da base encontradas além da que está em uso. + /// + /// Vazio no caso normal. Quando não está, o app escolheu a mais recente e a + /// tela de Configurações avisa — sem isso o usuário não teria como perceber + /// que passou a gravar em outra planilha. + final List planilhasDuplicadas; + const DadosCarregados({ required this.pacientes, required this.agendamentos, @@ -27,6 +34,7 @@ class DadosCarregados { required this.valorSessaoPadrao, required this.logsAuditoria, required this.planilhaId, + this.planilhasDuplicadas = const [], }); } @@ -34,13 +42,22 @@ class RepositorioDadosGoogle { final ServicoGoogleDrive _drive; final ServicoGoogleSheets _sheets; String? _planilhaId; + List _planilhasDuplicadas = const []; RepositorioDadosGoogle(http.Client cliente) : _drive = ServicoGoogleDrive(cliente), _sheets = ServicoGoogleSheets(cliente); + /// Planilhas com o nome da base que existem além da que está em uso. + /// + /// Só é preenchido quando a busca no Drive realmente roda: se o ID veio do + /// cache ou das preferências, o app nem consulta o Drive e a lista fica + /// vazia. + List get planilhasDuplicadas => _planilhasDuplicadas; + void limparCache() { _planilhaId = null; + _planilhasDuplicadas = const []; Preferencias.limparPlanilhaId(); } @@ -77,7 +94,20 @@ class RepositorioDadosGoogle { } } - final existente = await _drive.buscarPlanilhaBanco(); + final encontradas = await _drive.buscarPlanilhasBanco(); + _planilhasDuplicadas = encontradas.length > 1 + ? encontradas.sublist(1) + : const []; + if (_planilhasDuplicadas.isNotEmpty) { + developer.log( + 'Há ${encontradas.length} planilhas chamadas ' + '"${ServicoGoogleDrive.nomeBanco}" no Drive. Usando a mais recente ' + '(${encontradas.first.id}); as outras serão ignoradas.', + name: 'RepositorioDadosGoogle', + ); + } + + final existente = encontradas.isEmpty ? null : encontradas.first.id; if (existente != null) { _planilhaId = existente; try { @@ -160,6 +190,7 @@ class RepositorioDadosGoogle { ), logsAuditoria: logs, planilhaId: id, + planilhasDuplicadas: _planilhasDuplicadas, ); } catch (e, st) { developer.log( diff --git a/lib/telas/tela_configuracoes.dart b/lib/telas/tela_configuracoes.dart index 32456f7..32161d8 100644 --- a/lib/telas/tela_configuracoes.dart +++ b/lib/telas/tela_configuracoes.dart @@ -76,6 +76,7 @@ class _TelaConfiguracoesState extends ConsumerState { final nome = auth.sessao?.nomeUsuario ?? 'Profissional'; final email = auth.sessao?.email ?? ''; final planilhaId = ref.watch(provedorPlanilhaId); + final duplicadas = ref.watch(provedorPlanilhasDuplicadas); return Material( color: FisioCores.surface, @@ -187,6 +188,10 @@ class _TelaConfiguracoesState extends ConsumerState { : FisioCores.warning), ], ), + if (duplicadas.isNotEmpty) ...[ + const SizedBox(height: 12), + _avisoDuplicatas(duplicadas.length), + ], const SizedBox(height: 12), Row( children: [ @@ -305,6 +310,47 @@ class _TelaConfiguracoesState extends ConsumerState { ); } + /// Aviso de que existe mais de uma planilha `__saas_fisio_db__` no Drive. + /// + /// O app usa a mais recente. Sem esse aviso o usuário não teria como + /// perceber que os dados novos foram para outra planilha — a antiga + /// simplesmente parece ter parado de receber sessões. + Widget _avisoDuplicatas(int quantidade) { + final plural = quantidade > 1; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 9), + decoration: BoxDecoration( + color: FisioCores.warning.withValues(alpha: 0.11), + borderRadius: BorderRadius.circular(11), + border: Border.all(color: FisioCores.warning.withValues(alpha: 0.3)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, + color: FisioCores.warning, size: 17), + const SizedBox(width: 8), + Expanded( + child: Text( + plural + ? 'Há mais $quantidade planilhas com este nome no seu Drive. ' + 'O app está usando a mais recente; apague ou renomeie ' + 'as outras para evitar dados em duas bases.' + : 'Há outra planilha com este nome no seu Drive. O app está ' + 'usando a mais recente; apague ou renomeie a outra para ' + 'evitar dados em duas bases.', + style: const TextStyle( + fontSize: 11.5, + fontWeight: FontWeight.w600, + height: 1.35, + color: Color(0xFF7C5A11)), + ), + ), + ], + ), + ); + } + Widget _botaoSecundario(String label, VoidCallback? onTap) { return GestureDetector( onTap: onTap, diff --git a/lib/telas/tela_login.dart b/lib/telas/tela_login.dart index 11e97cf..add6af8 100644 --- a/lib/telas/tela_login.dart +++ b/lib/telas/tela_login.dart @@ -1,8 +1,13 @@ +import 'dart:developer' as developer; + +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'; import 'tela_dashboard.dart'; class TelaLogin extends ConsumerStatefulWidget { @@ -17,9 +22,22 @@ class _TelaLoginState extends ConsumerState { bool _navegou = false; ProviderSubscription? _assinaturaAuth; + /// Reconhecedores dos links legais dentro do `Text.rich`. + /// + /// Um `TextSpan` não recebe toque sozinho — sem `recognizer` ele é só texto + /// colorido, que foi exatamente o problema: dava para aceitar os termos sem + /// ter como lê-los. Ficam em campos porque `TapGestureRecognizer` precisa de + /// `dispose()`; criá-los no `build` vazaria um por quadro. + late final TapGestureRecognizer _tapPrivacidade; + late final TapGestureRecognizer _tapTermos; + @override void initState() { super.initState(); + _tapPrivacidade = TapGestureRecognizer() + ..onTap = () => _abrirLink(LinksLegais.politicaPrivacidade); + _tapTermos = TapGestureRecognizer() + ..onTap = () => _abrirLink(LinksLegais.termosDeUso); WidgetsBinding.instance.addPostFrameCallback((_) { _ouvirEstadoAutenticacao(); }); @@ -28,9 +46,40 @@ class _TelaLoginState extends ConsumerState { @override void dispose() { _assinaturaAuth?.close(); + _tapPrivacidade.dispose(); + _tapTermos.dispose(); super.dispose(); } + Future _abrirLink(String url) async { + try { + // `externalApplication` abre em nova aba no web e no navegador do sistema + // no Android — em nenhum dos dois o usuário perde a tela de login nem o + // estado do checkbox. + final abriu = await launchUrl( + Uri.parse(url), + mode: LaunchMode.externalApplication, + ); + if (!abriu && mounted) { + _avisarFalhaAoAbrir(url); + } + } catch (e, st) { + developer.log( + 'Falha ao abrir link legal: $url', + error: e, + stackTrace: st, + name: 'TelaLogin', + ); + if (mounted) _avisarFalhaAoAbrir(url); + } + } + + void _avisarFalhaAoAbrir(String url) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Não foi possível abrir o link. Acesse $url')), + ); + } + void _ouvirEstadoAutenticacao() { // `initState` roda via addPostFrameCallback só uma vez por State, mas // guardamos e fechamos a assinatura em dispose() para nunca deixar um @@ -193,33 +242,41 @@ class _TelaLoginState extends ConsumerState { ), ), const SizedBox(width: 11), - const Expanded( + Expanded( child: Text.rich( TextSpan( - style: TextStyle( + style: const TextStyle( fontSize: 12, height: 1.5, fontWeight: FontWeight.w500, color: FisioCores.textSecondary, ), children: [ - TextSpan(text: 'Li e concordo com a '), + const TextSpan( + text: 'Li e concordo com a ', + ), TextSpan( text: 'Política de Privacidade', - style: TextStyle( + style: const TextStyle( color: FisioCores.primary, fontWeight: FontWeight.w700, + decoration: TextDecoration.underline, + decorationColor: FisioCores.primary, ), + recognizer: _tapPrivacidade, ), - TextSpan(text: ' e os '), + const TextSpan(text: ' e os '), TextSpan( text: 'Termos de Uso', - style: TextStyle( + style: const TextStyle( color: FisioCores.primary, fontWeight: FontWeight.w700, + decoration: TextDecoration.underline, + decorationColor: FisioCores.primary, ), + recognizer: _tapTermos, ), - TextSpan( + const TextSpan( text: ', e autorizo o tratamento dos meus dados conforme a LGPD.', ), diff --git a/lib/utilitarios/links_legais.dart b/lib/utilitarios/links_legais.dart new file mode 100644 index 0000000..b63efd4 --- /dev/null +++ b/lib/utilitarios/links_legais.dart @@ -0,0 +1,12 @@ +/// Páginas legais publicadas junto com o app. +/// +/// São arquivos estáticos em `branding/`, copiados para `build/web` pelo alvo +/// `prod-web` do Makefile e pelos workflows de deploy. O `firebase.json` tem +/// rewrites explícitos para os dois caminhos — sem eles o catch-all mandaria +/// tudo para o `index.html` e as páginas nunca apareceriam. +class LinksLegais { + static const base = 'https://app-fisio-care-2.web.app'; + + static const termosDeUso = '$base/termos.html'; + static const politicaPrivacidade = '$base/privacidade.html'; +} diff --git a/pubspec.lock b/pubspec.lock index 75ea038..ef48118 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -286,7 +286,7 @@ packages: source: hosted version: "8.1.0" google_identity_services_web: - dependency: transitive + dependency: "direct main" description: name: google_identity_services_web sha256: "5d187c46dc59e02646e10fe82665fc3884a9b71bc1c90c2b8b749316d33ee454" diff --git a/pubspec.yaml b/pubspec.yaml index 8054c65..3255e86 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -39,6 +39,10 @@ dependencies: flutter_riverpod: ^3.3.1 google_sign_in: ^7.2.0 google_sign_in_web: ^1.1.3 + # Usado só no web (lib/servicos/autorizador_google_web.dart) para montar o + # token client do GIS com login_hint sem forçar o seletor de contas, coisa + # que a API do google_sign_in_web não permite. Já vinha como transitiva. + google_identity_services_web: ^0.3.3 googleapis: ^16.0.0 http: ^1.6.0 url_launcher: ^6.3.2 diff --git a/test/unitarios/auxiliares/servidor_google_fake.dart b/test/unitarios/auxiliares/servidor_google_fake.dart new file mode 100644 index 0000000..1a41cbd --- /dev/null +++ b/test/unitarios/auxiliares/servidor_google_fake.dart @@ -0,0 +1,148 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +/// Uma chamada HTTP que o app fez ao Google, já com o corpo decodificado. +class ChamadaGoogle { + final String metodo; + final Uri url; + final Map corpo; + + const ChamadaGoogle({ + required this.metodo, + required this.url, + required this.corpo, + }); + + /// Valores da primeira linha enviada em `values.append` / `values.update`. + List get valoresEnviados { + final values = (corpo['values'] as List?) ?? const []; + if (values.isEmpty) return const []; + return [for (final valor in values.first as List) valor.toString()]; + } + + @override + String toString() => '$metodo ${url.path}'; +} + +/// Google Sheets e Drive de mentira, no nível do HTTP. +/// +/// Os serviços do app instanciam `DriveApi`/`SheetsApi` diretamente a partir de +/// um `http.Client`, então trocar o cliente é o único ponto de injeção que +/// existe — e tem a vantagem de exercitar a montagem real das requisições +/// (query do Drive, range das abas, ordem dos valores), que é justamente onde +/// os bugs desta camada apareceram. +class ServidorGoogleFake { + /// Arquivos devolvidos por `files.list`, na ordem. + List> arquivosDrive; + + /// Conteúdo de cada aba (sem cabeçalho), como vem de `values.get`. + Map>> abas; + + /// Versão de esquema gravada na aba `Versao`. `null` simula a aba ausente. + int? versaoEsquema; + + /// Abas presentes na planilha, para `spreadsheets.get`. + List abasExistentes; + + /// ID devolvido por `spreadsheets.create`. + String idPlanilhaCriada; + + /// Ranges de `values.update` que devem falhar (ex.: `Versao!A1:B1`). + Set rangesQueFalham; + + final List chamadas = []; + + ServidorGoogleFake({ + this.arquivosDrive = const [], + Map>>? abas, + this.versaoEsquema = 1, + this.abasExistentes = const [ + 'Pacientes', + 'Agenda', + 'Evolucoes', + 'Configuracoes', + 'Auditoria', + 'Versao', + ], + this.idPlanilhaCriada = 'PLANILHA_NOVA', + Set? rangesQueFalham, + }) : abas = abas ?? {}, + rangesQueFalham = rangesQueFalham ?? {}; + + http.Client get cliente => MockClient(_tratar); + + /// Chamadas feitas ao Drive (útil para afirmar que o app **não** buscou). + Iterable get chamadasDrive => + chamadas.where((c) => c.url.path.startsWith('/drive/')); + + /// Tudo que não é leitura — para afirmar que nada foi gravado. + Iterable get escritas => + chamadas.where((c) => c.metodo != 'GET'); + + Iterable chamadasPara(String trechoDaUrl) => + chamadas.where((c) => Uri.decodeFull(c.url.path).contains(trechoDaUrl)); + + Future _tratar(http.Request requisicao) async { + final url = requisicao.url; + final corpo = requisicao.body.isEmpty + ? {} + : jsonDecode(requisicao.body) as Map; + chamadas.add( + ChamadaGoogle(metodo: requisicao.method, url: url, corpo: corpo), + ); + + final caminho = Uri.decodeFull(url.path); + + if (caminho.startsWith('/drive/v3/files')) { + return _json({'files': arquivosDrive}); + } + + if (caminho.contains('/values/')) { + final range = caminho.split('/values/').last.split(':append').first; + if (requisicao.method == 'GET') { + return _json({'values': _valoresDoRange(range)}); + } + if (rangesQueFalham.contains(range)) { + return http.Response('{"error":{"code":400}}', 400); + } + return _json({}); + } + + if (caminho.endsWith(':batchUpdate')) { + return _json({}); + } + + if (requisicao.method == 'POST' && caminho.endsWith('/v4/spreadsheets')) { + return _json({'spreadsheetId': idPlanilhaCriada}); + } + + // spreadsheets.get + return _json({ + 'sheets': [ + for (final aba in abasExistentes) + { + 'properties': {'title': aba}, + }, + ], + }); + } + + List>? _valoresDoRange(String range) { + if (range.startsWith('Versao!')) { + return versaoEsquema == null + ? null + : [ + ['$versaoEsquema'], + ]; + } + return abas[range.split('!').first] ?? const []; + } + + http.Response _json(Object corpo) => http.Response( + jsonEncode(corpo), + 200, + headers: {'content-type': 'application/json; charset=utf-8'}, + ); +} diff --git a/test/unitarios/servicos/servico_google_drive_test.dart b/test/unitarios/servicos/servico_google_drive_test.dart new file mode 100644 index 0000000..b23286d --- /dev/null +++ b/test/unitarios/servicos/servico_google_drive_test.dart @@ -0,0 +1,75 @@ +import 'package:alta/servicos/servico_google_drive.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../auxiliares/servidor_google_fake.dart'; + +void main() { + late ServidorGoogleFake servidor; + + setUp(() => servidor = ServidorGoogleFake()); + + ServicoGoogleDrive criarServico() => ServicoGoogleDrive(servidor.cliente); + + group('ServicoGoogleDrive', () { + test('busca pelo nome exato da base, não por "contains"', () async { + await criarServico().buscarPlanilhasBanco(); + + final q = servidor.chamadas.single.url.queryParameters['q']!; + expect(q, contains("name = '__saas_fisio_db__'")); + expect(q, isNot(contains('contains'))); + expect(q, contains('trashed=false')); + expect( + q, + contains("mimeType='application/vnd.google-apps.spreadsheet'"), + ); + }); + + test('pede a lista ordenada da mais recente para a mais antiga', () async { + await criarServico().buscarPlanilhasBanco(); + + final parametros = servidor.chamadas.single.url.queryParameters; + expect(parametros['orderBy'], 'modifiedTime desc'); + }); + + test('converte os arquivos preservando id, nome e data', () async { + servidor.arquivosDrive = [ + { + 'id': 'ID_RECENTE', + 'name': '__saas_fisio_db__', + 'modifiedTime': '2026-07-20T10:00:00.000Z', + }, + ]; + + final planilhas = await criarServico().buscarPlanilhasBanco(); + + expect(planilhas, hasLength(1)); + expect(planilhas.first.id, 'ID_RECENTE'); + expect(planilhas.first.nome, '__saas_fisio_db__'); + expect(planilhas.first.modificadaEm, DateTime.utc(2026, 7, 20, 10)); + }); + + test('descarta arquivos sem id — não dá para abrir uma planilha assim', () async { + servidor.arquivosDrive = [ + {'name': '__saas_fisio_db__'}, + {'id': 'ID_VALIDO', 'name': '__saas_fisio_db__'}, + ]; + + final planilhas = await criarServico().buscarPlanilhasBanco(); + + expect(planilhas.map((p) => p.id), ['ID_VALIDO']); + }); + + test('buscarPlanilhaBanco devolve null quando não há nenhuma', () async { + expect(await criarServico().buscarPlanilhaBanco(), isNull); + }); + + test('buscarPlanilhaBanco devolve a primeira (mais recente)', () async { + servidor.arquivosDrive = [ + {'id': 'RECENTE', 'name': '__saas_fisio_db__'}, + {'id': 'ANTIGA', 'name': '__saas_fisio_db__'}, + ]; + + expect(await criarServico().buscarPlanilhaBanco(), 'RECENTE'); + }); + }); +} diff --git a/test/unitarios/servicos/servico_google_sheets_test.dart b/test/unitarios/servicos/servico_google_sheets_test.dart new file mode 100644 index 0000000..7c6e541 --- /dev/null +++ b/test/unitarios/servicos/servico_google_sheets_test.dart @@ -0,0 +1,123 @@ +import 'package:alta/servicos/servico_google_sheets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../auxiliares/servidor_google_fake.dart'; + +void main() { + late ServidorGoogleFake servidor; + + setUp(() => servidor = ServidorGoogleFake()); + + ServicoGoogleSheets criarServico() => ServicoGoogleSheets(servidor.cliente); + + group('ServicoGoogleSheets — leitura', () { + test('lerAba descarta linhas totalmente vazias da planilha', () async { + servidor.abas = { + 'Pacientes': [ + ['P001', 'Ana'], + ['', ' '], + ['P002', 'Bruno'], + ], + }; + + final linhas = await criarServico().lerAba('ID', 'Pacientes'); + + expect(linhas, [ + ['P001', 'Ana'], + ['P002', 'Bruno'], + ]); + }); + + test('lerAba pede o intervalo a partir da segunda linha (pula cabeçalho)', () async { + await criarServico().lerAba('ID', 'Agenda'); + + expect( + Uri.decodeFull(servidor.chamadas.single.url.path), + endsWith('/values/Agenda!A2:Z'), + ); + }); + }); + + group('ServicoGoogleSheets — versão do esquema', () { + test('lê o número dentro da linha, não a linha inteira', () async { + // Regressão: `valores[0].toString()` dava "[2]", o parse falhava e o + // serviço respondia 1 — uma planilha de versão futura passava batido. + servidor.versaoEsquema = 2; + + expect(await criarServico().lerVersaoEsquema('ID'), 2); + }); + + test('assume versão 1 quando a aba Versao está vazia', () async { + servidor.versaoEsquema = null; + + expect(await criarServico().lerVersaoEsquema('ID'), 1); + }); + + test('validarVersao aceita a versão suportada', () async { + servidor.versaoEsquema = 1; + + await expectLater(criarServico().validarVersao('ID'), completes); + }); + + test('validarVersao recusa planilha de versão futura', () async { + servidor.versaoEsquema = 2; + + expect( + () => criarServico().validarVersao('ID'), + throwsA(isA()), + ); + }); + }); + + group('ServicoGoogleSheets — escrita', () { + test('criarPlanilhaBanco cria todas as abas, inclusive Versao', () async { + final id = await criarServico().criarPlanilhaBanco(); + + expect(id, 'PLANILHA_NOVA'); + final criacao = servidor.chamadas.first; + final titulos = [ + for (final aba in criacao.corpo['sheets'] as List) + (aba as Map)['properties']['title'] as String, + ]; + // A aba Versao precisa nascer junto: `salvarVersaoEsquema` escreve nela + // logo em seguida e a Sheets API devolve 400 se ela não existir. + expect(titulos, contains('Versao')); + expect(titulos, containsAll(ServicoGoogleSheets.cabecalhos.keys)); + }); + + test('criarPlanilhaBanco grava os cabeçalhos de cada aba', () async { + await criarServico().criarPlanilhaBanco(); + + final cabecalhoPacientes = servidor + .chamadasPara('/values/Pacientes!A1:S1') + .single; + expect( + cabecalhoPacientes.valoresEnviados, + ServicoGoogleSheets.cabecalhos['Pacientes'], + ); + expect(servidor.chamadasPara('/values/Agenda!A1:I1'), hasLength(1)); + }); + + test('garantirEstrutura cria só as abas que faltam', () async { + servidor.abasExistentes = ['Pacientes', 'Agenda']; + + await criarServico().garantirEstrutura('ID'); + + final batch = servidor.chamadasPara(':batchUpdate').single; + final titulos = [ + for (final req in batch.corpo['requests'] as List) + (req as Map)['addSheet']['properties']['title'] as String, + ]; + expect(titulos, ['Evolucoes', 'Configuracoes', 'Auditoria']); + }); + + test('inserirLinha manda append com USER_ENTERED', () async { + await criarServico().inserirLinha('ID', 'Auditoria', ['L1', 'hoje']); + + final chamada = servidor.chamadasPara('/values/Auditoria!A:Z').single; + expect(chamada.metodo, 'POST'); + expect(chamada.valoresEnviados, ['L1', 'hoje']); + expect(chamada.url.queryParameters['valueInputOption'], 'USER_ENTERED'); + }); + }); +} diff --git a/test/unitarios/servicos/servico_repositorio_dados_test.dart b/test/unitarios/servicos/servico_repositorio_dados_test.dart new file mode 100644 index 0000000..afe8626 --- /dev/null +++ b/test/unitarios/servicos/servico_repositorio_dados_test.dart @@ -0,0 +1,418 @@ +import 'package:alta/modelos/agendamento.dart'; +import 'package:alta/modelos/evolucao.dart'; +import 'package:alta/modelos/paciente.dart'; +import 'package:alta/servicos/preferencias.dart'; +import 'package:alta/servicos/servico_repositorio_dados.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../auxiliares/servidor_google_fake.dart'; + +Paciente _paciente() => Paciente( + idPaciente: 'P001', + nome: 'Ana Souza', + telefone: '11999999999', + dataNascimento: DateTime(1990, 5, 20), + cpf: '12345678901', + endereco: 'Rua A, 10', + situacao: 'Ativo', +); + +Agendamento _agendamento() => Agendamento( + idAgendamento: 'A001', + idPaciente: 'P001', + data: DateTime(2026, 7, 20), + horaInicio: '08:00', + horaFim: '09:00', + valorSessao: 150, + situacao: Agendamento.situacaoAgendado, +); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late ServidorGoogleFake servidor; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + servidor = ServidorGoogleFake(); + }); + + RepositorioDadosGoogle criarRepositorio() => + RepositorioDadosGoogle(servidor.cliente); + + group('RepositorioDadosGoogle — obterPlanilhaId', () { + test('usa o ID salvo nas preferências sem consultar o Drive', () async { + SharedPreferences.setMockInitialValues({'planilha_id': 'ID_SALVO'}); + + final id = await criarRepositorio().obterPlanilhaId(); + + expect(id, 'ID_SALVO'); + expect(servidor.chamadasDrive, isEmpty); + }); + + test('descarta o ID salvo quando a planilha tem versão incompatível', () async { + SharedPreferences.setMockInitialValues({'planilha_id': 'ID_ANTIGO'}); + servidor.versaoEsquema = 2; + servidor.arquivosDrive = [ + {'id': 'ID_DO_DRIVE', 'name': '__saas_fisio_db__'}, + ]; + + // A busca no Drive acha outra planilha, mas ela também é versão 2 — + // o importante é que o ID inválido saiu das preferências. + await expectLater( + criarRepositorio().obterPlanilhaId(), + throwsA(isA()), + ); + expect(await Preferencias.lerPlanilhaId(), isNull); + expect(servidor.chamadasDrive, isNotEmpty); + }); + + test('adota a planilha existente no Drive e a persiste', () async { + servidor.arquivosDrive = [ + {'id': 'ID_EXISTENTE', 'name': '__saas_fisio_db__'}, + ]; + + final id = await criarRepositorio().obterPlanilhaId(); + + expect(id, 'ID_EXISTENTE'); + expect(await Preferencias.lerPlanilhaId(), 'ID_EXISTENTE'); + }); + + test('cria uma planilha quando não existe nenhuma', () async { + final id = await criarRepositorio().obterPlanilhaId(); + + expect(id, 'PLANILHA_NOVA'); + expect(await Preferencias.lerPlanilhaId(), 'PLANILHA_NOVA'); + expect(servidor.chamadasPara('/values/Versao!A1:B1'), hasLength(1)); + }); + + test('persiste o ID mesmo se gravar a versão falhar', () async { + // Regressão: a falha ao escrever na aba Versao deixava o ID sem salvar, + // e a tentativa seguinte criava **outra** planilha em vez de reaproveitar + // a que acabou de nascer. + servidor.rangesQueFalham = {'Versao!A1:B1'}; + + await expectLater( + criarRepositorio().obterPlanilhaId(), + throwsA(anything), + ); + expect(await Preferencias.lerPlanilhaId(), 'PLANILHA_NOVA'); + }); + + test('reutiliza o ID em memória sem buscar de novo no Drive', () async { + servidor.arquivosDrive = [ + {'id': 'ID_EXISTENTE', 'name': '__saas_fisio_db__'}, + ]; + final repositorio = criarRepositorio(); + + await repositorio.obterPlanilhaId(); + final chamadasApos1a = servidor.chamadasDrive.length; + await repositorio.obterPlanilhaId(); + + expect(servidor.chamadasDrive.length, chamadasApos1a); + }); + + test('limparCache apaga o ID persistido', () async { + SharedPreferences.setMockInitialValues({'planilha_id': 'ID_SALVO'}); + final repositorio = criarRepositorio(); + await repositorio.obterPlanilhaId(); + + repositorio.limparCache(); + await Future.delayed(Duration.zero); + + expect(await Preferencias.lerPlanilhaId(), isNull); + }); + }); + + group('RepositorioDadosGoogle — planilhas duplicadas', () { + test('usa a mais recente e reporta as demais', () async { + servidor.arquivosDrive = [ + {'id': 'RECENTE', 'name': '__saas_fisio_db__'}, + {'id': 'ANTIGA', 'name': '__saas_fisio_db__'}, + ]; + final repositorio = criarRepositorio(); + + final id = await repositorio.obterPlanilhaId(); + + expect(id, 'RECENTE'); + expect(repositorio.planilhasDuplicadas.map((p) => p.id), ['ANTIGA']); + }); + + test('não reporta duplicata quando só existe uma planilha', () async { + servidor.arquivosDrive = [ + {'id': 'UNICA', 'name': '__saas_fisio_db__'}, + ]; + final repositorio = criarRepositorio(); + + await repositorio.obterPlanilhaId(); + + expect(repositorio.planilhasDuplicadas, isEmpty); + }); + + test('carregarTudo propaga as duplicatas para a UI', () async { + servidor.arquivosDrive = [ + {'id': 'RECENTE', 'name': '__saas_fisio_db__'}, + {'id': 'ANTIGA', 'name': '__saas_fisio_db__'}, + ]; + + final dados = await criarRepositorio().carregarTudo(); + + expect(dados.planilhaId, 'RECENTE'); + expect(dados.planilhasDuplicadas.map((p) => p.id), ['ANTIGA']); + }); + }); + + group('RepositorioDadosGoogle — carregarTudo', () { + setUp(() { + SharedPreferences.setMockInitialValues({'planilha_id': 'ID'}); + }); + + test('converte linhas curtas sem estourar índice', () async { + // A planilha devolve só as colunas preenchidas: um paciente cadastrado + // sem anamnese chega com 6 células, não com 19. + servidor.abas = { + 'Pacientes': [ + ['P001', 'Ana Souza', '11999999999', '20/05/1990', '123', 'Rua A'], + ], + }; + + final dados = await criarRepositorio().carregarTudo(); + + final paciente = dados.pacientes.single; + expect(paciente.nome, 'Ana Souza'); + expect(paciente.dataNascimento, DateTime(1990, 5, 20)); + expect(paciente.queixaPrincipal, isNull); + // Coluna vazia não pode virar paciente arquivado. + expect(paciente.situacao, 'Ativo'); + }); + + test('lê o valor da sessão com vírgula decimal', () async { + servidor.abas = { + 'Agenda': [ + ['A001', 'P001', '20/07/2026', '08:00', '09:00', '150,50'], + ], + }; + + final dados = await criarRepositorio().carregarTudo(); + + expect(dados.agendamentos.single.valorSessao, 150.5); + expect(dados.agendamentos.single.situacao, 'Agendado'); + }); + + test('carrega evoluções pela mesma regra do modelo', () async { + servidor.abas = { + 'Evolucoes': [ + ['E001', 'P001', 'A001', '20/07/2026', 'Texto clínico'], + ], + }; + + final dados = await criarRepositorio().carregarTudo(); + + expect(dados.evolucoes.single.idEvolucao, 'E001'); + expect(dados.evolucoes.single.evolucaoTexto, 'Texto clínico'); + }); + + test('usa o valor padrão da sessão vindo de Configuracoes', () async { + servidor.abas = { + 'Configuracoes': [ + ['valor_sessao_padrao', '220,00'], + ], + }; + + final dados = await criarRepositorio().carregarTudo(); + + expect(dados.valorSessaoPadrao, '220,00'); + }); + + test('cai em 150,00 quando a configuração não existe', () async { + final dados = await criarRepositorio().carregarTudo(); + + expect(dados.valorSessaoPadrao, '150,00'); + }); + + test('mostra os logs de auditoria do mais recente para o mais antigo', () async { + servidor.abas = { + 'Auditoria': [ + ['L1', '01/07/2026 10:00', 'CADASTRO_PACIENTE', 'Primeiro'], + ['L2', '02/07/2026 10:00', 'EDITAR_PACIENTE', 'Segundo'], + ], + }; + + final dados = await criarRepositorio().carregarTudo(); + + expect(dados.logsAuditoria.first, contains('Segundo')); + expect(dados.logsAuditoria.last, contains('Primeiro')); + }); + }); + + group('RepositorioDadosGoogle — escrita', () { + setUp(() { + SharedPreferences.setMockInitialValues({'planilha_id': 'ID'}); + }); + + test('salvarPaciente grava as 19 colunas na ordem do cabeçalho', () async { + await criarRepositorio().salvarPaciente(_paciente()); + + final append = servidor.chamadasPara('/values/Pacientes!A:Z').single; + expect(append.valoresEnviados, hasLength(19)); + expect(append.valoresEnviados[0], 'P001'); + expect(append.valoresEnviados[1], 'Ana Souza'); + expect(append.valoresEnviados[4], '12345678901'); + }); + + test('salvarPaciente registra auditoria da operação', () async { + await criarRepositorio().salvarPaciente(_paciente()); + + final auditoria = servidor.chamadasPara('/values/Auditoria!A:Z').single; + expect(auditoria.valoresEnviados[2], 'CADASTRO_PACIENTE'); + expect(auditoria.valoresEnviados[3], contains('P001')); + }); + + test('registrarAuditoria gera o próximo ID a partir do maior existente', () async { + // `length + 1` daria L3 e sobrescreveria a linha existente. + servidor.abas = { + 'Auditoria': [ + ['L7', '01/07/2026 10:00', 'X', 'y'], + ['L2', '01/07/2026 11:00', 'X', 'y'], + ], + }; + + await criarRepositorio().registrarAuditoria('TESTE', 'detalhe'); + + final auditoria = servidor.chamadasPara('/values/Auditoria!A:Z').single; + expect(auditoria.valoresEnviados.first, 'L008'); + }); + + test('atualizarSituacaoAgendamento troca só a coluna Situacao', () async { + servidor.abas = { + 'Agenda': [ + ['A001', 'P001', '20/07/2026', '08:00', '09:00', '150,00', 'obs', + 'Agendado', '2026-07-01T10:00:00.000'], + ['A002', 'P002', '21/07/2026', '10:00', '11:00', '150,00', '', + 'Agendado', '2026-07-01T10:00:00.000'], + ], + }; + + await criarRepositorio().atualizarSituacaoAgendamento( + 'A002', + Agendamento.situacaoRealizado, + ); + + // Segunda linha de dados = linha 3 da planilha (cabeçalho ocupa a 1). + final update = servidor.chamadasPara('/values/Agenda!A3:I3').single; + expect(update.metodo, 'PUT'); + expect(update.valoresEnviados[7], 'Realizado'); + expect(update.valoresEnviados[0], 'A002'); + expect(update.valoresEnviados[2], '21/07/2026'); + }); + + test('atualizarSituacaoAgendamento ignora ID inexistente', () async { + servidor.abas = {'Agenda': []}; + + await criarRepositorio().atualizarSituacaoAgendamento( + 'INEXISTENTE', + Agendamento.situacaoRealizado, + ); + + expect(servidor.escritas, isEmpty); + }); + + test('arquivarPaciente marca a situação preservando o resto da linha', () async { + servidor.abas = { + 'Pacientes': [ + ['P001', 'Ana Souza', '11999999999', '20/05/1990', '123', 'Rua A'], + ], + }; + + await criarRepositorio().arquivarPaciente('P001'); + + final update = servidor.chamadasPara('/values/Pacientes!A2:S2').single; + expect(update.valoresEnviados[10], 'Arquivado'); + expect(update.valoresEnviados[1], 'Ana Souza'); + expect(update.valoresEnviados, hasLength(19)); + }); + + test('restaurarPaciente devolve a situação para Ativo', () async { + servidor.abas = { + 'Pacientes': [ + ['P001', 'Ana Souza', '11999999999', '20/05/1990', '123', 'Rua A', + '', '', '', '', 'Arquivado'], + ], + }; + + await criarRepositorio().restaurarPaciente('P001'); + + final update = servidor.chamadasPara('/values/Pacientes!A2:S2').single; + expect(update.valoresEnviados[10], 'Ativo'); + }); + + test('atualizarPaciente falha alto quando o paciente sumiu da planilha', () async { + servidor.abas = {'Pacientes': []}; + + expect( + () => criarRepositorio().atualizarPaciente(_paciente()), + throwsA(isA()), + ); + }); + + test('atualizarEvolucao não escreve nada se o ID não existe', () async { + servidor.abas = {'Evolucoes': []}; + + await criarRepositorio().atualizarEvolucao( + Evolucao( + idEvolucao: 'E999', + idPaciente: 'P001', + idAgendamento: 'A001', + dataAtendimento: DateTime(2026, 7, 20), + evolucaoTexto: 'x', + dataRegistro: DateTime(2026, 7, 20), + horarioInicioReal: DateTime(2026, 7, 20, 8), + horarioFimReal: DateTime(2026, 7, 20, 9), + ), + ); + + expect(servidor.escritas, isEmpty); + }); + + test('salvarValorSessaoPadrao insere a chave quando ela não existe', () async { + servidor.abas = {'Configuracoes': []}; + + await criarRepositorio().salvarValorSessaoPadrao('200,00'); + + final append = servidor.chamadasPara('/values/Configuracoes!A:Z').single; + expect(append.valoresEnviados, ['valor_sessao_padrao', '200,00']); + }); + + test('salvarValorSessaoPadrao atualiza a linha existente', () async { + servidor.abas = { + 'Configuracoes': [ + ['valor_sessao_padrao', '150,00'], + ], + }; + + await criarRepositorio().salvarValorSessaoPadrao('200,00'); + + final update = servidor.chamadasPara('/values/Configuracoes!A2:B2').single; + expect(update.valoresEnviados, ['valor_sessao_padrao', '200,00']); + }); + + test('salvarAgendamento grava as 9 colunas da Agenda', () async { + await criarRepositorio().salvarAgendamento(_agendamento()); + + final append = servidor.chamadasPara('/values/Agenda!A:Z').single; + expect(append.valoresEnviados, hasLength(9)); + expect(append.valoresEnviados[0], 'A001'); + expect(append.valoresEnviados[2], '20/07/2026'); + }); + }); + + group('RepositorioDadosGoogle — utilidades', () { + test('urlPlanilha aponta para o documento no Drive', () { + expect( + criarRepositorio().urlPlanilha('ABC'), + 'https://docs.google.com/spreadsheets/d/ABC/edit', + ); + }); + }); +} diff --git a/test/widgets/telas/tela_configuracoes_test.dart b/test/widgets/telas/tela_configuracoes_test.dart index 0e3f704..1b41256 100644 --- a/test/widgets/telas/tela_configuracoes_test.dart +++ b/test/widgets/telas/tela_configuracoes_test.dart @@ -7,6 +7,7 @@ import 'package:alta/telas/tela_configuracoes.dart'; import 'package:alta/telas/tela_login.dart'; import 'package:alta/provedores/provedor_autenticacao.dart'; import 'package:alta/provedores/provedores_dados.dart'; +import 'package:alta/servicos/servico_google_drive.dart'; import 'package:alta/servicos/servico_repositorio_dados.dart'; import '../../unitarios/auxiliares/fakes.dart'; @@ -36,6 +37,13 @@ class _PlanilhaIdFixo extends PlanilhaIdNotifier { String? build() => _id; } +class _PlanilhasDuplicadasFixas extends PlanilhasDuplicadasNotifier { + final List _dados; + _PlanilhasDuplicadasFixas(this._dados); + @override + List build() => _dados; +} + // Large surface so all content renders without scrolling const _bigSurface = Size(600, 3000); @@ -43,6 +51,7 @@ Widget _app({ RepositorioDadosGoogle? repositorio, List logs = const [], String? planilhaId, + List duplicadas = const [], }) { return ProviderScope( overrides: [ @@ -54,6 +63,9 @@ Widget _app({ provedorLogsAuditoria.overrideWith(() => LogsComDados(logs)), if (planilhaId != null) provedorPlanilhaId.overrideWith(() => _PlanilhaIdFixo(planilhaId)), + provedorPlanilhasDuplicadas.overrideWith( + () => _PlanilhasDuplicadasFixas(duplicadas), + ), ], child: const MaterialApp( home: Scaffold(body: TelaConfiguracoes()), @@ -262,5 +274,58 @@ void main() { // url_launcher returned false — no crash, widget still present expect(find.byType(TelaConfiguracoes), findsOneWidget); }); + + testWidgets('sem duplicatas não exibe aviso na base de dados', ( + tester, + ) async { + await tester.binding.setSurfaceSize(_bigSurface); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget(_app(planilhaId: 'ABC123')); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.warning_amber_rounded), findsNothing); + }); + + testWidgets('duplicata no Drive vira aviso visível', (tester) async { + // O app grava sempre na planilha mais recente. Sem esse aviso, a base + // antiga só "para de receber sessões" e o usuário não descobre por quê. + await tester.binding.setSurfaceSize(_bigSurface); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + _app( + planilhaId: 'ABC123', + duplicadas: const [ + PlanilhaBanco(id: 'ANTIGA', nome: '__saas_fisio_db__'), + ], + ), + ); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.warning_amber_rounded), findsOneWidget); + expect( + find.textContaining('Há outra planilha com este nome'), + findsOneWidget, + ); + }); + + testWidgets('duas duplicatas usam a redação no plural', (tester) async { + await tester.binding.setSurfaceSize(_bigSurface); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + _app( + planilhaId: 'ABC123', + duplicadas: const [ + PlanilhaBanco(id: 'A', nome: '__saas_fisio_db__'), + PlanilhaBanco(id: 'B', nome: '__saas_fisio_db__'), + ], + ), + ); + await tester.pumpAndSettle(); + + expect(find.textContaining('Há mais 2 planilhas'), findsOneWidget); + }); }); } diff --git a/test/widgets/telas/tela_login_test.dart b/test/widgets/telas/tela_login_test.dart index 53fae87..4c60a3f 100644 --- a/test/widgets/telas/tela_login_test.dart +++ b/test/widgets/telas/tela_login_test.dart @@ -1,6 +1,8 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -8,6 +10,7 @@ import 'package:alta/telas/tela_dashboard.dart'; import 'package:alta/telas/tela_login.dart'; import 'package:alta/provedores/provedor_autenticacao.dart'; import 'package:alta/servicos/servico_autenticacao_google.dart'; +import 'package:alta/utilitarios/links_legais.dart'; import '../../unitarios/auxiliares/fakes.dart'; /// Fake cujo [entrar] só completa quando o teste mandar, permitindo @@ -302,4 +305,125 @@ void main() { }, ); }); + + group('TelaLogin — links legais', () { + /// Registra o mock do canal do url_launcher e devolve as URLs pedidas. + List espionarUrlLauncher(WidgetTester tester, {bool abre = true}) { + final abertas = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/url_launcher'), + (call) async { + if (call.method == 'launch') { + abertas.add((call.arguments as Map)['url'] as String); + return abre; + } + if (call.method == 'canLaunch') return true; + return null; + }, + ); + addTearDown(() { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/url_launcher'), + null, + ); + }); + return abertas; + } + + /// Toca no meio do trecho [rotulo] dentro do `Text.rich` dos termos. + /// + /// `tester.tap(find.text(...))` não serve: o texto todo é um único + /// `RichText`, e o toque precisa cair sobre o span certo para o + /// `TapGestureRecognizer` dele disparar. + Future tocarNoLink(WidgetTester tester, String rotulo) async { + final richText = tester.widget( + find.byWidgetPredicate( + (w) => w is RichText && w.text.toPlainText().contains(rotulo), + ), + ); + final texto = richText.text.toPlainText(); + final render = tester.renderObject( + find.byWidgetPredicate( + (w) => w is RichText && w.text.toPlainText().contains(rotulo), + ), + ); + final offset = render.getOffsetForCaret( + TextPosition(offset: texto.indexOf(rotulo) + rotulo.length ~/ 2), + Rect.zero, + ); + await tester.tapAt( + tester.getTopLeft(find.byWidget(richText)) + + offset + + const Offset(0, 6), + ); + await tester.pumpAndSettle(); + } + + testWidgets('tocar em "Termos de Uso" abre a página publicada', ( + tester, + ) async { + // Sem `recognizer` no TextSpan o texto era só colorido: dava para aceitar + // os termos sem ter como lê-los. + final abertas = espionarUrlLauncher(tester); + final servico = ServicoAutenticacaoGoogleControlavel(); + + await tester.pumpWidget(criarAppTeste(servico)); + await tester.pumpAndSettle(); + + await tocarNoLink(tester, 'Termos de Uso'); + + expect(abertas, [LinksLegais.termosDeUso]); + }); + + testWidgets('tocar em "Política de Privacidade" abre a página publicada', ( + tester, + ) async { + final abertas = espionarUrlLauncher(tester); + final servico = ServicoAutenticacaoGoogleControlavel(); + + await tester.pumpWidget(criarAppTeste(servico)); + await tester.pumpAndSettle(); + + await tocarNoLink(tester, 'Política de Privacidade'); + + expect(abertas, [LinksLegais.politicaPrivacidade]); + }); + + testWidgets('abrir o link não marca o checkbox de termos', (tester) async { + // O toque no link fica dentro da mesma linha do checkbox; aceitar os + // termos sem intenção seria pior do que não abrir a página. + espionarUrlLauncher(tester); + final servico = ServicoAutenticacaoGoogleControlavel(); + + await tester.pumpWidget(criarAppTeste(servico)); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.check_rounded), findsNothing); + + await tocarNoLink(tester, 'Termos de Uso'); + + // O check só aparece com os termos aceitos. + expect(find.byIcon(Icons.check_rounded), findsNothing); + await tester.tap(find.byKey(const Key('btn_entrar_google'))); + await tester.pumpAndSettle(); + expect(servico.entrarFoiChamado, isFalse); + }); + + testWidgets('falha ao abrir o link mostra a URL no snackbar', ( + tester, + ) async { + espionarUrlLauncher(tester, abre: false); + final servico = ServicoAutenticacaoGoogleControlavel(); + + await tester.pumpWidget(criarAppTeste(servico)); + await tester.pumpAndSettle(); + + await tocarNoLink(tester, 'Termos de Uso'); + + expect( + find.textContaining(LinksLegais.termosDeUso), + findsOneWidget, + ); + }); + }); }