Interface profissional é um sistema
Uma tela isolada pode parecer correta e ainda falhar quando o produto cresce. Interfaces profissionais dependem de regras compartilhadas para espaçamento, tipografia, cores, estados e comportamento responsivo.
Material 3 oferece fundamentos e componentes, mas não substitui decisões do produto. Use o tema como fonte de verdade:
ThemeData buildTheme(ColorScheme colors) => ThemeData(
useMaterial3: true,
colorScheme: colors,
textTheme: const TextTheme(
headlineMedium: TextStyle(fontWeight: FontWeight.w700),
bodyLarge: TextStyle(fontSize: 16, height: 1.5),
),
inputDecorationTheme: const InputDecorationTheme(
border: OutlineInputBorder(),
),
);
Evite cores e tamanhos arbitrários dentro de widgets. O tema permite coerência, dark mode e mudanças de marca com menor risco.
Restrições antes de coordenadas
Flutter organiza layout por restrições: o pai informa limites, o filho escolhe um tamanho e o pai o posiciona. Quando um Row estoura, o problema costuma ser uma restrição mal distribuída.
Row(
children: [
Expanded(child: ProductSummary(product: product)),
const SizedBox(width: 16),
FilledButton(onPressed: onEdit, child: const Text('Editar')),
],
)
Use ListView.builder para listas potencialmente grandes e reserve Column para conjuntos pequenos.
Formulários que ajudam a corrigir
Validação deve explicar o problema no contexto do campo e preservar o que a pessoa digitou.
TextFormField(
controller: nameController,
decoration: const InputDecoration(labelText: 'Nome do produto'),
textInputAction: TextInputAction.next,
validator: (value) {
if (value == null || value.trim().length < 3) {
return 'Informe pelo menos 3 caracteres';
}
return null;
},
)
Ao enviar, desabilite ações duplicadas, mostre progresso e mova o foco para o primeiro erro quando possível.
Responsividade por decisão de layout
Não crie versões separadas da aplicação apenas por largura. Defina breakpoints a partir do conteúdo:
Widget build(BuildContext context) {
final width = MediaQuery.sizeOf(context).width;
if (width >= 1024) return const DesktopDashboard();
if (width >= 600) return const TabletDashboard();
return const MobileDashboard();
}
Extraia partes comuns e altere a composição. Teste tamanhos intermediários, orientação, escala de fonte e conteúdo traduzido.
Estados visuais obrigatórios
Toda área que busca dados precisa representar:
- Carregamento sem saltos excessivos de layout.
- Conteúdo disponível.
- Lista vazia com orientação de próximo passo.
- Erro compreensível e ação de tentar novamente.
- Ação em andamento, sucesso e falha.
Acessibilidade desde a composição
Use rótulos claros, alvos confortáveis, contraste suficiente e ordem lógica de foco. Não dependa somente de cor para comunicar estado. Semantics, tooltips e testes com navegação por teclado tornam problemas visíveis antes da produção.
Prática guiada: transforme a feature em uma experiência
Continue no sales_management do Módulo 3. Antes de editar, execute flutter test e confirme que a arquitetura anterior está verde.
Checkpoint 1 — aplique tema e composição na raiz
Substitua lib/main.dart:
import 'package:flutter/material.dart';
import 'app_dependencies.dart';
import 'features/products/presentation/pages/products_page.dart';
void main() {
final dependencies = AppDependencies();
runApp(SalesApp(dependencies: dependencies));
}
class SalesApp extends StatelessWidget {
const SalesApp({required this.dependencies, super.key});
final AppDependencies dependencies;
@override
Widget build(BuildContext context) {
final colors = ColorScheme.fromSeed(seedColor: Colors.indigo);
return MaterialApp(
title: 'Sales Management',
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
colorScheme: colors,
inputDecorationTheme: const InputDecorationTheme(
border: OutlineInputBorder(),
),
),
home: ProductsPage(controller: dependencies.productsController),
);
}
}
O arquivo ainda não compila porque ProductsPage será criado a seguir. Esse erro intermediário é esperado.
Checkpoint 2 — renderize todos os estados
Crie presentation/pages/products_page.dart:
import 'package:flutter/material.dart';
import '../controllers/products_controller.dart';
class ProductsPage extends StatefulWidget {
const ProductsPage({required this.controller, super.key});
final ProductsController controller;
@override
State<ProductsPage> createState() => _ProductsPageState();
}
class _ProductsPageState extends State<ProductsPage> {
@override
void initState() {
super.initState();
widget.controller.load();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Produtos')),
body: AnimatedBuilder(
animation: widget.controller,
builder: (context, _) => switch (widget.controller.state) {
ProductsInitial() || ProductsLoading() =>
const Center(child: CircularProgressIndicator()),
ProductsLoaded(:final items) when items.isEmpty => const _EmptyView(),
ProductsLoaded(:final items) => _ProductsContent(items: items),
ProductsFailure(:final message) => _ErrorView(
message: message,
onRetry: widget.controller.load,
),
},
),
);
}
}
class _EmptyView extends StatelessWidget {
const _EmptyView();
@override
Widget build(BuildContext context) => const Center(
child: Text('Nenhum produto disponível.'),
);
}
class _ErrorView extends StatelessWidget {
const _ErrorView({required this.message, required this.onRetry});
final String message;
final VoidCallback onRetry;
@override
Widget build(BuildContext context) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(message),
const SizedBox(height: 16),
FilledButton(onPressed: onRetry, child: const Text('Tentar novamente')),
],
),
);
}
O switch é exaustivo porque ProductsState é sealed. Se um novo estado for criado, o compilador lembra você de representá-lo na interface.
Checkpoint 3 — faça o conteúdo responder às restrições
No mesmo arquivo, adicione imports para a entidade e estas classes:
import '../../domain/entities/product.dart';
class _ProductsContent extends StatelessWidget {
const _ProductsContent({required this.items});
final List<Product> items;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final columns = switch (constraints.maxWidth) {
>= 1100 => 4,
>= 700 => 2,
_ => 1,
};
return GridView.builder(
padding: const EdgeInsets.all(24),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: columns,
mainAxisSpacing: 16,
crossAxisSpacing: 16,
childAspectRatio: columns == 1 ? 3 : 1.6,
),
itemCount: items.length,
itemBuilder: (context, index) => _ProductCard(product: items[index]),
);
},
);
}
}
class _ProductCard extends StatelessWidget {
const _ProductCard({required this.product});
final Product product;
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(product.name, style: Theme.of(context).textTheme.titleLarge),
const Spacer(),
Text('R\$ ${product.price.toStringAsFixed(2)}'),
],
),
),
);
}
}
Use as restrições do componente, não a largura global da tela. Redimensione lentamente: 699, 700, 1099 e 1100 pixels são mais informativos que testar somente três aparelhos famosos.
Checkpoint 3: nenhuma transição gera faixa de overflow e os cards continuam legíveis.
Checkpoint 4 — crie um formulário que ensina a corrigir
Crie presentation/pages/product_form_page.dart com uma Form, uma GlobalKey<FormState>, campos de nome e preço e um botão salvar. Comece pelo menor fluxo funcional:
class ProductFormPage extends StatefulWidget {
const ProductFormPage({super.key});
@override
State<ProductFormPage> createState() => _ProductFormPageState();
}
class _ProductFormPageState extends State<ProductFormPage> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
final _priceController = TextEditingController();
@override
void dispose() {
_nameController.dispose();
_priceController.dispose();
super.dispose();
}
void _submit() {
if (!_formKey.currentState!.validate()) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Produto válido; persistência virá depois.')),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Novo produto')),
body: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(24),
children: [
TextFormField(
controller: _nameController,
decoration: const InputDecoration(labelText: 'Nome'),
textInputAction: TextInputAction.next,
validator: (value) => value == null || value.trim().length < 3
? 'Informe ao menos 3 caracteres'
: null,
),
const SizedBox(height: 16),
TextFormField(
controller: _priceController,
decoration: const InputDecoration(labelText: 'Preço'),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
validator: (value) {
final price = double.tryParse((value ?? '').replaceAll(',', '.'));
return price == null || price < 0 ? 'Informe um preço válido' : null;
},
),
const SizedBox(height: 24),
FilledButton(onPressed: _submit, child: const Text('Salvar')),
],
),
),
);
}
}
Importe material.dart. Acrescente temporariamente uma ação no AppBar de ProductsPage que abre o formulário com Navigator.push. O ListView evita que o teclado torne os campos inacessíveis.
Checkpoint 5 — prove interação e largura estreita
Crie um widget test para o formulário: envie vazio, confirme as duas mensagens, preencha campos e confirme o SnackBar. Para testar layout, use tester.view.physicalSize com uma proporção estreita e restaure o valor em addTearDown.
Também teste manualmente:
- escala de texto em 200%;
- navegação por Tab no desktop/web;
- tema claro e escuro;
- estado vazio e estado de erro;
- nomes de produto longos.
Execute o gate:
dart format lib test
flutter analyze
flutter test
Erros comuns
| Sintoma | Causa provável | Correção |
|---|---|---|
Vertical viewport was given unbounded height | scrollable dentro de Column sem limite | use Expanded ou deixe o scrollable ser o corpo |
| overflow em texto longo | card força altura ou largura inadequada | reveja constraints, linhas e flexibilidade |
| controller de texto continua vivo | dispose não foi implementado | descarte todos os controllers e focus nodes |
| botão salva formulário inválido | validate() não foi consultado | interrompa _submit quando retornar falso |
| leitor de tela anuncia apenas “botão” | rótulo/semântica não comunica intenção | use texto claro, tooltip ou Semantics |
Prática autônoma: extraia _ProductCard para um componente público, implemente dark mode controlado pelo usuário e registre screenshots em 360, 768 e 1280 pixels.
Aprofundamento: constraints e semântica
No layout Flutter, o pai envia constraints, o filho escolhe um tamanho permitido e o pai posiciona o resultado. Isso explica muitos overflows: um filho tenta manter largura intrínseca maior do que o limite recebido. Antes de adicionar Expanded ao acaso, inspecione quem impõe a restrição e qual componente deveria ceder, quebrar linha ou rolar.
Responsividade não é selecionar três telas por largura. Ela inclui densidade, orientação, texto ampliado, teclado, conteúdo traduzido e regiões seguras. Teste as transições entre breakpoints, porque falhas normalmente aparecem entre os tamanhos “oficiais”.
A árvore de semântica não é idêntica à árvore de widgets. Use o inspector para confirmar nome, função, estado e ordem de foco percebidos por tecnologia assistiva. Uma interface visualmente correta pode continuar inutilizável sem esses dados.
Termos para revisar: constraints, intrinsic size, LayoutBuilder, breakpoint, semantics, focus order e text scaling. Consulte a referência do curso.