Meu app rodava lindo no celular. Aí botaram ele num totem deitado e o layout explodiu.

Tem um momento na carreira de todo dev Flutter que parece pegadinha de programa de auditório: você entrega um app redondo, testado em três iPhones e dois Androids, o cliente aprova, todo mundo bate palma — e seis meses depois alguém manda uma foto do seu app rodando num totem de autoatendimento. Deitado. Num tablet pequeno. Com o título cortado no meio, um botão de “Confirmar” escondido atrás do teclado e a fonte tão miúda que o cliente precisa encostar o nariz na tela pra ler.

Bem-vindo ao clube. Eu também levei esse soco.

A frase que ninguém te avisa: “responsivo” não é um breakpoint que você copia do tutorial. É entender o contexto físico de uso do device. E totem é o exemplo perfeito de como o “responsivo de mentira” desmorona.

O contexto: ninguém disse que ia virar totem

O app nasceu como app de pedidos pra celular. Tela vertical, dedão do usuário a 20 centímetros do display, uma coisa de cada vez. Tudo certo.

Aí o cliente teve a ideia genial (que ninguém comunicou pro time de dev, claro) de colocar o mesmo app num totem de autoatendimento — um tablet de 8 polegadas, montado na horizontal, parafusado numa coluna, e o usuário olhando de pé, a um metro de distância.

Três variáveis mudaram de uma vez:

  1. Orientação — virou paisagem, e o layout vertical não tinha plano B.
  2. Densidade de informação — sobrou largura, faltou altura. O Column que cabia no celular agora estourava pra fora da tela.
  3. Distância de leitura — texto de 14sp é confortável na mão; a um metro vira borrão.

O detalhe cruel: no emulador, em modo retrato, tudo continuava perfeito. O bug só existia no mundo físico. Esse é o tipo de problema que não aparece no PR — aparece na foto que chega no grupo do WhatsApp às 19h de sexta.

O erro de raiz: decidir layout por “mobile vs web”

A primeira tentação — e o primeiro erro — é resolver com um if:

// ❌ O reflexo errado
if (isDesktop) {
  return DesktopLayout();
} else {
  return MobileLayout();
}

Isso não responde a pergunta certa. Totem não é Desktop. É um Android rodando num tablet. Pra esse if, totem é “mobile”, e você acabou de mandar o layout de celular pra uma tela horizontal. O problema nunca foi a plataforma. O problema é o form factor: quanto espaço eu tenho, em qual orientação, e a que distância isso vai ser lido.

A regra de ouro: decida pelo espaço disponível, não pelo nome da plataforma. E quem te dá o espaço disponível em Flutter é o LayoutBuilder.

Solução 1 — LayoutBuilder: pergunte ao espaço, não ao device

LayoutBuilder te entrega as constraints reais do pai. Em vez de adivinhar, você mede:

class AdaptiveHome extends StatelessWidget {
  const AdaptiveHome({super.key});

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (context, constraints) {
        // Decide por largura real, não por "é mobile?"
        if (constraints.maxWidth >= 600) {
          return const _WideLayout();   // totem, tablet, paisagem
        }
        return const _PhoneLayout();    // celular em pé
      },
    );
  }
}

A diferença sutil pra MediaQuery.of(context).size: o MediaQuery te dá a tela inteira; o LayoutBuilder te dá o espaço que aquele widget realmente tem. Dentro de um painel lateral, de um modal, de um split view, é o LayoutBuilder que fala a verdade. Pra layout de componente, prefira ele. MediaQuery fica ótimo pra decisões globais (insets, safe area, escala de texto do sistema).

Solução 2 — OrientationBuilder pro tombo da paisagem

O totem deitado é o caso clássico onde o Column precisa virar Row. Não force isso na unha com if espalhado, deixe explícito:

class OrderScreen extends StatelessWidget {
  const OrderScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return OrientationBuilder(
      builder: (context, orientation) {
        final isLandscape = orientation == Orientation.landscape;

        // Em paisagem: produtos à esquerda, resumo à direita.
        // Em retrato: tudo empilhado.
        return Flex(
          direction: isLandscape ? Axis.horizontal : Axis.vertical,
          children: const [
            Expanded(flex: 2, child: ProductGrid()),
            Expanded(flex: 1, child: OrderSummary()),
          ],
        );
      },
    );
  }
}

Flex com direction dinâmico é um truque limpo: o mesmo código vira Row ou Column sem duplicar a árvore. Em paisagem, você usa a largura sobrando pra colocar o resumo do pedido ao lado, exatamente o espaço que estava ocioso.

Solução 3 — tipografia que se lê de longe

Esse é o ponto que quase todo mundo esquece. No celular, a tela está na sua mão. No totem, está a um metro do seu rosto. A mesma fonte de 14 pontos é confortável num caso e ilegível no outro.

A correção não é chumbar fontSize: 28 no totem. É escalar a tipografia em função do form factor, de um único ponto:

class FormFactor {
  const FormFactor._(this.textScale);
  final double textScale;

  factory FormFactor.from(BoxConstraints constraints) {
    // Totem/tablet em paisagem: leitura à distância → texto maior.
    if (constraints.maxWidth >= 600) return const FormFactor._(1.35);
    return const FormFactor._(1.0);
  }
}

// No topo da árvore do totem:
LayoutBuilder(
  builder: (context, constraints) {
    final factor = FormFactor.from(constraints);
    return MediaQuery(
      data: MediaQuery.of(context).copyWith(
        textScaler: TextScaler.linear(factor.textScale),
      ),
      child: const OrderScreen(),
    );
  },
)

Sobrescrever o textScaler no MediaQuery faz toda a tipografia abaixo dele crescer junto, sem você sair caçando Text por Text. É escala de tipografia de verdade: um número, aplicado no contexto físico certo. (E continue respeitando o textScaler do sistema pra acessibilidade — aqui é multiplicação, não substituição cega.)

Solução 4 — FittedBox e constraints pra não estourar na tela curta

Lembra que no totem deitado sobra largura e falta altura? É aí que o Column solta aquele simpático “RenderFlex overflowed by 37 pixels”, a listra amarela e preta da vergonha.

Pra conteúdo que precisa caber inteiro (um valor de total, um número de senha, um título de etapa), FittedBox resolve encolhendo proporcionalmente em vez de cortar:

SizedBox(
  height: 64,
  child: FittedBox(
    fit: BoxFit.scaleDown, // só encolhe se faltar espaço; nunca estica demais
    alignment: Alignment.centerLeft,
    child: Text(
      'Total: R\$ 248,90',
      style: Theme.of(context).textTheme.displaySmall,
    ),
  ),
)

E pro corpo da tela que pode ter mais conteúdo do que cabe na altura curta da paisagem, a rede de segurança honesta é tornar a área rolável, melhor rolar do que estourar:

SingleChildScrollView(
  child: Column(children: itens),
)

Não é “gambiarra”: é reconhecer que altura virou recurso escasso e tratar isso de propósito.

O impacto técnico (e o que de fato mudou)

Depois do ajuste, o mesmo binário passou a se comportar como três apps diferentes sem três bases de código:

  • Celular em pé → layout empilhado, fonte padrão, do jeito que sempre foi.
  • Totem em paisagem → grade de produtos à esquerda, resumo à direita, tipografia 35% maior, nada de overflow.
  • Tablet do gerente → herdou o layout largo de graça, porque a decisão é por espaço, não por device.

Zero if (isTotem) espalhado pelo código. Toda a adaptação ficou concentrada em dois lugares: o que mede (LayoutBuilder/OrientationBuilder) e o que escala (FormFactor + textScaler). É manutenível, é testável com golden tests em tamanhos diferentes, e o mais importante, não quebra de novo quando aparecer o próximo form factor que ninguém previu.

A lição que fica é desconfortável e libertadora ao mesmo tempo: quase todo app sério acaba rodando num formato que ninguém planejou. Tablet, totem, dobrável, tela de carro, TV. O “responsivo” de tutorial… aquele if largura > 600 e pronto! Falha exatamente nesse ponto, porque ele responde “qual o tamanho da tela?” quando a pergunta certa é “como, onde e a que distância isso vai ser usado?“.

Trate form factor como cidadão de primeira classe desde o começo: meça o espaço com LayoutBuilder, reaja à orientação com OrientationBuilder, escale a tipografia pelo contexto de leitura e use FittedBox/scroll como rede de segurança. Faça isso, e da próxima vez que chegar a foto do seu app num totem deitado às 19h de sexta, você vai poder responder no grupo: “tá previsto, pode parafusar”.