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 esperadi.allReady()antes de renderizar rotas;registerLazySingleton+dispose:— managers de feature (sem odispose: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 porconfigureDependencies(databaseOverride:). - Builders de fixture com defaults nomeados (
movRow(placa: 'ABC1234')) emtest/_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 comfakeAsync— o ponto de injeção é a zona: oTimerfixo da produção já é interceptado, então não parametrize a duração só para testar. O callback dofakeAsyncé síncrono: com corpoasync, semawaitno retorno o teste passa vazio, e comawaitoFuturenunca completa. Chameelapse()/flushMicrotasks()e afirme depois.- Package importado em
test/vai emdev_dependencies, mesmo chegando transitivo (fake_async,matcher,collection): odepend_on_referenced_packagesacusa em info, e oflutter analyzeroda com--fatal-infosligado. - Widget test:
di.pushNewScope()nosetUp+di.popScope()notearDown, 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 osaddTearDownde reset, ouscrollUntilVisibleantes do tap. DropdownButton:tap(find.byType(DropdownButton<T>)), não no hint; selecione o item comfind.text(...).last.- Teste de contrato do
app.json: featureenabled⇒ os refs client-grade existem e têm o formato certo. - Arquivo estático servido ao mundo (o
web/roles-catalog.jsonda 0029) se testa lendo o arquivo real comdart:io, nunca uma cópia emtest/, 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.pngna 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 doisIcon-maskable-*, sem preencher o fundo. Obackground_colorsó muda omanifest.json. - Ícone que a plataforma recorta ou preenche não tem transparência: o alvo do
apple-touch-icondoindex.htmle os íconesmaskabledomanifest.json. O que o sistema pinta no lugar do pixel transparente não é escolha do app. - Logo de fundo transparente no
Icon-192.pngpede alvo próprio: oindex.htmldoflutter createaponta oapple-touch-iconpara esse arquivo, e o pacote não edita oindex.html. Exporte um PNG opaco para oapple-touch-icone troque ohref; osmaskableocupam o quadro inteiro, com a marca no centro. - A guarda é o
checa-icones-web.py, no gate dopipeline.yml. Reprova PNG deweb/idêntico ao doflutter create, inclusive renomeado. Avisa, sem reprovar, o alvo doapple-touch-icone omaskablecom pixel não opaco. - A lista de ícones do
flutter createé datada pela versão do SDK: ao subir otoolchain.flutter, rode a guarda numflutter createnovo 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.jsontoolchain.flutter: opipeline.ymla exporta para osubosito/flutter-action, e o.fvmrce oARG FLUTTER_VERSIONdo Dockerfile batem com ela (a guarda do gate compara). Nada deFLUTTER_VERSIONno Coolify. Native assets (PowerSync/sqlite3) precisam deninja-buildno 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 rodaflutter 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 getantes dos testes, senão oink_sparkle.fragantigo quebra widget test comtap. .gitignore: ogitignore-flutterdo kit, copiado uma vez e adaptado (pubspec.lockversionado).- Run de debug web não fixa porta:
flutter run -d chromejá 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 — obuildConfigtraz"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-revalidatepara 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-cachenã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 doapp.jsonvia--dart-define(ocentral-uifaz 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 oenvsubstrendeu, o nginx resolve o nome ao carregar a config e, sem DNS, sai comhost 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 oresolverresolve a cada pedido: sem rede, só o túnel responde 502. Com variável, a barra final doproxy_passnão tira mais o prefixo, e quem faz isso é orewrite ... break. Osetvem antes dorewrite, porque obreakencerra ossetseguintes e a variável chega vazia (500invalid URL prefix). A guarda proxy_pass resolvido no boot dopipeline.ymlreprova host literal noproxy_pass; IP,localhosteupstreamnomeado passam. - O túnel é chamada de app para app:
bugeaptabasemoram no mesmo host, e o recurso web leva o--dnsdo Coolify para não sair pelo hairpin NAT (chamada de app para app). Oresolverdo nginx não muda. - O GlitchTip exige a key no pedido, na query (
sentry_key=) ou noX-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. envsubstsempre com a lista explícita de variáveis: sem ela o$urie o$request_methoddo 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 -tfecha o render. O smoke só vê a rota do túnel declarada commethod: POST(ela devolve 405 aGET). - A guarda do
pipeline.yml(Destino de proxy cravado) reprova host doapp.jsonescrito literal num.conf/.conf.template. Ponto cego:ARGdo Dockerfile com o host como default — o destino do upload de sourcemap chega derivado do DSN pelobuild_web, e oARGfica sem default. - No browser, o
tunnelentra por um patch noweb/index.html, porque osentry_flutter9.x não expõe a opção no web. No boot, ele sempre carrega de novo o SDK debrowser.sentry-cdn.com, sem conferir se já existe um, e esse bundle reatribui oinitno mesmowindow.Sentry. Por isso não adianta envolver oinitnem owindow.Sentry: ovar Sentrydo bundle cria uma propriedade não-configurável nowindow. O que funciona é interceptar a propriedadeinitcomObject.defineProperty, de modo que a reatribuição caia no setter. O script vai logo depois dobundle.min.jslocal, 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 oCross-Origin-Embedder-Policy: require-corpbloqueia, 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-nonecomCOEP: require-corpperde o isolamento e mantém o custo. Se o COOP tiver de existir, o valor que convive com popup ésame-origin-allow-popups;restrict-propertiesnenhum 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 pelosubject.
Config e Central de Apps¶
- O build do web e do mobile roda no CI: o job
build_webdopipeline.ymlpublicafonte.xadm.biz/xadm/<slug>:web-amd64e 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.jsone vira--dart-define: o/xadm-setupo grava, o build o lê — mesmo mecanismo no Dockerfile (web) e nopipeline.yml(mobile). O// emptydojqdeixa vazio quando a feature não está ligada, e o define vazio chega como"": odefaultValuedoString.fromEnvironmentsó vale com o define ausente, então o lado Dart testaisEmpty:
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_flutternopubspec; nomain,await SentryFlutter.init((o){ o.dsn = <do app.json>; }, appRunner: () => _bootstrap()), com todo o bootstrap dentro doappRunner. Captura custom só nos pontos escolhidos. - Rota
/test(opt-in): tela fora do menu, atrás de login, com um botão que fazSentry.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 pacoteexcel(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 comarchive: gere os bytes comexcel, abra o zip, edite o XML da planilha e re-zipe.- Hooks: freeze =
<pane ySplit="1" …/>nosheetView; filtro =<autoFilter ref="…"/>após</sheetData>; outline =outlineLevel="N"nas linhas-filhas +<outlinePr summaryBelow="0"/>. - Armadilhas:
Archive.filesé imutável (remonte umArchivenovo); pós-processar pula oexcel.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.
- Hooks: freeze =
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 |