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

SintomaCausa provávelCorreção
Vertical viewport was given unbounded heightscrollable dentro de Column sem limiteuse Expanded ou deixe o scrollable ser o corpo
overflow em texto longocard força altura ou largura inadequadareveja constraints, linhas e flexibilidade
controller de texto continua vivodispose não foi implementadodescarte todos os controllers e focus nodes
botão salva formulário inválidovalidate() não foi consultadointerrompa _submit quando retornar falso
leitor de tela anuncia apenas “botão”rótulo/semântica não comunica intençãouse 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.