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:
- Orientação — virou paisagem, e o layout vertical não tinha plano B.
- Densidade de informação — sobrou largura, faltou altura. O
Columnque cabia no celular agora estourava pra fora da tela. - 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”.


