Pular para conteúdo

Flutter — cliente / web / desktop / mobile

Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-30

Aplica-se a: perfil app, stack flutter.

O arquétipo cliente da casa (web, desktop e mobile). Organização feature-first: features em lib/features/, compartilhado em lib/_shared/, núcleo em lib/core/.

Piso

dart format --output=none --set-exit-if-changed . && flutter analyze && flutter test --exclude-tags golden no gate (CI, gates e testes). O --output=none não é cosmético: sem ele o dart format reescreve os arquivos, e o gate conserta o que devia só acusar. No agente, flutter test -r failures-only (higiene de saída).

Lints: o analysis_options.yaml do kit, na raiz — base flutter_lints + as regras de recurso assíncrono (unawaited_futures, cancel_subscriptions, close_sinks). Ligue-as cedo: pegam future não aguardado e subscription ou sink não fechado. prefer_initializing_formals fica desligada: ela sugere this._campo, que é ilegal em parâmetro nomeado privado.

Arquitetura

Layout feature-first:

lib/
├── _shared/{models, services, widgets}   # transversal ao app
├── core/{config, database, logging, router, theme}   # infraestrutura
├── features/<nome>/{pages, widgets, model, manager|services}
├── locator.dart          # wiring do DI
└── main.dart / app.dart

BI multi-painel prefixa as features com NNN_ (ordem dos painéis). A fronteira entre camadas é cobrada pela guarda de imports no gate.

DI — get_it + watch_it: todo o wiring em configureDependencies() no locator.dart:

  • registerSingleton — serviço leve e síncrono (config, interação);
  • registerSingletonAsync + dependsOn: — a cadeia de boot (sessão → banco → caches); o app espera di.allReady() antes de renderizar rotas;
  • registerLazySingleton + dispose: — managers de feature (sem o dispose: eles vazam).

O locator expõe o seam de teste configureDependencies({AppDatabase? databaseOverride}) — o teste injeta um banco in-memory no lugar do PowerSync (registrado como async, porque dependsOn exige).

Estado no manager, nunca na tela: o manager é dono do estado, exposto como ValueNotifier/ValueListenable; a tela é WatchingWidget com watchValue, sem lógica (setState só para UI local). O valor observado carrega o próprio ciclo — MeasureValue<T> sealed (loading/data/error + refreshing(previous)) nos BIs, ou sealed classes por feature em app pequeno. O invariante é erro como valor observável, não exceção atravessando a UI.

Ação do usuário — command_it: Command.createAsyncNoResult no notifier; isRunning é a única fonte de "carregando"; Command.globalExceptionHandler no bootstrap loga e mostra toast. App pequeno (o central-ui) usa método async + try/catch no State — aceito abaixo de ~3 features.

Side-effect de um serviço de estado (analytics, telemetria) sai por delegação, não por listener: se o que se rastreia é a intenção do usuário e o que o serviço expõe é o estado efetivo (derivado), o listener emite eventos espúrios. O serviço chama observer.onChange(prev, next) no ponto exato da mudança de intenção, e a máquina de emissão fica fora dele.

Dado (BI/PowerSync): raw tables com DDL versionada em core/database/, migrations congeladas (PowerSync) e a mesma DDL alimentando o banco in-memory dos testes; a tabela vai materializada em RAM e agregada em Dart — JOIN no SQLite-WASM é lento demais para painel. Sem cópia que se desatualize. O transporte (connector, write-back, JWKS) está em PowerSync.

Como nasce uma feature: features/<nome>/ com pages/<nome>_screen.dart (WatchingWidget), manager registrado lazy com dispose:, model/ com agregador puro + resultado tipado, widgets/ privados; rota no core/router/ (a única exceção de import core → features); testes do agregador e da screen.

Erros, logging e observabilidade

Modelo de erro: exceção tipada na borda de IO, com semântica (AuthRemoteException.isInvalidCredentials); no meio, o erro vira valor no estado observável — quem converte é o manager, logando antes; na UI, o trio loading/vazio/erro com widgets compartilhados. Erro esperado de ação do usuário vira mensagem no state (401 → "credenciais inválidas"; no web, erro de rede chega como ClientException, não SocketException); o inesperado cai no globalExceptionHandler.

Captura GlitchTip/Sentry: SentryFlutter.init(..., appRunner: _bootstrap) com todo o bootstrap dentro do appRunner — o SDK cobre FlutterError.onError e async não capturado; envio só em kReleaseMode e com DSN não vazio; sendDefaultPii = true (Segurança).

Fachada AppLog: info/warn viram breadcrumbs; error() faz Sentry.captureException com tags indexáveis (log_category, phase=); measureAsync com expectedError: para 401 não poluir o GlitchTip. Senha e token nunca entram no log, nem em breadcrumb.

Testes e fixtures (Flutter)

  • Banco de teste = in-memory com a DDL de produção (InMemoryAppDatabase), injetado por configureDependencies(databaseOverride:).
  • Builders de fixture com defaults nomeados (movRow(placa: 'ABC1234')) em test/_helpers/, dado 100% sintético.
  • ponto de injeção nomeados, não mock-framework: construtor forTesting(...), relógio injetável (_now), credenciais injetáveis, @visibleForTesting.
  • Timer/debounce se testa com fakeAsync — o ponto de injeção é a zona: o Timer fixo da produção já é interceptado, então não parametrize a duração só para testar. O callback do fakeAsync é síncrono: com corpo async, sem await no retorno o teste passa vazio, e com await o Future nunca completa. Chame elapse()/flushMicrotasks() e afirme depois.
  • Package importado em test/ vai em dev_dependencies, mesmo chegando transitivo (fake_async, matcher, collection): o depend_on_referenced_packages acusa em info, e o flutter analyze roda com --fatal-infos ligado.
  • Widget test: di.pushNewScope() no setUp + di.popScope() no tearDown, registrando só o que a tela usa; SharedPreferences.setMockInitialValues.
  • Viewport pequeno esconde alvo: o default é 800×600, e tap() fora dele erra com "offset outside bounds". tester.view.physicalSize = const Size(2400, 1200); tester.view.devicePixelRatio = 1; com os addTearDown de reset, ou scrollUntilVisible antes do tap.
  • DropdownButton: tap(find.byType(DropdownButton<T>)), não no hint; selecione o item com find.text(...).last.
  • Teste de contrato do app.json: feature enabled ⇒ os refs client-grade existem e têm o formato certo.
  • Arquivo estático servido ao mundo (o web/roles-catalog.json da 0029) se testa lendo o arquivo real com dart:io, nunca uma cópia em test/, e afirmando o conjunto de chaves, não os rótulos.
  • Journey/integration tests (integration_test/) ficam fora do gate headless e rodam local.
  • Verificação visual headless: a receita de screenshot (kScreenshotMode + seed in-memory, Screenshots do manual) renderiza o PNG e o agente lê a imagem.

Identidade visual (paleta · logo · tipografia)

O mecanismo vale em todo app: tema por ColorScheme (nunca fromSeed com azul de exemplo), ícones por flutter_launcher_icons, feature-first. A marca depende do grupo do app.json: xadm usa a da casa; cliente usa a do cliente, derivada do logo dele.

  • Paleta da casa: os tokens do xadm.css — royal #064490 (primária), médio #2a5b97 (accent), claro #7092bb, escuro #042c5e, prata #939495.
  • Logo: https://docs.xadm.biz/assets/xadm-logo.png na UI (header, rail, splash). O ícone é uma imagem quadrada própria, sem recortar o lockup horizontal.
  • Tipografia: Inter.

Ícones do web e do app instalado

O flutter create entrega web/favicon.png e os quatro web/icons/Icon-*.png com a marca do Flutter. O manifest.json e o index.html apontam para eles, e é o que aparece quando o usuário instala o app. Trocar o nome e o favicon não basta: os cinco arquivos saem do ícone da marca.

# pubspec.yaml — regenerar com `dart run flutter_launcher_icons`
flutter_launcher_icons:
  image_path: assets/icone.png        # quadrado, fundo opaco
  web:
    generate: true
    background_color: "#ffffff"       # vai para o manifest.json, não para o PNG
    theme_color: "#064490"
  • A imagem de origem do web é opaca. O pacote redimensiona a mesma imagem para o favicon, os dois Icon-* e os dois Icon-maskable-*, sem preencher o fundo. O background_color só muda o manifest.json.
  • Ícone que a plataforma recorta ou preenche não tem transparência: o alvo do apple-touch-icon do index.html e os ícones maskable do manifest.json. O que o sistema pinta no lugar do pixel transparente não é escolha do app.
  • Logo de fundo transparente no Icon-192.png pede alvo próprio: o index.html do flutter create aponta o apple-touch-icon para esse arquivo, e o pacote não edita o index.html. Exporte um PNG opaco para o apple-touch-icon e troque o href; os maskable ocupam o quadro inteiro, com a marca no centro.
  • A guarda é o checa-icones-web.py, no gate do pipeline.yml. Reprova PNG de web/ idêntico ao do flutter create, inclusive renomeado. Avisa, sem reprovar, o alvo do apple-touch-icon e o maskable com pixel não opaco.
  • A lista de ícones do flutter create é datada pela versão do SDK: ao subir o toolchain.flutter, rode a guarda num flutter create novo e confirme que ela reprova (CI, gates e testes).

Guarda de fronteiras (imports)

A arquitetura que o livro do app narra tem guarda no gate: um teste (test/architecture/import_boundaries_test.dart) que lê os imports e falha em fronteira proibida — as que o livro do app afirma. Costuma valer guardar:

  • infra não importa feature;
  • uma feature não importa outra direto (salvo allowlist);
  • página e widget não acessam o banco direto;
  • model e query não importam UI;
  • rota pública não expõe código de debug.

Exemplo de partida (test/architecture/import_boundaries_test.dart)

import 'dart:io';
import 'package:test/test.dart';

/// (de, proíbe) — `de` casa o caminho do arquivo; `proíbe` casa a linha de import.
final regras = <(String, bool Function(String path), bool Function(String import))>[
  ('core/ e _shared/ não importam features/',
      (p) => p.contains('/core/') || p.contains('/_shared/'),
      (i) => i.contains('/features/')),
  ('model/ (agregador puro) não importa UI nem DI',
      (p) => p.contains('/model/'),
      (i) => i.contains('package:flutter/') || i.contains('watch_it') || i.endsWith("locator.dart';")),
  ('página/widget não acessa banco direto (passa por manager/service)',
      (p) => p.contains('/pages/') || p.contains('/widgets/'),
      (i) => i.contains('/database/') || i.contains('powersync')),
];

void main() {
  final arquivos = Directory('lib').listSync(recursive: true)
      .whereType<File>().where((f) => f.path.endsWith('.dart'));
  for (final f in arquivos) {
    final imports = f.readAsLinesSync().where((l) => l.trimLeft().startsWith('import '));
    for (final (nome, de, proibe) in regras) {
      if (!de(f.path)) continue;
      for (final imp in imports) {
        test('$nome :: ${f.path}', () => expect(proibe(imp), isFalse, reason: 'import proibido: ${imp.trim()}'));
      }
    }
  }
}

Roda no flutter test. Cada fronteira nova é uma tupla em regras.

Específico de Flutter

Build e versão do SDK

  • A versão do SDK vem do docs/app.json toolchain.flutter: o pipeline.yml a exporta para o subosito/flutter-action, e o .fvmrc e o ARG FLUTTER_VERSION do Dockerfile batem com ela (a guarda do gate compara). Nada de FLUTTER_VERSION no Coolify. Native assets (PowerSync/sqlite3) precisam de ninja-build no gate.
  • Golden fica fora do gate: golden é travado ao ambiente — fonte e anti-aliasing divergem ~0,2–0,7% entre a máquina que assou e o runner, e o teste falha por ruído. O arquivo golden leva @Tags(['golden']) (no topo, antes dos imports), o gate roda flutter test --exclude-tags golden, e o golden roda local e no e2e. Tolerância no comparator mascara regressão; re-bake no CI diverge no dev.
  • Bump de SDK: limpe o cache de shader — flutter clean + flutter pub get antes dos testes, senão o ink_sparkle.frag antigo quebra widget test com tap.
  • .gitignore: o gitignore-flutter do kit, copiado uma vez e adaptado (pubspec.lock versionado).
  • Run de debug web não fixa porta: flutter run -d chrome já serve em porta aleatória e imprime a URL; leia-a (agentes).

Deploy web — cache no nginx

  • main.dart.js é no-cache, não cache longo: o Flutter Web não versiona a URL dos filhos — o buildConfig traz "mainJsPath":"main.dart.js", sem query nem hash, e a casa builda com --pwa-strategy=none. Com cache longo, o banner de nova versão recarrega e o browser serve o mesmo bundle.
  • no-cache, must-revalidate para os entry points (/, index.html, flutter_bootstrap.js, main.dart.js, version.json, manifest.json, health) e todo asset de nome fixo (MaterialIcons-Regular.otf, FontManifest.json/AssetManifest*, sqlite3.wasm, powersync_db.worker.js). no-cache não é no-store: o servidor responde 304 quando nada mudou. Cache longo só para o que tem URL versionada.
map $uri $cache_control {
    default                      "public, max-age=2592000";
    /                            "no-cache, must-revalidate";
    /index.html                  "no-cache, must-revalidate";
    /flutter_bootstrap.js        "no-cache, must-revalidate";
    /main.dart.js                "no-cache, must-revalidate";
    /version.json                "no-cache, must-revalidate";
    /manifest.json               "no-cache, must-revalidate";
    /MaterialIcons-Regular.otf   "no-cache, must-revalidate";
    /FontManifest.json           "no-cache, must-revalidate";
    /AssetManifest.bin.json      "no-cache, must-revalidate";
    /AssetManifest.json          "no-cache, must-revalidate";
    /sqlite3.wasm                "no-cache, must-revalidate";   # PowerSync: nome fixo, bumpa com o SDK
    /powersync_db.worker.js      "no-cache, must-revalidate";
}
server {
    # add_header de location SUBSTITUI os herdados — deixe o Cache-Control só aqui.
    add_header Cache-Control $cache_control always;
}

Depois do deploy, curl -sI <url>/main.dart.js confere o cache-control.

Deploy web — túnel same-origin e destino de proxy

  • O túnel é opcional, para público com navegador que bloqueia telemetria (Tracking Prevention do Edge, listas de ad-blocker): o bundle posta numa rota do próprio app (/analytics-tunnel/, /sentry-tunnel) e o nginx repassa. Painel interno pode falar direto com o serviço, pelo host do app.json via --dart-define (o central-ui faz assim).
  • O destino vem do app.json, derivado no build — nunca cravado no nginx (constituição). O template versionado usa variável:
# nginx/default.conf.template — o build troca ${APTABASE_HOST} etc.; $uri, $request_method e $*_destino ficam
resolver 127.0.0.11 valid=30s ipv6=off;   # DNS embutido do Docker (repassa ao --dns do recurso); no nível do server
resolver_timeout 5s;

location /analytics-tunnel/ {
    if ($request_method !~ ^(POST|OPTIONS)$) { return 405; }
    set $aptabase_destino https://${APTABASE_HOST};
    rewrite ^/analytics-tunnel/(.*)$ /$1 break;
    proxy_pass $aptabase_destino;
    proxy_ssl_server_name on;
    proxy_set_header Host ${APTABASE_HOST};
    proxy_buffering off;
    proxy_request_buffering off;
}
location = /sentry-tunnel {
    if ($request_method !~ ^(POST|OPTIONS)$) { return 405; }
    set $glitchtip_destino https://${GLITCHTIP_HOST}/api/${GLITCHTIP_PROJECT}/envelope/;
    proxy_pass $glitchtip_destino;
    proxy_ssl_server_name on;
    proxy_set_header Host ${GLITCHTIP_HOST};
    proxy_set_header X-Sentry-Auth "Sentry sentry_version=7, sentry_key=${GLITCHTIP_KEY}";
    proxy_buffering off;
    proxy_request_buffering off;
}

E o Dockerfile deriva os valores do app.json e renderiza no estágio final:

# estágio de build — o jq já está aqui por causa da ponte --dart-define
RUN APTABASE_URL="$(jq -r '.features.analytics.aptabase_host // empty' docs/app.json)" \
 && DSN="$(jq -r '.features.glitchtip.dsn // empty' docs/app.json)" \
 && APTABASE_HOST="${APTABASE_URL#*://}" && APTABASE_HOST="${APTABASE_HOST%%/*}" \
 && RESTO="${DSN#*@}" && GLITCHTIP_HOST="${RESTO%%/*}" && GLITCHTIP_PROJECT="${DSN##*/}" \
 && KEY="${DSN#*://}" && KEY="${KEY%%@*}" && GLITCHTIP_KEY="${KEY%%:*}" \
 && { [ -n "$APTABASE_HOST" ] && [ -n "$GLITCHTIP_HOST" ] && [ -n "$GLITCHTIP_PROJECT" ] \
      && [ -n "$GLITCHTIP_KEY" ] \
      || { echo "tunel sem destino: confira features.analytics e features.glitchtip no docs/app.json" >&2; exit 1; }; } \
 && printf 'APTABASE_HOST=%s\nGLITCHTIP_HOST=%s\nGLITCHTIP_PROJECT=%s\nGLITCHTIP_KEY=%s\n' \
      "$APTABASE_HOST" "$GLITCHTIP_HOST" "$GLITCHTIP_PROJECT" "$GLITCHTIP_KEY" > /app/nginx.env

# estágio final
FROM nginx:1.27-alpine
COPY nginx/default.conf.template /tmp/default.conf.template
COPY --from=build /app/nginx.env /tmp/nginx.env
RUN set -a && . /tmp/nginx.env && set +a \
 && envsubst '${APTABASE_HOST} ${GLITCHTIP_HOST} ${GLITCHTIP_PROJECT} ${GLITCHTIP_KEY}' \
      < /tmp/default.conf.template > /etc/nginx/conf.d/default.conf \
 && rm /tmp/default.conf.template /tmp/nginx.env \
 && nginx -t
  • O destino é resolvido por pedido, nunca no boot: com host literal no proxy_pass, inclusive o que o envsubst rendeu, o nginx resolve o nome ao carregar a config e, sem DNS, sai com host not found in upstream. Com a VM religando sem WAN, o container entra em crash-loop e fica parado, enquanto o resto da frota volta sozinho. Por isso o destino vai numa variável e o resolver resolve a cada pedido: sem rede, só o túnel responde 502. Com variável, a barra final do proxy_pass não tira mais o prefixo, e quem faz isso é o rewrite ... break. O set vem antes do rewrite, porque o break encerra os set seguintes e a variável chega vazia (500 invalid URL prefix). A guarda proxy_pass resolvido no boot do pipeline.yml reprova host literal no proxy_pass; IP, localhost e upstream nomeado passam.
  • O túnel é chamada de app para app: bug e aptabase moram no mesmo host, e o recurso web leva o --dns do Coolify para não sair pelo hairpin NAT (chamada de app para app). O resolver do nginx não muda.
  • O GlitchTip exige a key no pedido, na query (sentry_key=) ou no X-Sentry-Auth — o DSN do envelope não basta (403). O túnel deriva a key do DSN (o trecho entre :// e @); ela é pública.
  • envsubst sempre com a lista explícita de variáveis: sem ela o $uri e o $request_method do nginx viram vazio.
  • Não use o /etc/nginx/templates/ da imagem oficial: ele renderiza no boot, a partir da env do container — o destino voltaria para o recurso do Coolify, que no web fica sem env.
  • Destino vazio falha o build, e nginx -t fecha o render. O smoke só vê a rota do túnel declarada com method: POST (ela devolve 405 a GET).
  • A guarda do pipeline.yml (Destino de proxy cravado) reprova host do app.json escrito literal num .conf/.conf.template. Ponto cego: ARG do Dockerfile com o host como default — o destino do upload de sourcemap chega derivado do DSN pelo build_web, e o ARG fica sem default.
  • No browser, o tunnel entra por um patch no web/index.html, porque o sentry_flutter 9.x não expõe a opção no web. No boot, ele sempre carrega de novo o SDK de browser.sentry-cdn.com, sem conferir se já existe um, e esse bundle reatribui o init no mesmo window.Sentry. Por isso não adianta envolver o init nem o window.Sentry: o var Sentry do bundle cria uma propriedade não-configurável no window. O que funciona é interceptar a propriedade init com Object.defineProperty, de modo que a reatribuição caia no setter. O script vai logo depois do bundle.min.js local, que é o fallback quando o CDN é bloqueado:
<script>
  (function () {
    var s = window.Sentry;
    if (!s || typeof s.init !== 'function') return;
    var origInit = s.init;
    function init(opts) {
      opts = opts || {};
      if (!opts.tunnel) opts.tunnel = '/sentry-tunnel';
      return origInit.call(s, opts);
    }
    Object.defineProperty(s, 'init', {
      configurable: true,
      enumerable: true,
      get: function () { return init; },
      set: function (novo) { origInit = novo; }   // o bundle do CDN reatribui aqui
    });
  })();
</script>

Confira em produção: no DevTools, o envelope vai para /sentry-tunnel, e não para o host do GlitchTip.

Deploy web — login social, COOP/COEP e popup

  • Login social no web é popup; signInWithRedirect é fallback — o redirect depende do iframe de auth, que o Cross-Origin-Embedder-Policy: require-corp bloqueia, e o boot trava em tela branca.
  • Os headers de cross-origin isolation não são mais necessários ao PowerSync: a partir do SDK Dart/Flutter 2.2.0 o OPFS roda em Chrome, Firefox e Safari (doc oficial). Suba o SDK e tire COOP e COEP do nginx.
  • Meio-termo é o pior: COOP: unsafe-none com COEP: require-corp perde o isolamento e mantém o custo. Se o COOP tiver de existir, o valor que convive com popup é same-origin-allow-popups; restrict-properties nenhum browser implementa.
  • Verificação: curl -sI <url>/ sem os dois headers, mais um login social real no browser.

Login e tela de acesso (app que loga pelo central-backend)

O app que autentica pelo central (0030) tem o estado autenticado sem acesso concedido — sem o grant da 0029:

  • Pendente e rejeitado são telas diferentes.
  • O contato do admin vem no corpo do 403 — o usuário pendente não tem JWT para consultar a lista (Segurança).
  • A tela identifica o app: id, nome e versão visíveis.
  • Tela administrativa entra na navegação, não existe só por URL.
  • Identidade parcial (Apple): o nome vem só no primeiro login e o e-mail pode ser relay @privaterelay.appleid.com; a identificação é sempre pelo subject.

Config e Central de Apps

  • O build do web e do mobile roda no CI: o job build_web do pipeline.yml publica fonte.xadm.biz/xadm/<slug>:web-amd64 e o deploy vai pelo control-plane; o Coolify só puxa, e o recurso web fica sem env de build (Operação no Coolify).
  • Client-grade (DSN, app key, bucket) vem do docs/app.json e vira --dart-define: o /xadm-setup o grava, o build o lê — mesmo mecanismo no Dockerfile (web) e no pipeline.yml (mobile). O // empty do jq deixa vazio quando a feature não está ligada, e o define vazio chega como "": o defaultValue do String.fromEnvironment só vale com o define ausente, então o lado Dart testa isEmpty:
RUN apt-get update && apt-get install -y --no-install-recommends jq   # Alpine: apk add --no-cache jq
RUN flutter build web --release \
      --dart-define=SENTRY_DSN=$(jq -r '.features.glitchtip.dsn // empty' docs/app.json) \
      --dart-define=GARAGE_BUCKET=$(jq -r '.features.garage.bucket // empty' docs/app.json)

Com .dockerignore excluindo docs/, a exceção !docs/app.json vai junto (Operação no Coolify). - Segredo de build é secret do repo no GitHub: o SENTRY_AUTH_TOKEN (upload de sourcemap), lido pelo build_web e passado como --build-arg; sem ele o build passa e só não sobe o .map. Destino, org e projeto do upload não são variável do repo: o build_web os deriva do docs/app.json (host do DSN, smoke.glitchtip.org, features.glitchtip.project) e os passa como SENTRY_URL, SENTRY_ORG e SENTRY_PROJECT. Como o upload é degradável, coordenada faltando passaria calada e o evento chegaria minificado. Por isso o valida-frontmatter.py reprova no gate o app web com features.glitchtip.enabled que não declara as três. - O Dockerfile web → nginx é receita por app (o fluxo de sourcemap difere entre apps), sem template. - Extração de texto no Dockerfile é CR-safe: extração por shell de arquivo versionado (a versão do pubspec.yaml por sed) filtra o \r (| tr -d '\r'), e o app Flutter web leva o .gitattributes do kit (gitattributes-flutter). Melhor: ler o version.json que o flutter build web gera. - O /health do web é gerado no build, com versao do version.json e commit do ARG XADM_COMMIT — shape em Versionamento. - Analytics: o /xadm-setup scaffolda o helper (Aptabase) e lista onde chamar o baseline; a instrumentação custom é do app (Central de Apps).

Observabilidade

  • GlitchTip/Sentry: sentry_flutter no pubspec; no main, await SentryFlutter.init((o){ o.dsn = <do app.json>; }, appRunner: () => _bootstrap()), com todo o bootstrap dentro do appRunner. Captura custom só nos pontos escolhidos.
  • Rota /test (opt-in): tela fora do menu, atrás de login, com um botão que faz Sentry.captureException(Exception('teste GlitchTip <app_id>'), stackTrace: StackTrace.current) — confirma em produção que o DSN chega.

Receitas

  • Exportar XLSX sem lib licenciada — excel + pós-processar OOXML. O pacote excel (MIT) gera a planilha mas não faz freeze pane, autoFilter nem outline (verificado no fonte da 4.0.6). Pós-processe o zip com archive: gere os bytes com excel, abra o zip, edite o XML da planilha e re-zipe.
    • Hooks: freeze = <pane ySplit="1" …/> no sheetView; filtro = <autoFilter ref="…"/> após </sheetData>; outline = outlineLevel="N" nas linhas-filhas + <outlinePr summaryBelow="0"/>.
    • Armadilhas: Archive.files é imutável (remonte um Archive novo); pós-processar pula o excel.save(), então o download web é seu (Blob + <a download>, import condicional); a edição por string fica acoplada à saída do plugin — torne-a idempotente e teste re-unzipando (XmlDocument.parse + Excel.decodeBytes); XML válido não é "abre no Excel", e exige smoke real.
    • Hierarquia × ordenação: o outline não sobrevive a sort plano — pré-ordene no Dart.

Armadilhas

Sintoma Causa Cura
-0,00 na tela só no web; toString() dá -0.0 e isNegative dá true no dart2js o int é número do JavaScript, e -v ou v * -1 com v zero dá o zero negativo; == 0 segue verdadeiro, e a guarda não o vê negue com 0 - v; o flutter test roda na VM, onde dá 0, e não pega
no web, a sessão cai sem erro logo depois do primeiro login: o read do flutter_secure_storage devolve null duas escritas concorrentes antes de existir a chave AES no browser geram duas chaves; a última grava por cima, e o valor cifrado com a outra não decifra — o pacote engole o erro um ponto só grava o token, com as operações numa fila; o flutter test não roda o WebCrypto e não pega
di<T>() dentro do factory async do próprio T lança, e ninguém vê o unawaited do registro no get_it engole o erro passe a dependência explícita ao factory
splash eterno depois do hot restart handler de allReady() no build perde a subscription a cada rebuild (watch_it) future criado uma vez no initState + FutureBuilder
setState() called during build com ListenableBuilder no routerDelegate o go_router notifica durante o build escute e adie para o pós-frame (addPostFrameCallback)
F5 numa rota funda volta para a inicial, no web o MaterialApp do splash reescreve o window.location antes de o router ler capture Uri.base antes do runApp e passe overridePlatformDefaultLocation: true
observer raiz dispara em troca dentro do ShellRoute (go_router 17) o 17.x notifica os observers raiz no shell notifyRootObserver: false no ShellRoute
observer não vê a troca de rota dentro do shell o shell tem navigator próprio routerDelegate.addListener
dialog abre ou fecha no navigator errado Navigator.of(context) pega o navigator aninhado do shell Navigator.of(context, rootNavigator: true)
log some do console, no web o developer.log não chega ao console do browser de forma confiável no web, espelhe com print()
falso slow-query, no web o browser estrangula o timer de aba em background limiar alto e configurável
CanvasKit baixado duas vezes a variante é escolhida em runtime, e o preload baixa a outra sem preload do CanvasKit no index.html
erro "enviado" que não chega ao GlitchTip o captureException devolve o id sem garantir a entrega confirme pelo beforeSend ou pelo flush() do hub
no web, o envelope vai direto para o GlitchTip, sem passar pelo túnel o sentry_flutter 9.x não expõe tunnel no web e recarrega o SDK do CDN no boot, que reatribui o init por cima de um patch que só envolve a função intercepte a propriedade init com Object.defineProperty, com setter (receita)
Aptabase responde 400 ou descarta a prop prop com Map aninhado achate as props
~13 s de retries por flush do Aptabase com ad-blocker o ERR_BLOCKED_BY_CLIENT vira retry circuit breaker no envio
overflow com escala de texto alta Column(mainAxisSize: MainAxisSize.min) sem maxHeight maxHeight + rolagem
filho perde espaço dentro de Container com border a borda da decoração entra no padding do filho DecoratedBox
Timer lança no teardown o callback lê MediaQuery do context desmontado guarde o valor em didChangeDependencies
watch do banco não re-emite na segunda visita à tela a stream é single-subscription e já foi consumida Stream.multi, abrindo um watch() por listener