Pular para conteúdo

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 espera di.allReady() antes de renderizar rotas;
  • registerLazySingleton + dispose: — managers de feature (sem o dispose: 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 delegarobserver.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: InMemoryAppDatabase executa 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 via configureDependencies(databaseOverride:).
  • Builders de fixture com defaults nomeados (movRow(placa: 'ABC1234')) em test/_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() no setUp + di.popScope() no tearDown, registrando o que a tela usa; SharedPreferences.setMockInitialValues.
  • Teste de contrato do app.json (padrão do authui, recomendado a todo app da Central): feature enabled ⇒ 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 + seed InMemoryAppDatabase) 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.css do central (amostrados de assets/xadm-logo.png): azul royal #064490 (primária), médio #2a5b97 (accent/links), claro #7092bb / escuro #042c5e, prata #939495 (neutro). No Flutter, traduza para ColorScheme (primária = royal, secondary = médio) — não fromSeed com azul de exemplo. (Um ThemeData light+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, use flutter_launcher_icons apontando 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_VERSION do Dockerfile, CI, build-arg do Coolify) e um bump esquecendo um quebrou deploy E CI. Regra: o .fvmrc manda; o ARG FLUTTER_VERSION do Dockerfile e a tag ci-flutter:<v> do CI batem com ele; nada de FLUTTER_VERSION no Coolify (build-arg stale venceu o default bom e quebrou o pub get). Decisão 0003. Antes de apontar o container: ci-flutter:<v>, a versão tem que estar na matriz-baseline.json da 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 shader ink_sparkle.frag em cache do SDK antigo quebra widget tests que dão tap (Expected 2, got 0) → flutter clean + flutter pub get + regenerar assets antes de rodar os testes.
  • .gitignore: use o gitignore-flutter do central (pubspec.lock versionado, .fvmrc) — referência inicial: copie e adapte (o app acrescenta as suas regras); não é rastreado no manifesto (diferente do checkstyle.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 presuma 8080. 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): "o flutter_bootstrap.js carrega os filhos com ?v=<build>, então basta revalidar o bootstrap". Falso — verificado no build 3.44: o _flutter.buildConfig traz "mainJsPath":"main.dart.js" e o loader monta a URL com new URL(...), sem query e sem hash no nome. O único ?v= do bootstrap é o flutter_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: o version.json revalida, o banner de "nova versão" aparece, o usuário clica, o location.replace recarrega — 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 serviu max-age=2592000 em produção por dois meses com o comentário do nginx afirmando o contrário).
  • A regra: no-cache, must-revalidate para os entry points (/, index.html, flutter_bootstrap.js, main.dart.js, version.json, manifest.json, health) e para todo asset de nome fixoMaterialIcons-Regular.otf e FontManifest.json/AssetManifest* (um deploy com --no-tree-shake-icons some com os ícones por semanas, mesmo com Ctrl+F5), sqlite3.wasm e powersync_db.worker.js (WASM velho após bump do SDK → "Unsupported powersync extension version" no boot). no-cache nã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 default do map. 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.js e confira o cache-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" é o docs/app.json (escrito pelo /xadm-setup) lido pela ponte --dart-define (abaixo) — não hardcode no Dart. Segredo real (ex. SENTRY_AUTH_TOKEN de upload de sourcemap) ⇒ build-arg/secret do Coolify.
  • --dart-define no 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-setup grava em docs/app.json é a fonte única, committada — o build lê o app.json e 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á no app.json committado, o Dockerfile lê direto; Coolify Build Argument fica só pro segredo (não vai no app.json; injetado pelo guard de deploy) e pra FLUTTER_VERSION. Receita (instale jq no stage de build — é a ponte que o /xadm-setup passo 5 pressupõe; entregue-a, não hardcode ARG):
    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)
    
    (mobile: idêntico no ci.ymlflutter build apk --dart-define=…). O // empty do jq deixa o valor vazio quando a feature não está enabled (no-op, sem quebrar o build). Se o .dockerignore exclui docs/ (comum), a mesma metade da ponte precisa da exceção !docs/app.json — senão o jq não acha o arquivo no contexto de build e o deploy quebra (detalhe e o contraste com o .git/HEAD). Analytics: o /xadm-setup scaffolda 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-setup scaffolda, com seu OK): sentry_flutter no pubspec; no main, await SentryFlutter.init((o){ o.dsn = <do app.json>; }, appRunner: () => _bootstrap()) com todo o bootstrap dentro do appRunner — o SDK cobre FlutterError.onError e 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 espalhe try/catch no 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) + um SnackBar de 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-setup oferece scaffoldar (com seu OK), junto da instrumentação.

Receitas

  • Exportar XLSX sem lib licenciada — excel + pós-processar OOXML. O pacote excel (justkawal, MIT — license-safe) gera a planilha, mas não faz freeze pane, autoFilter nem outline (agrupamento +/−) — verificado no fonte da 4.0.6 (o drawing1.xml emitido é 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 pacote archive: gere os bytes com excel, 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 de media/drawing.)
    • Hooks: freeze = <pane ySplit="1" …/> no sheetView; 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 um Archive novo copiando/substituindo; (2) pós-processar bypassa excel.save() (que codifica E baixa) → faça o download Web você mesmo (Blob + <a download> via package: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).