Dart não é C# com outra sintaxe

A familiaridade com classes, interfaces e async/await ajuda, mas Dart tem decisões próprias. Código idiomático usa imutabilidade, parâmetros nomeados, construtores concisos e tipos anuláveis explícitos.

final class Product {
  const Product({
    required this.id,
    required this.name,
    required this.price,
  });

  final String id;
  final String name;
  final double price;
}

final impede uma nova atribuição à variável após a inicialização. const cria valores de tempo de compilação quando toda a expressão também é constante. Prefira imutabilidade por padrão; ela reduz estados intermediários e torna widgets mais previsíveis.

Null safety como contrato

String não aceita null; String? aceita. Essa diferença deve representar o domínio, não apenas silenciar o compilador.

String greeting(String? displayName) {
  final normalized = displayName?.trim();
  return normalized == null || normalized.isEmpty
      ? 'Olá, pessoa desenvolvedora'
      : 'Olá, $normalized';
}

Use ! somente quando uma invariável já tiver sido verificada e documentada. Em geral, operadores como ?., ?? e promoção de tipo produzem código mais seguro.

Funções e parâmetros nomeados

APIs Flutter usam parâmetros nomeados porque tornam chamadas longas mais legíveis:

Product createProduct({
  required String id,
  required String name,
  double price = 0,
}) {
  if (price < 0) {
    throw ArgumentError.value(price, 'price', 'não pode ser negativo');
  }

  return Product(id: id, name: name.trim(), price: price);
}

Parâmetros opcionais devem ter um padrão seguro ou ser anuláveis. Evite booleanos posicionais: save(true, false) comunica pouco; save(validate: true, notify: false) comunica intenção.

Abstrações, genéricos e extensões

abstract interface class Repository<T> {
  Future<List<T>> getAll();
  Future<void> save(T value);
}

extension CurrencyFormatting on num {
  String toBrl() => 'R\$ ${toStringAsFixed(2).replaceAll('.', ',')}';
}

Interfaces definem contratos; genéricos preservam tipo; extensões adicionam operações próximas do domínio sem criar classes utilitárias globais. Mantenha extensões pequenas e sem efeitos colaterais surpreendentes.

Coleções e pattern matching

final products = <Product>[
  const Product(id: 'p1', name: 'Notebook', price: 5999),
  const Product(id: 'p2', name: 'Mouse', price: 199),
];

final expensiveNames = products
    .where((product) => product.price >= 1000)
    .map((product) => product.name)
    .toList(growable: false);

Listas preservam ordem, Set remove duplicados e Map associa chaves a valores. Escolha a estrutura pelo comportamento necessário, não por hábito.

Pattern matching torna estados fechados mais explícitos:

String statusMessage(Object state) => switch (state) {
  LoadingState() => 'Carregando',
  SuccessState(:final count) => '$count itens carregados',
  FailureState(:final message) => 'Falha: $message',
  _ => 'Estado desconhecido',
};

Future, Stream e falhas

Future<T> representa um resultado que chegará uma vez. Stream<T> representa uma sequência de valores ao longo do tempo.

Future<List<Product>> loadProducts(Repository<Product> repository) async {
  try {
    return await repository.getAll();
  } on TimeoutException {
    throw const ProductFailure('Tempo de resposta excedido');
  } catch (error, stackTrace) {
    Error.throwWithStackTrace(
      ProductFailure('Não foi possível carregar produtos', cause: error),
      stackTrace,
    );
  }
}

Não capture uma exceção apenas para ignorá-la. Converta falhas técnicas em erros compreensíveis para a camada superior e preserve a causa para diagnóstico.

O que acontece quando Dart executa

Cada aplicação começa em main(). Em desenvolvimento, a Dart VM pode usar compilação JIT para oferecer iteração rápida, depuração e Hot Reload quando integrada ao Flutter. Em produção, alvos compatíveis usam compilação AOT para gerar código otimizado antes da execução. Essa diferença explica por que medir desempenho em debug conduz a conclusões erradas.

Um isolate possui sua própria memória e event loop. Future não cria automaticamente uma thread: ele representa um resultado assíncrono processado pelo fluxo do isolate. Trabalho síncrono pesado continua bloqueando a resposta. Para CPU intensiva, avalie outro isolate e o custo de serializar mensagens; para I/O, APIs assíncronas normalmente são suficientes.

Prática guiada: seu primeiro pacote Dart

No Módulo 1, o Flutter escondeu parte da inicialização da linguagem. Agora você vai trabalhar com Dart sem interface gráfica e construir o núcleo do futuro Sales Management. Você criará primeiro Product; Customer e Order ficam para a prática autônoma, depois de o padrão estar compreendido.

Checkpoint 1 — crie um package, não um arquivo solto

Em uma pasta de estudos fora do projeto do Módulo 1, execute:

dart create --template=package sales_core
cd sales_core
dart test

O template package cria lib/, test/, pubspec.yaml e analysis_options.yaml. O teste inicial deve passar. Se dart não for reconhecido, volte ao diagnóstico do Módulo 1: o Dart SDK acompanha o Flutter SDK.

Substitua lib/sales_core.dart por um arquivo de exportação:

export 'src/in_memory_product_repository.dart';
export 'src/product.dart';
export 'src/product_repository.dart';

Crie a pasta lib/src. A convenção é simples: consumidores importam package:sales_core/sales_core.dart; arquivos internos permanecem organizados em src.

Resultado esperado: dart analyze ainda apresentará erros de URI até os três arquivos exportados serem criados. Neste checkpoint, esse erro é esperado e tem causa conhecida.

Checkpoint 2 — modele uma entidade que protege suas invariantes

Crie lib/src/product.dart:

final class Product {
  const Product._({
    required this.id,
    required this.name,
    required this.price,
  });

  factory Product({
    required String id,
    required String name,
    required double price,
  }) {
    final normalizedId = id.trim();
    final normalizedName = name.trim();

    if (normalizedId.isEmpty) {
      throw ArgumentError.value(id, 'id', 'não pode ser vazio');
    }
    if (normalizedName.length < 3) {
      throw ArgumentError.value(name, 'name', 'deve ter 3 caracteres');
    }
    if (price < 0) {
      throw ArgumentError.value(price, 'price', 'não pode ser negativo');
    }

    return Product._(
      id: normalizedId,
      name: normalizedName,
      price: price,
    );
  }

  final String id;
  final String name;
  final double price;

  Product copyWith({String? name, double? price}) {
    return Product(
      id: id,
      name: name ?? this.name,
      price: price ?? this.price,
    );
  }
}

extension CurrencyFormatting on num {
  String toBrl() => 'R\$ ${toStringAsFixed(2).replaceAll('.', ',')}';
}

O construtor público é uma factory: ele valida e normaliza antes de criar a instância pelo construtor privado. Assim, não existe Product válido com ID vazio ou preço negativo. copyWith não contorna a validação; ele passa novamente pela factory.

Crie test/product_test.dart:

import 'package:sales_core/sales_core.dart';
import 'package:test/test.dart';

void main() {
  group('Product', () {
    test('normaliza os textos e formata o preço', () {
      final product = Product(id: ' p1 ', name: ' Mouse ', price: 199.9);

      expect(product.id, 'p1');
      expect(product.name, 'Mouse');
      expect(product.price.toBrl(), r'R$ 199,90');
    });

    test('rejeita preço negativo', () {
      expect(
        () => Product(id: 'p1', name: 'Mouse', price: -1),
        throwsArgumentError,
      );
    });
  });
}

Execute dart test test/product_test.dart. Primeiro você protege o comportamento da entidade; depois adiciona infraestrutura.

Checkpoint 3 — defina o contrato assíncrono

Crie lib/src/product_repository.dart:

import 'product.dart';

abstract interface class ProductRepository {
  Future<List<Product>> getAll();
  Future<void> save(Product product);
  Stream<List<Product>> watchAll();
}

O contrato expressa três comportamentos sem mencionar banco ou HTTP. Future representa um resultado futuro único; Stream representa uma sequência de listas ao longo do tempo.

Crie lib/src/in_memory_product_repository.dart:

import 'dart:async';

import 'product.dart';
import 'product_repository.dart';

final class InMemoryProductRepository implements ProductRepository {
  final List<Product> _items = [];
  final StreamController<List<Product>> _changes =
      StreamController<List<Product>>.broadcast();

  List<Product> get _snapshot => List.unmodifiable(_items);

  @override
  Future<List<Product>> getAll() async => _snapshot;

  @override
  Future<void> save(Product product) async {
    if (_items.any((item) => item.id == product.id)) {
      throw StateError('Produto ${product.id} já existe');
    }

    _items.add(product);
    _changes.add(_snapshot);
  }

  @override
  Stream<List<Product>> watchAll() async* {
    yield _snapshot;
    yield* _changes.stream;
  }

  Future<void> dispose() => _changes.close();
}

Por que retornar List.unmodifiable? Sem isso, um consumidor poderia executar clear() e alterar o estado interno sem passar por save. Por que broadcast? O exemplo permite mais de um observador. Em produção, a escolha entre stream simples e broadcast deve ser consciente.

Checkpoint 4 — observe uma mudança assíncrona

Crie example/main.dart:

import 'package:sales_core/sales_core.dart';

Future<void> main() async {
  final repository = InMemoryProductRepository();
  final subscription = repository.watchAll().listen((products) {
    final names = products.map((product) => product.name).join(', ');
    print('Catálogo: ${names.isEmpty ? 'vazio' : names}');
  });

  await Future<void>.delayed(Duration.zero);
  await repository.save(
    Product(id: 'p1', name: 'Notebook', price: 5999),
  );

  await Future<void>.delayed(Duration.zero);
  await subscription.cancel();
  await repository.dispose();
}

Execute:

dart run example/main.dart

A saída deve conter primeiro Catálogo: vazio e depois Catálogo: Notebook. await não cria uma thread; ele permite que o isolate continue processando sua fila enquanto aguarda a conclusão assíncrona.

Checkpoint 5 — teste contrato, falha e Stream

Crie test/in_memory_product_repository_test.dart:

import 'package:sales_core/sales_core.dart';
import 'package:test/test.dart';

void main() {
  late InMemoryProductRepository repository;

  setUp(() => repository = InMemoryProductRepository());
  tearDown(() => repository.dispose());

  test('salva e devolve uma cópia não modificável', () async {
    final product = Product(id: 'p1', name: 'Mouse', price: 199);
    await repository.save(product);

    final products = await repository.getAll();

    expect(products, [same(product)]);
    expect(() => products.clear(), throwsUnsupportedError);
  });

  test('rejeita identificador duplicado', () async {
    await repository.save(Product(id: 'p1', name: 'Mouse', price: 199));

    expect(
      () => repository.save(Product(id: 'p1', name: 'Teclado', price: 299)),
      throwsStateError,
    );
  });

  test('publica estado inicial e alteração', () async {
    final states = repository.watchAll();

    final expectation = expectLater(
      states,
      emitsInOrder([
        isEmpty,
        predicate<List<Product>>((items) => items.single.id == 'p1'),
      ]),
    );

    await Future<void>.delayed(Duration.zero);
    await repository.save(Product(id: 'p1', name: 'Mouse', price: 199));
    await expectation;
  });
}

O late diz que a variável será inicializada antes do uso; setUp garante isso para cada teste. tearDown fecha o controller mesmo quando um teste falha. expectLater é necessário porque a asserção observa eventos futuros.

Gate local dart format .

Aplica o formatador oficial aos arquivos do diretório atual. Ele padroniza apresentação, não corrige design ou comportamento. Revise o diff mesmo depois da formatação.

No CI, use dart format --output=none --set-exit-if-changed . para verificar sem reescrever. Um código de saída diferente de zero deve bloquear o gate.

Gate local dart analyze

Executa análise estática a partir do analysis_options.yaml. Leia a severidade e o código do diagnóstico; lints são decisões explícitas do projeto, não obstáculos para silenciar indiscriminadamente.

Análise estática detecta classes inteiras de erro sem executar o programa, mas não confirma regras de negócio.

Gate local dart test

Executa os testes do pacote Dart. Uma saída verde significa que as asserções existentes passaram naquele ambiente; não significa que todos os comportamentos relevantes foram cobertos.

Teste valor, falha e transições. Controle relógio, aleatoriedade e I/O para evitar testes intermitentes.

Execute o gate completo:

dart format .
dart analyze
dart test

O módulo está concluído quando os três comandos terminam sem erro e você consegue explicar qual risco cada um reduz.

Erros comuns

SintomaCausa provávelCorreção
URI de src/... não existearquivo ainda não foi criado ou nome está diferentecompare exports e nomes no disco
Bad state: Stream has already been listened tocontroller não foi criado como broadcast para múltiplos ouvintesconfirme a decisão de broadcast ou mantenha um ouvinte
teste do Stream não terminaexpectativa foi criada depois do evento ou controller não foi fechadoassine antes de save e use tearDown
lista muda fora do repositórioreferência mutável vazoudevolva List.unmodifiable
exceção aparece depois do testeFuture não foi aguardadouse await ou retorne o Future

Prática autônoma

Adicione Customer e Order repetindo o processo: invariantes primeiro, contrato depois, implementação por último. Um pedido deve exigir ao menos um item e calcular o total a partir dos produtos; não aceite um total informado externamente sem validação.

Termos para revisar: null safety, Future, Stream, isolate, event loop, JIT, AOT, extension, factory, interface e pattern matching. Consulte a referência de comandos e conceitos sempre que precisar.

Termos para revisar: null safety, Future, Stream, isolate, event loop, JIT, AOT, extension, mixin, sealed class e pattern matching. Consulte a referência de comandos e conceitos sempre que precisar.