Módulo 08 — Exercícios: Persistência Local de Dados
Estes exercícios foram elaborados para que você coloque em prática os três mecanismos de persistência local estudados no material do Módulo 08: SharedPreferences, sistema de arquivos via path_provider e banco de dados relacional com sqflite. Persistência é uma habilidade que exige tanto o domínio técnico das APIs quanto o raciocínio arquitetural correto — saber não apenas como guardar um dado, mas onde guardá-lo, em que formato e com qual responsabilidade distribuída entre as camadas do aplicativo. Tente resolver cada exercício sem consultar o material antes de chegar ao final do enunciado. O esforço de trabalhar a partir do que você reteve é o que consolida o aprendizado, especialmente neste módulo, onde a correta separação entre as camadas de domínio e infraestrutura é tão importante quanto o funcionamento técnico do banco de dados.
Exercício 1 — Nível Básico
Exercício 2 — Nível Intermediário
Catálogo de Produtos com SQLite, CRUD Completo e Padrão Repository
Banco relacional no dispositivo: do schema SQL ao repositório abstraído, passando pelo CRUD completo
O sqflite é a solução de persistência relacional para Flutter, e dominá-lo significa compreender tanto a API Dart quanto os princípios de banco de dados relacional: schema, tipos de dados, constraints, consultas com filtros e a importância das transações. Neste exercício, você vai implementar a tela de gerenciamento do catálogo de produtos do aplicativo de delivery, permitindo que o gerente do restaurante adicione, edite, consulte e remova produtos — tudo persistido localmente em SQLite. Além da camada de banco de dados em si, você vai aplicar o padrão Repository para separar a lógica de persistência do restante do aplicativo, seguindo a arquitetura hexagonal que o Projeto Integrador adota.
Você vai implementar um aplicativo Flutter autocontido em um único arquivo, executável com flutter run, com pubspec.yaml incluindo sqflite: ^2.4.2, path: ^1.9.0 e provider: ^6.1.5+1.
O modelo de domínio. Defina a classe Produto com os campos finais id do tipo int? (nulo quando o produto ainda não foi persistido), nome do tipo String, categoria do tipo String, preco do tipo double e disponivel do tipo bool. O construtor deve ter todos os parâmetros nomeados, com id sendo opcional. Implemente o método toMap() que retorna um Map<String, dynamic> com as chaves 'id' (omitindo a chave quando id for nulo, pois o banco gerará o valor automaticamente), 'nome', 'categoria', 'preco' e 'disponivel' (convertido para int, onde 1 representa verdadeiro e 0 representa falso, pois SQLite não tem tipo booleano nativo). Implemente o construtor de fábrica Produto.fromMap(Map<String, dynamic> map) que lê cada campo do mapa, convertendo 'disponivel' de int de volta para bool.
A interface de repositório. Defina a classe abstrata IProdutoRepository com os seguintes métodos assíncronos: Future<List<Produto>> listarTodos(), Future<List<Produto>> listarPorCategoria(String categoria), Future<Produto?> buscarPorId(int id), Future<int> inserir(Produto produto) — retorna o id gerado pelo banco —, Future<void> atualizar(Produto produto), Future<void> remover(int id) e Future<List<String>> listarCategorias() — retorna as categorias únicas presentes no banco, ordenadas alfabeticamente.
A implementação concreta. Implemente a classe SqfliteProdutoRepository que implements IProdutoRepository. Ela deve ter um campo privado Database? _db. O método privado Future<Database> get _banco deve ser assíncrono: se _db não for nulo, retorna _db!; caso contrário, abre o banco de dados. Para abrir o banco, importe path e sqflite, obtenha o diretório do banco com getDatabasesPath(), construa o caminho completo com join(dir, 'delivery.db') e chame openDatabase com version: 1 e onCreate definindo a seguinte instrução SQL:
Implemente cada método da interface. listarTodos() deve chamar db.query('produtos', orderBy: 'nome ASC') e mapear os resultados com Produto.fromMap. listarPorCategoria(categoria) deve usar o parâmetro where: 'categoria = ?' com whereArgs: [categoria]. buscarPorId(id) deve usar where: 'id = ?' e retornar null se a lista de resultados estiver vazia. inserir(produto) deve chamar db.insert('produtos', produto.toMap()) e retornar o id gerado. atualizar(produto) deve chamar db.update com where: 'id = ?' e whereArgs: [produto.id]. remover(id) deve chamar db.delete com as cláusulas correspondentes. listarCategorias() deve usar db.rawQuery('SELECT DISTINCT categoria FROM produtos ORDER BY categoria ASC') e mapear os resultados extraindo o campo 'categoria'.
O notifier. Implemente o CatalogoNotifier que estende ChangeNotifier. Ele deve receber um IProdutoRepository no construtor — não crie o repositório dentro do notifier. Gerencie os campos privados _produtos do tipo List<Produto>, _carregando do tipo bool e _erro do tipo String?, com os getters públicos correspondentes. O método carregarProdutos() deve definir _carregando = true, chamar notifyListeners(), chamar _repositorio.listarTodos() em um try/catch, armazenar o resultado ou o erro conforme o caso, e no finally definir _carregando = false e chamar notifyListeners(). Implemente também os métodos adicionarProduto(Produto p), atualizarProduto(Produto p) e removerProduto(int id), que devem chamar o método correspondente do repositório e, em caso de sucesso, chamar carregarProdutos() para refletir o estado atual do banco na interface.
A interface. Implemente as telas e widgets necessários para a operação completa do catálogo. A tela principal, TelaCatalogo, deve ser um StatefulWidget que chama carregarProdutos() no initState (via addPostFrameCallback) e exibe os produtos em uma ListView.builder. Cada produto deve ser exibido em um Card com ListTile mostrando o nome como title, a categoria e o preço formatado como subtitle, um indicador visual (ícone ou badge) de disponibilidade como leading, e no trailing dois IconButtons — um para editar (Icons.edit) e outro para remover (Icons.delete). Ao tocar em remover, exiba um AlertDialog de confirmação antes de chamar removerProduto. Ao tocar em editar ou no FloatingActionButton (para adicionar novo produto), abra um BottomSheet modal contendo um Form com TextFormFields para nome, categoria e preço, e um SwitchListTile para disponibilidade. O botão de confirmação no BottomSheet deve validar o formulário e chamar adicionarProduto ou atualizarProduto conforme o caso, fechando o BottomSheet em seguida.
O SqfliteProdutoRepository deve ser instanciado uma única vez na aplicação e compartilhado via ChangeNotifierProvider. Uma forma simples de fazer isso sem o GetIt neste exercício é instanciar o repositório no main e passá-lo para o CatalogoNotifier: ChangeNotifierProvider(create: (_) => CatalogoNotifier(SqfliteProdutoRepository())). Pense em por que é importante que o repositório seja um singleton — ou seja, que exista apenas uma instância dele em toda a aplicação — em vez de ser criado a cada vez que um método é chamado. O que aconteceria se cada operação abrisse e fechasse o banco de dados de forma independente?
Ao implementar o BottomSheet para adição e edição de produtos, você precisará lidar com o contexto correto para chamar os métodos do CatalogoNotifier. Como o BottomSheet é exibido com showModalBottomSheet, ele cria um novo contexto que não é descendente do ChangeNotifierProvider original — ele está em uma nova rota na pilha de navegação. Para contornar isso, passe uma referência ao CatalogoNotifier como argumento para o widget do BottomSheet em vez de tentar acessá-lo via context.read de dentro do conteúdo do BottomSheet. Alternativamente, use o context da tela principal (capturado antes de abrir o BottomSheet) para as chamadas ao notifier, e passe o resultado de volta via callback.
O que deve ser entregue: um arquivo chamado g_ex2.dart, onde g é o nome do seu grupo.
Exercício 3 — Nível Desafiador
Pedidos com Persistência Relacional, Comprovante em Arquivo e Estratégia de Cache com GetIt
A arquitetura completa: transações, junções SQL, geração de arquivo e cache offline com sincronização simulada
Uma aplicação de delivery real precisa que seus pedidos — incluindo todos os itens que os compõem — sejam persistidos de forma atômica, consultados com junções entre tabelas e, após a finalização, convertidos em um comprovante gravado no sistema de arquivos do dispositivo. Além disso, precisa de uma estratégia de cache que evite buscas desnecessárias ao servidor quando os dados locais ainda estão frescos. Este exercício une os três mecanismos do módulo — sqflite, path_provider e SharedPreferences — dentro de uma arquitetura com injeção de dependências via GetIt, repositórios abstraídos e um ChangeNotifier que orquestra tudo. É o exercício que mais se aproxima do que você implementará no Projeto Integrador.
:::
Você vai implementar a tela de histórico de pedidos do aplicativo de delivery. O pubspec.yaml deve incluir sqflite: ^2.4.2, path: ^1.9.0, path_provider: ^2.1.5, shared_preferences: ^2.5.4, provider: ^6.1.5+1 e get_it: ^9.2.0. O arquivo pode ser único ou organizado como preferir, mas deve ser executável com flutter run.
Os modelos. Defina a classe ItemPedido com campos finais id do tipo int?, pedidoId do tipo int?, nomeProduto do tipo String, precoUnitario do tipo double e quantidade do tipo int. Implemente toMap() e ItemPedido.fromMap(Map<String, dynamic> map). Defina a classe Pedido com campos finais id do tipo int?, usuarioId do tipo String, status do tipo String, valorTotal do tipo double, criadoEm do tipo DateTime e itens do tipo List<ItemPedido> com valor padrão const []. Implemente toMap() — em criadoEm, converta para String com criadoEm.toIso8601String() — e Pedido.fromMap(Map<String, dynamic> map) — em criadoEm, converta de volta com DateTime.parse(map['criado_em']). Observe que itens não faz parte do mapa persistido na tabela de pedidos; ela é preenchida após a junção com a tabela de itens.
O banco de dados. Defina uma função global Future<Database> abrirBancoDados() que cria as seguintes tabelas na versão 1:
CREATE TABLE pedidos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
usuario_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pendente',
valor_total REAL NOT NULL,
criado_em TEXT NOT NULL
);
CREATE TABLE itens_pedido (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pedido_id INTEGER NOT NULL,
nome_produto TEXT NOT NULL,
preco_unitario REAL NOT NULL,
quantidade INTEGER NOT NULL,
FOREIGN KEY (pedido_id) REFERENCES pedidos (id) ON DELETE CASCADE
);A interface de repositório de pedidos. Defina a classe abstrata IPedidoRepository com os métodos: Future<int> salvarPedido(Pedido pedido) — retorna o id gerado —; Future<List<Pedido>> listarPedidosDoUsuario(String usuarioId) — retorna pedidos com seus itens preenchidos —; Future<void> atualizarStatus(int pedidoId, String novoStatus).
A implementação concreta. Implemente SqflitePedidoRepository que implements IPedidoRepository. O construtor recebe um Database db. O método salvarPedido(pedido) deve usar uma transação para garantir atomicidade: dentro de db.transaction((txn) async {...}), insira o pedido na tabela pedidos sem a chave id (use toMap()..remove('id')) e obtenha o pedidoId retornado; em seguida, para cada item em pedido.itens, insira na tabela itens_pedido um mapa contendo 'pedido_id': pedidoId, 'nome_produto': item.nomeProduto, 'preco_unitario': item.precoUnitario e 'quantidade': item.quantidade. Retorne o pedidoId ao final da transação. O método listarPedidosDoUsuario(usuarioId) deve buscar todos os pedidos do usuário com db.query('pedidos', where: 'usuario_id = ?', whereArgs: [usuarioId], orderBy: 'criado_em DESC'). Para cada pedido encontrado, busque seus itens com db.query('itens_pedido', where: 'pedido_id = ?', whereArgs: [pedido.id]) e combine-os para construir o objeto Pedido com a lista de itens preenchida. Retorne a lista de pedidos completos. O método atualizarStatus deve chamar db.update na tabela pedidos.
O serviço de comprovante. Implemente a classe ComprovanteService com o método Future<String> gerarComprovante(Pedido pedido). Ele deve obter o diretório de documentos com getApplicationDocumentsDirectory(), construir o caminho do arquivo como comprovantes/pedido_${pedido.id}.txt dentro desse diretório, criar o diretório se não existir e escrever um arquivo de texto com as informações do pedido formatadas de forma legível — número do pedido, data e hora, status, lista de itens com preço unitário e quantidade, e valor total. O método deve retornar o caminho completo do arquivo gerado.
A estratégia de cache. Implemente a classe CacheStrategy com dois métodos assíncronos. O método Future<bool> cacheEstaValido(String chave) deve obter uma instância de SharedPreferences, ler o timestamp armazenado na chave 'cache_ts_$chave' como int? e retornar true se o timestamp existir e o tempo decorrido desde ele for menor do que 5 minutos — use DateTime.now().millisecondsSinceEpoch para comparação. O método Future<void> registrarAtualizacao(String chave) deve obter uma instância de SharedPreferences e escrever o timestamp atual na chave 'cache_ts_$chave'.
O service locator. Declare a variável global final sl = GetIt.instance. Escreva a função assíncrona Future<void> configurarDependencias() que: abre o banco de dados com abrirBancoDados() e registra a instância com sl.registerSingleton<Database>(db); registra sl.registerLazySingleton<IPedidoRepository>(() => SqflitePedidoRepository(sl<Database>())); registra sl.registerLazySingleton<ComprovanteService>(() => ComprovanteService()); e registra sl.registerLazySingleton<CacheStrategy>(() => CacheStrategy()). No main, chame WidgetsFlutterBinding.ensureInitialized(), depois await configurarDependencias() e então runApp(...).
O notifier de pedidos. Implemente PedidoNotifier que estende ChangeNotifier. O construtor não recebe argumentos — ele obtém as dependências de dentro usando sl<IPedidoRepository>(), sl<ComprovanteService>() e sl<CacheStrategy>(). Gerencie os campos privados _pedidos, _carregando, _erro e _caminhoComprovanteGerado do tipo String?. Defina os getters públicos correspondentes. Implemente o método carregarPedidos(String usuarioId): antes de fazer a busca no banco, verifique com CacheStrategy.cacheEstaValido('pedidos_$usuarioId') se o cache é válido — se for e _pedidos não estiver vazio, retorne imediatamente sem recarregar. Se o cache estiver inválido ou _pedidos estiver vazio, execute o carregamento completo e ao final registre a atualização do cache com registrarAtualizacao. Implemente registrarPedido(Pedido pedido) que salva o pedido no repositório e, em seguida, recarrega a lista de pedidos. Implemente gerarComprovante(Pedido pedido) que chama ComprovanteService e armazena o caminho retornado em _caminhoComprovanteGerado, notificando os listeners.
A interface. Implemente a tela TelaPedidos que, no initState, chama carregarPedidos('usuario-demo') via addPostFrameCallback. Exiba os pedidos em uma ListView. Cada pedido deve ser um ExpansionTile que, quando expandido, exibe os itens do pedido. No trailing de cada ExpansionTile, inclua um TextButton com o texto 'Gerar comprovante' que chama gerarComprovante(pedido) via context.read<PedidoNotifier>(). Após a geração, quando caminhoComprovanteGerado não for nulo, exiba um SnackBar com a mensagem 'Comprovante salvo em: ${caminho}'. Para popular o banco com dados de teste, adicione um FloatingActionButton que cria e salva um pedido de demonstração com dois ou três itens, usando registrarPedido.
A parte mais sutil deste exercício é a transação em salvarPedido. Uma transação garante que a inserção do pedido e a inserção de todos os seus itens aconteçam de forma atômica: ou tudo é salvo, ou nada é salvo. Sem a transação, um falha no meio da operação poderia resultar em um pedido salvo sem itens — um estado inconsistente no banco de dados. Pense no seguinte cenário: o banco é salvo no disco com os dados do pedido, mas o processo é interrompido antes de os itens serem inseridos. Sem transação, esses dados corrompidos seriam permanentes. Com a transação, o banco automaticamente faz rollback e o estado anterior é preservado. Como você testaria esse comportamento de rollback em um ambiente de desenvolvimento?
A estratégia de cache com SharedPreferences que você implementou é uma versão simplificada do padrão Stale-While-Revalidate que o material descreve. Em uma aplicação de produção, o limiar de validade do cache dependeria da frequência de atualização dos dados: um cardápio de restaurante muda raramente, então um cache de horas ou até dias poderia ser adequado. Já o status de um pedido em andamento muda a cada poucos minutos, então o cache deveria ser muito curto ou inexistente. Pense em como você adaptaria a CacheStrategy para aceitar um parâmetro de duração máxima em vez de um valor fixo de 5 minutos.
Ao implementar a junção entre pedidos e itens em listarPedidosDoUsuario, você fará uma consulta por pedido para buscar os itens correspondentes. Isso é chamado de problema N+1: para N pedidos, você faz N+1 consultas ao banco (uma para listar os pedidos, mais uma para os itens de cada pedido). Para poucos pedidos, isso é aceitável. Para grandes volumes, você utilizaria um rawQuery com JOIN entre as tabelas, retornando tudo em uma única consulta. Implemente primeiro a versão N+1 para garantir que o comportamento está correto e, se o tempo permitir, refatore para a versão com JOIN e compare os resultados.
O que deve ser entregue: um arquivo chamado g_ex3.dart, onde g é o nome do seu grupo.
Se você concluiu os três exercícios com sucesso, reflita sobre a coerência arquitetural do que você construiu. Nos três exercícios, o padrão é o mesmo: a camada de domínio define contratos (IProdutoRepository, IPedidoRepository), a infraestrutura implementa esses contratos (SqfliteProdutoRepository, SqflitePedidoRepository, ComprovanteService), e o GetIt faz a composição. O ChangeNotifier orquestra os casos de uso sem conhecer os detalhes de implementação da persistência. No Módulo 09, você adicionará os repositórios remotos — que consomem APIs REST — seguindo exatamente o mesmo padrão. A única mudança será a implementação concreta registrada no GetIt; o restante do código permanecerá idêntico.