Integração é uma fronteira

Uma API externa não deve ditar o formato do domínio nem vazar detalhes de transporte para a interface. Organize o fluxo:

Widget -> Controller -> Use case -> Repository -> Data source -> HTTP

O caminho de volta converte JSON em DTO, DTO em entidade e exceções em falhas conhecidas.

Cliente Dio centralizado

Dio buildDio(AppEnvironment environment) {
  return Dio(
    BaseOptions(
      baseUrl: environment.apiUrl,
      connectTimeout: const Duration(seconds: 10),
      receiveTimeout: const Duration(seconds: 20),
      sendTimeout: const Duration(seconds: 20),
      headers: const {'Accept': 'application/json'},
    ),
  );
}

Não crie um Dio por requisição. A instância central permite interceptores, cancelamento, métricas e políticas consistentes.

DTO e conversão defensiva

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

  factory ProductDto.fromJson(Map<String, Object?> json) {
    final id = json['id'];
    final name = json['name'];
    final price = json['price'];

    if (id is! String || name is! String || price is! num) {
      throw const FormatException('Produto inválido');
    }

    return ProductDto(id: id, name: name, price: price.toDouble());
  }

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

Geradores reduzem repetição, mas não eliminam a necessidade de validar contratos e casos de compatibilidade.

Data source e repositório

final class DioProductRemoteDataSource {
  const DioProductRemoteDataSource(this._dio);
  final Dio _dio;

  Future<List<ProductDto>> getProducts() async {
    final response = await _dio.get<List<dynamic>>('/products');
    final data = response.data ?? const [];
    return data
        .map((item) => ProductDto.fromJson(item as Map<String, Object?>))
        .toList(growable: false);
  }
}

O repositório decide cache, fallback e conversão para entidade. A camada de apresentação não deve conhecer Response, status code ou cabeçalhos.

Autenticação sem vazamento

Um interceptor pode obter o token de uma abstração segura e adicionar o cabeçalho. Nunca escreva token em logs, mensagens de erro ou analytics.

final class AuthInterceptor extends Interceptor {
  AuthInterceptor(this._tokenStore);
  final TokenStore _tokenStore;

  @override
  Future<void> onRequest(
    RequestOptions options,
    RequestInterceptorHandler handler,
  ) async {
    final token = await _tokenStore.read();
    if (token != null) options.headers['Authorization'] = 'Bearer $token';
    handler.next(options);
  }
}

Refresh de token precisa de exclusão mútua: várias respostas 401 não podem iniciar renovações paralelas. Se a renovação falhar, encerre a sessão de forma clara.

Erro, retry e cancelamento

Classifique falhas por causa e possibilidade de recuperação. Retry automático só é seguro para operações idempotentes e falhas transitórias. Não repita indefinidamente um POST sem idempotency key.

Use CancelToken quando a tela puder ser descartada ou uma busca substituída. Diferencie cancelamento voluntário de falha real.

Logging responsável

Registre método, rota normalizada, duração, status e correlation ID. Remova Authorization, cookies, payloads sensíveis e dados pessoais. Logs de desenvolvimento não devem virar configuração de produção por acidente.

Prática guiada: substitua o fake por HTTP sem contaminar o domínio

Continue com a arquitetura do Módulo 3. A interface não mudará; você trocará somente a implementação composta em AppDependencies.

Checkpoint 1 — instale e configure um único cliente

flutter pub add dio

Crie lib/core/network/dio_factory.dart:

import 'package:dio/dio.dart';

Dio buildDio({required String baseUrl}) {
  return Dio(
    BaseOptions(
      baseUrl: baseUrl,
      connectTimeout: const Duration(seconds: 10),
      receiveTimeout: const Duration(seconds: 20),
      sendTimeout: const Duration(seconds: 20),
      headers: const {'Accept': 'application/json'},
    ),
  );
}

Não escreva a URL diretamente no widget. Leia configuração pública na composição:

const apiUrl = String.fromEnvironment(
  'API_URL',
  defaultValue: 'http://localhost:8080',
);
final dio = buildDio(baseUrl: apiUrl);

Execute com flutter run --dart-define=API_URL=https://seu-ambiente. Um dart-define incluído no binário não é segredo.

Checkpoint 2 — adapte Dio a uma interface pequena

Crie core/network/json_client.dart:

import 'package:dio/dio.dart';

abstract interface class JsonClient {
  Future<Object?> get(String path, {Object? cancelToken});
}

final class DioJsonClient implements JsonClient {
  DioJsonClient(this._dio);
  final Dio _dio;

  @override
  Future<Object?> get(String path, {Object? cancelToken}) async {
    final response = await _dio.get<Object?>(
      path,
      cancelToken: cancelToken as CancelToken?,
    );
    return response.data;
  }
}

O cast fica na borda do adapter. O data source passa a depender de uma operação necessária, não da API inteira de Dio.

Checkpoint 3 — implemente o data source defensivamente

Substitua o fake por HttpProductRemoteDataSource:

final class HttpProductRemoteDataSource implements ProductRemoteDataSource {
  const HttpProductRemoteDataSource(this._client);
  final JsonClient _client;

  @override
  Future<List<ProductDto>> getProducts() async {
    final payload = await _client.get('/products');
    if (payload is! List) {
      throw const FormatException('A resposta deveria ser uma lista');
    }

    return payload.map((item) {
      if (item is! Map) {
        throw const FormatException('Item de produto inválido');
      }
      return ProductDto.fromJson(Map<String, Object?>.from(item));
    }).toList(growable: false);
  }
}

Não use as Map<String, dynamic> sem verificação: um payload incompatível precisa virar uma falha controlada na fronteira.

Checkpoint 4 — prove HTTP com um servidor local descartável

Crie um teste VM em test/features/products/data/http_product_remote_data_source_test.dart:

import 'dart:convert';
import 'dart:io';

import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sales_management/core/network/json_client.dart';
import 'package:sales_management/features/products/data/data_sources/product_remote_data_source.dart';

void main() {
  test('busca e converte produtos por HTTP', () async {
    final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
    addTearDown(server.close);

    server.listen((request) async {
      request.response.headers.contentType = ContentType.json;
      request.response.write(jsonEncode([
        {'id': 'p1', 'title': 'Mouse', 'price': 199},
      ]));
      await request.response.close();
    });

    final dio = Dio(BaseOptions(
      baseUrl: 'http://${server.address.host}:${server.port}',
    ));
    final dataSource = HttpProductRemoteDataSource(DioJsonClient(dio));

    final result = await dataSource.getProducts();

    expect(result.single.title, 'Mouse');
    expect(result.single.price, 199);
  });
}

Esse teste atravessa HTTP real no loopback, mas não depende de internet nem de um servidor compartilhado. Adicione casos com JSON inválido, status 500 e timeout.

Checkpoint 5 — traduza DioException uma única vez

Crie uma taxonomia em core/error/app_failure.dart e um mapper:

sealed class AppFailure implements Exception {
  const AppFailure(this.message);
  final String message;
}
final class NetworkFailure extends AppFailure { const NetworkFailure(super.message); }
final class UnauthorizedFailure extends AppFailure { const UnauthorizedFailure(super.message); }
final class ContractFailure extends AppFailure { const ContractFailure(super.message); }
final class UnexpectedFailure extends AppFailure { const UnexpectedFailure(super.message); }

AppFailure mapDioException(DioException error) => switch (error.response?.statusCode) {
  401 => const UnauthorizedFailure('Sessão expirada'),
  >= 400 && < 500 => const ContractFailure('Requisição rejeitada'),
  _ when error.type == DioExceptionType.connectionTimeout ||
      error.type == DioExceptionType.receiveTimeout =>
    const NetworkFailure('Tempo de resposta excedido'),
  _ => const UnexpectedFailure('Falha inesperada na comunicação'),
};

Capture Dio na camada data e lance AppFailure. A UI decide a mensagem final e a ação; ela não importa Dio.

Checkpoint 6 — autenticação, redação e cancelamento

Adicione um interceptor que depende de TokenStore, nunca de uma variável global. Antes de registrar requisições, remova Authorization, cookies e campos pessoais. Não use LogInterceptor(requestBody: true) em produção.

Para busca substituível, crie um CancelToken por operação, cancele o anterior e diferencie CancelToken.isCancel(error) de falha. Retry automático fica limitado a GET idempotente e falha transitória; nunca repita POST indefinidamente.

Componha:

final dio = buildDio(baseUrl: apiUrl);
final remote = HttpProductRemoteDataSource(DioJsonClient(dio));
final repository = ProductRepositoryImpl(remote);
productsController = ProductsController(GetProducts(repository));

Execute dart format, flutter analyze e flutter test.

Matriz de falhas obrigatória

CenárioEstado/apresentação esperadaRetry automático?
timeout em GETmensagem de conexão e tentar novamentelimitado
400dados rejeitados; orientar correçãonão
401renovar sessão uma vez ou sairnão como request comum
403acesso insuficientenão
500 em GETindisponibilidade temporárialimitado
JSON inválidoincompatibilidade de contratonão
cancelamento voluntárionenhuma mensagem de erronão

Prática autônoma: implemente POST de produto com idempotency key e um teste provando que timeout não é tratado como confirmação de que o servidor não processou a operação.

Aprofundamento: semântica antes da biblioteca

Dio organiza transporte; ele não define o contrato. Antes do código, descreva método, rota, autenticação, timeout, corpo, status de sucesso, erros e idempotência. GET repetido deveria observar o mesmo recurso sem criar efeito; POST pode duplicar trabalho se houver retry sem chave de idempotência.

Separe falha de transporte, resposta HTTP válida com erro de negócio e payload inválido. Um timeout não prova que o servidor não processou a operação. Um 400 contratual não deve entrar em retry infinito. Um 401 pode exigir refresh; um 403 normalmente representa autorização insuficiente, não sessão expirada.

Teste a matriz de falhas e confirme a mensagem e ação oferecidas ao usuário. “Erro inesperado” é fallback, não estratégia principal.

Termos para revisar: idempotência, timeout, cancellation, DTO, status code, interceptor, correlation ID e redaction. Consulte a referência do curso.