Flutter — cliente / web / desktop / mobile¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-05
O arquétipo cliente da casa (web, desktop e mobile). Organização feature-first
(features sob lib/features/; compartilhado em lib/_shared/; núcleo em lib/core/).
Piso¶
dart format --output=none --set-exit-if-changed . && flutter analyze && flutter test — sempre
no gate (CI, gates e testes); formatação verificada no CI, senão o formato é opinião.
O --output=none não é cosmético: sem ele o dart format reescreve os arquivos em disco (é o
default), o gate "conserta" o que deveria só acusar e a segunda rodada fica verde
(verificador não muta a árvore).
No agente/local, use flutter test -r failures-only (verde ≈ 1 linha; vermelho = só as
falhas — o reporter default despeja progresso e queima contexto:
higiene de saída).
Lints: copie o
analysis_options.yaml
canônico do central para a raiz do repo — fonte única (par do checkstyle.xml no Java; não
copie de outro app). Base flutter_lints + regras de segurança de recurso assíncrono
(unawaited_futures, cancel_subscriptions, close_sinks) destiladas do piloto. Alto valor,
verificado: na adoção pegaram 3 bugs reais (future não aguardado, subscription/sink não
fechado) — ligue-as cedo (na caracterização, antes do refactor), não no fim.
Arquitetura (v0.1 — destilada dos pilotos)¶
Destilado em 2026-07-05 do que os três apps Flutter da casa realmente praticam (bi-transporte, bi-comercial, authui). "v0.1": o que é consenso virou norma; o que ainda diverge está declarado como variação — não normalize acidente.
Layout feature-first (norma, praticado 3/3):
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) — convenção de BI, não
universal. A fronteira entre camadas é enforçada pela
guarda de imports no gate, não por disciplina.
DI — get_it + watch_it (norma): todo o wiring vive em configureDependencies() no
locator.dart, com a taxonomia de registro:
registerSingleton— serviço leve, 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 — auditoria 2026-06 dos pilotos).
O locator expõe o seam de teste configureDependencies({AppDatabase? databaseOverride})
— o teste injeta um banco in-memory semeado no lugar do PowerSync (registrado como async
mesmo sendo síncrono, porque dependsOn exige — pegadinha documentada no piloto).
Estado — no manager/service, nunca na tela (norma): o manager é o dono do estado, exposto
como ValueNotifier/ValueListenable; a tela é WatchingWidget com watchValue, burra
(setState só para UI local, ex. campo de busca). O valor observado carrega o próprio ciclo:
MeasureValue<T> sealed (loading/data/error + refreshing(previous), que evita flicker
mantendo o dado anterior durante recompute) nos BIs; app pequeno pode usar sealed classes por
feature (Loading/Data/Error) com switch exaustivo na tela — as duas formas são aceitas, o
invariante é erro-como-valor observável, não exceção atravessando a UI.
Ação do usuário — command_it (recomendado): Command.createAsyncNoResult no notifier;
isRunning é a única fonte de "carregando" (reentrância bloqueada de graça);
Command.globalExceptionHandler no bootstrap loga + mostra toast para o que escapar. App
pequeno (authui) usa método async + try/catch no State — aceito abaixo de ~3 features.
Extrair side-effect (analytics/telemetria) de um serviço de estado — delegação, não listener. Ao
tirar um efeito (analytics, telemetria) de um serviço de estado reativo (flutter_it/notifier), o
instinto é um observer escutando o ValueListenable. Mas se o que se rastreia é o estado PRETENDIDO
(intenção do usuário) e o exposto é o EFETIVO (derivado, com ajustes automáticos), o listener emite
eventos espúrios. Faça o serviço delegar — observer.onChange(prev, next) no ponto exato da
mudança de intenção — em vez de escutar o listenable derivado: mantém a semântica correta e move a
máquina (buffer/debounce/emissão + acoplamento ao SDK de analytics) para fora do serviço. Ref.:
FilterAnalyticsObserver (bi-transporte).
Dado (BI/PowerSync): raw tables com DDL versionada em core/database/ (a mesma DDL
alimenta o banco in-memory dos testes — paridade de schema); padrão de performance =
materializar a tabela em RAM (cache services) e agregar em Dart (JOIN no SQLite-WASM
media 18–27s; Map Dart é O(1) — números do piloto). Sem Drift em nenhum app da casa. O lado do
transporte (connector, write-back via uploadData, JWKS, config) está em PowerSync.
Como nasce uma feature: pasta features/<nome>/ com pages/<nome>_screen.dart
(WatchingWidget), manager/service registrado lazy no locator.dart com dispose:,
model/ com agregador puro (testável sem UI/DI) + resultado tipado, widgets/ privados;
rota no core/router/ (única exceção permitida de import core→features); testes espelhando
(unit do agregador + widget da screen).
Variações ainda não normatizadas (decidir quando o 4º app chegar): recompute-só-quando-
visível tem duas soluções — CacheBackedPanelManager (bi-transporte, a geração mais recente;
referência recomendada) vs PanelLifecycleMixin + attach/detach (bi-comercial); organização
de test/ (por tipo vs espelho de lib/). Declare no livro do app qual variante ele usa.
Erros, logging e observabilidade¶
Modelo de erro (norma): exceção tipada na borda de IO, com semântica
(AuthRemoteException.isInvalidCredentials, não Exception genérica); no meio, o erro
vira valor no estado observável (MeasureValue.error(e, st) / Error sealed) — quem
converte é o manager, logando antes; na UI, o trio loading/vazio/erro com widgets
compartilhados (_shared/widgets/: skeleton, empty state, error state com "Tentar
novamente"). Erro esperado de ação do usuário é mapeado para mensagem no state (401 →
"credenciais inválidas"; rede → mensagem própria — no Web, erro de rede chega como
ClientException, não SocketException); o inesperado cai no
Command.globalExceptionHandler.
Captura GlitchTip/Sentry (norma): SentryFlutter.init(..., appRunner: _bootstrap) com
todo o bootstrap dentro do appRunner — o SDK cobre FlutterError.onError e async
não-capturado sozinho (ninguém seta à mão); envio só em kReleaseMode e DSN não-vazio
(vazio = no-op, o app roda sem a feature); sendDefaultPii = false.
O que se loga — fachada AppLog (recomendado, destilado dos BIs): info/warn viram
breadcrumbs (contexto de quem chega junto do erro, não evento avulso); error() faz
Sentry.captureException com tags indexáveis (log_category, phase= por etapa de
fluxo — diagnóstico sem sourcemap); measureAsync com expectedError: para 401 não poluir o
GlitchTip; slow-query sintética acima de threshold configurável em runtime. Regra absoluta:
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:
InMemoryAppDatabaseexecuta a mesma DDL das raw tables (core/database/) — teste de query roda SQL de verdade e não passa por divergência de schema. Injetado viaconfigureDependencies(databaseOverride:). - Builders de fixture com defaults nomeados (
movRow(placa: 'ABC1234')) emtest/_helpers/— fixtures 100% sintéticas (dado real de cliente é proibido, constituição §5). - Seams nomeados, não mock-framework: construtor
forTesting(...), clock injetável (_now), credenciais injetáveis,@visibleForTesting. O piloto não usa mockito — a costura é de design, não de biblioteca. - Widget test:
di.pushNewScope()nosetUp+di.popScope()notearDown, registrando só o que a tela usa;SharedPreferences.setMockInitialValues. - Teste de contrato do
app.json(padrão do authui, recomendado a todo app da Central): featureenabled⇒ os refs client-grade existem e têm o formato certo ("nunca meia-boca"). - Journey/integration tests (
integration_test/) ficam fora do gate headless — limitação declarada, não decisão de qualidade; rode-os local. Screenshots do manual: receita própria (kScreenshotMode + seed in-memory). - Verificação visual headless: a mesma receita de screenshot (
kScreenshotMode+ seedInMemoryAppDatabase) dobra como loop de verificação de mudança de pixel — renderize pro PNG e leia a imagem pra confirmar que a mudança entrou, sem app rodando. Útil quando o diff é visual e o teste de widget não captura o que mudou.
Identidade visual (paleta · logo · tipografia)¶
A arquitetura tem piso; o visual também — pra um app não nascer com a cara genérica do
flutter create (ColorScheme.fromSeed num azul aleatório + ícones default do Flutter).
Paleta por grupo: casa (xadm) ou cliente (cliente). A identidade abaixo (paleta/logo/
tipografia da casa) é o default e vale para apps da X-Adm (grupo: xadm no app.json). Um
app de cliente (grupo: cliente, ex. bi-comercial = OnPetro) usa a marca do cliente —
paleta/logo/tipografia derivadas do logo do cliente, não a da casa. O gatilho é o grupo do
app.json (cliente → marca do cliente; xadm → casa) — decisão de 2026-07-03 (antes o gatilho
era "vestir a marca declarado"; simplificado pro grupo, feedback de app). O que segue valendo em
todo app é o mecanismo, não as cores: tema por ColorScheme (não fromSeed-aleatório),
app-icon via flutter_launcher_icons, feature-first — "faça direito com a marca certa". Num app de
cliente, leia os itens abaixo como "a paleta/logo/tipografia do cliente" (derivadas do logo dele)
no lugar dos tokens da casa.
- Paleta da casa (default), derivada do logo. A fonte-da-verdade são os tokens do
xadm.cssdo central (amostrados deassets/xadm-logo.png): azul royal #064490 (primária), médio #2a5b97 (accent/links), claro #7092bb / escuro #042c5e, prata #939495 (neutro). No Flutter, traduza paraColorScheme(primária = royal, secondary = médio) — nãofromSeedcom azul de exemplo. (UmThemeDatalight+dark pronto vem no padrão de identidade — em elaboração, §6 do central.) - Logo e ícone do app. O logo da casa é
https://docs.xadm.biz/assets/xadm-logo.png— embarque-o na UI (header/rail/splash) no lugar dos ícones default. Para app-icon/favicon, useflutter_launcher_iconsapontando pro logo. (A variação quadrada/adaptativa — necessária pra ícone bom — está pendente de design; até lá, não gere ícone recortando o lockup horizontal.) - Tipografia/densidade = decisão pendente, não acidente. Hoje é acaso: o site de docs usa
Roboto (default do Material) e o Flutter idem; um app (bi-transporte) chegou a declarar Inter.
A casa ainda não fixou a família — a escolha (Roboto-default × Inter-de-marca), a type scale e
a densidade (
VisualDensity) entram no padrão de identidade (spec). Até lá, siga o default do Material e não invente fonte.
O ThemeData compartilhado, o kit mínimo de componentes (estado vazio/erro, status chip) e a
variação quadrada do logo são o padrão de identidade visual de apps, em elaboração via spec
(§6 do central).
Guarda de fronteiras (imports)¶
A arquitetura que o livro do app narra tem guarda no gate, derivada do que o livro descreve (não uma estrutura ideal importada). Começar simples, sem esperar lint custom perfeito:
- um script Dart (
tool/check_architecture.dart) ou teste (test/architecture/import_boundaries_test.dart) que lê os imports e falha em fronteira proibida — exatamente as que o livro do app afirma. - Antes de escrever a guarda: confirme no livro quais são as camadas reais (se o app
organiza por
features//_shared//core/, guarde isso; se organiza diferente, guarde o que ele realmente afirma).
Exemplos do que costuma valer guardar (sujeito ao que o livro diz):
- camada de infra não importa camada de feature;
- uma feature não importa outra direto (salvo allowlist);
- página/widget não acessa o banco direto (passa por manager/service);
- model/queries não importam UI;
- rota pública não expõe código de debug.
Comece pelo script/teste; lint custom só se a regra crescer. O livro linka a guarda.
Exemplo de partida (test/architecture/import_boundaries_test.dart)¶
Um teste sem dependência nova (só dart:io + test) — copie e ajuste as regras às
fronteiras que o SEU livro afirma. Sem um esqueleto, o time defere a guarda (lição do
piloto, que tinha a arquitetura nítida e mesmo assim ficou sem guarda):
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 (gate). Cada fronteira nova = uma tupla em regras.
Específico de Flutter¶
A arquitetura, os erros e os testes estão nas seções acima (v0.1, destiladas dos pilotos);
uma eventual skill xadm-flutter empacotará isso como scaffold. Pontos operacionais já
provados que valem fixar:
Build e versão do SDK¶
- Versão do SDK = fonte única no
.fvmrc. A versão do Flutter já morou em 4 lugares (.fvmrc,ARG FLUTTER_VERSIONdo Dockerfile, CI, build-arg do Coolify) e um bump esquecendo um quebrou deploy E CI. Regra: o.fvmrcmanda; oARG FLUTTER_VERSIONdo Dockerfile e a tagci-flutter:<v>do CI batem com ele; nada deFLUTTER_VERSIONno Coolify (build-arg stale venceu o default bom e quebrou opub get). Decisão 0003. Antes de apontar ocontainer: ci-flutter:<v>, a versão tem que estar namatriz-baseline.jsonda fábrica (PR no central + push → a fábrica builda a imagem) — senão o pull falha e o CI trava (deadlock de bootstrap; correção 2026-06-25 na decisão 0003). - Bump de SDK: limpe o cache de shader. Depois de
fvm use <nova versão>, o shaderink_sparkle.fragem cache do SDK antigo quebra widget tests que dãotap(Expected 2, got 0) →flutter clean+flutter pub get+ regenerar assets antes de rodar os testes. .gitignore: use ogitignore-flutterdo central (pubspec.lockversionado,.fvmrc) — referência inicial: copie e adapte (o app acrescenta as suas regras); não é rastreado no manifesto (diferente docheckstyle.xml, que é idêntico entre apps).- Run de debug/E2E web: não fixe a porta. O
flutter run -d chrome(ou-d web-server) já serve numa porta random e imprime a URL (http://localhost:<porta>) — o antipadrão é fixar--web-port <n>, que colide com uma run viva ou zumbi. Se quiser random explícito,--web-port=0(o SO escolhe). Em qualquer caso, leia a URL impressa, não presuma8080. O porquê geral em agentes § Loop de verificação.
Deploy web — cache no nginx¶
main.dart.jséno-cache, não cache longo — o Flutter Web não versiona URL de filho. Instinto errado (e comentado como verdade em app da casa até 2026-07-16): "oflutter_bootstrap.jscarrega os filhos com?v=<build>, então basta revalidar o bootstrap". Falso — verificado no build 3.44: o_flutter.buildConfigtraz"mainJsPath":"main.dart.js"e o loader monta a URL comnew URL(...), sem query e sem hash no nome. O único?v=do bootstrap é oflutter_service_worker.js?v=<serviceWorkerVersion>— e a casa builda com--pwa-strategy=none, então nem service worker existe pra invalidar nada. Sintoma quando se erra: oversion.jsonrevalida, o banner de "nova versão" aparece, o usuário clica, olocation.replacerecarrega — e o browser serve o mesmo bundle do cache, por até 30 dias: a versão troca, o código não (bug reproduzido 2026-05-13 no bi-comercial; o bi-transporte serviumax-age=2592000em produção por dois meses com o comentário do nginx afirmando o contrário).- A regra:
no-cache, must-revalidatepara os entry points (/,index.html,flutter_bootstrap.js,main.dart.js,version.json,manifest.json,health) e para todo asset de nome fixo —MaterialIcons-Regular.otfeFontManifest.json/AssetManifest*(um deploy com--no-tree-shake-iconssome com os ícones por semanas, mesmo com Ctrl+F5),sqlite3.wasmepowersync_db.worker.js(WASM velho após bump do SDK → "Unsupported powersync extension version" no boot).no-cachenão éno-store: o browser grava e faz request condicional, o servidor responde 304 quando nada mudou. - Cache longo só para o que tem URL versionada. Na prática, o
defaultdomap. O CanvasKit não é problema: o bootstrap o busca no CDN (gstatic.com/flutter-canvaskit/<engineRevision>), versionado por path e sem passar pelo nginx do app.
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;
}
- Verificação, não fé: depois do deploy,
curl -sI <url>/main.dart.jse confira ocache-control. É o único jeito de pegar a divergência — o app builda, sobe e funciona igual com a regra errada; o custo só aparece no deploy seguinte.
Config e Central de Apps¶
- Config pública é fonte única no repo, não build-arg do Coolify: o que não é segredo e
vai embutido no
main.dart.js(DSN do Sentry/GlitchTip, key de analytics, URL pública) mora no repo, não na indireção frágil do build-arg do Coolify. Com a Central de Apps, esse "no repo" é odocs/app.json(escrito pelo/xadm-setup) lido pela ponte--dart-define(abaixo) — não hardcode no Dart. Segredo real (ex.SENTRY_AUTH_TOKENde upload de sourcemap) ⇒ build-arg/secret do Coolify. --dart-defineno Coolify = Build Argument, não env de runtime: todo valor que o Flutter embarca no bundle em build (SENTRY_DSN,FLUTTER_VERSION, endpoint de release) tem que ser Build Argument no Coolify (+ "Available at Buildtime") — como env de runtime chega vazio ao bundle e o app sobe sem ele, sem erro. Pega-pé recorrente; mecânica completa em Operação no Coolify.- Central de Apps (build-time) — a ponte
app.json→--dart-define: o client-grade (DSN, app key, bucket) que o/xadm-setupgrava emdocs/app.jsoné a fonte única, committada — o build lê oapp.jsone injeta via--dart-define. Mesmo mecanismo pra web (no Dockerfile) e mobile (no CI); só o lugar do build muda. Client-grade NÃO precisa de Build Argument no Coolify — está noapp.jsoncommittado, o Dockerfile lê direto; Coolify Build Argument fica só pro segredo (não vai noapp.json; injetado pelo guard de deploy) e praFLUTTER_VERSION. Receita (instalejqno stage de build — é a ponte que o/xadm-setuppasso 5 pressupõe; entregue-a, não hardcode ARG):(mobile: idêntico noRUN 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)ci.yml—flutter build apk --dart-define=…). O// emptydojqdeixa o valor vazio quando a feature não estáenabled(no-op, sem quebrar o build). Se o.dockerignoreexcluidocs/(comum), a mesma metade da ponte precisa da exceção!docs/app.json— senão ojqnão acha o arquivo no contexto de build e o deploy quebra (detalhe e o contraste com o.git/HEAD). Analytics: o/xadm-setupscaffolda um helper (Aptabase) e lista onde chamar o baseline (login/logout/clique/CRUD) — instrumentação custom é sua. Ver Central de apps.
Observabilidade¶
- GlitchTip/Sentry — init (o
/xadm-setupscaffolda, com seu OK):sentry_flutternopubspec; nomain,await SentryFlutter.init((o){ o.dsn = <do app.json>; }, appRunner: () => _bootstrap())com todo o bootstrap dentro doappRunner— o SDK cobreFlutterError.onErrore async sozinho (não registre à mão; ver Erros e observabilidade). DSN = client-grade (--dart-define, acima); vazio = no-op. Captura custom só nos pontos que você escolher — não espalhetry/catchno negócio. - Rota de diagnóstico
/test(smoke-test pós-deploy, opt-in): tela oculta (rota nomeada fora do menu/nav), login-gated, com um botão "Enviar exceção de teste" →Sentry.captureException(Exception('teste GlitchTip <app_id>'), stackTrace: StackTrace.current)+ umSnackBarde confirmação. Confirma em produção que o DSN chega no GlitchTip sem esperar um erro real. Oculta (só quem sabe a rota) + login-gated (não deixa qualquer um spammar). O/xadm-setupoferece scaffoldar (com seu OK), junto da instrumentação.
Receitas¶
- Exportar XLSX sem lib licenciada —
excel+ pós-processar OOXML. O pacoteexcel(justkawal, MIT — license-safe) gera a planilha, mas não faz freeze pane, autoFilter nem outline (agrupamento +/−) — verificado no fonte da 4.0.6 (odrawing1.xmlemitido é scaffold inerte; instância do princípio "confirmar a capacidade da lib na fonte antes de especificar",/x-desenhar§3). Em vez de trocar por Syncfusion (licenciado), pós-processe o zip OOXML com o pacotearchive: gere os bytes comexcel, abra o zip, edite o XML da planilha, injete os recursos e re-zipe. (Imagem/logo no XLSX ficou fora de escopo — não vale a injeção demedia/drawing.)- Hooks: freeze =
<pane ySplit="1" …/>nosheetView; filtro =<autoFilter ref="…"/>após</sheetData>; outline =outlineLevel="N"nas linhas-filhas +<outlinePr summaryBelow="0"/>. - Armadilhas: (1)
Archive.filesé imutável (archive 3.6) → remonte umArchivenovo copiando/substituindo; (2) pós-processar bypassaexcel.save()(que codifica E baixa) → faça o download Web você mesmo (Blob+<a download>viapackage:web/dart:js_interop, import condicional); (3) edição por string/regex fica acoplada à saída do plugin → torne idempotente e cubra com teste que re-unzipa e valida bem-formação (XmlDocument.parse) +Excel.decodeBytes; (4) teste garante XML válido, não "abre no Excel" → exija smoke real. - Hierarquia × ordenação: o outline colapsa mas não sobrevive a sort plano — para BI hierárquico de entrega, pré-ordene no Dart e documente "use o filtro" (PivotTable é o canônico, porém pesado).
- Hooks: freeze =