Construindo um App Financeiro com Flutter 3.41: Múltiplas Contas, Orçamentos e Boletos Avançados (Parte 3)
Nas etapas anteriores, criamos o Dashboard de transações, o motor de operações com o leitor de código de barras e os gráficos de extrato. Agora, vamos elevar o nível arquitetural do nosso aplicativo para lidar com a realidade financeira complexa: o usuário não tem o dinheiro em um só lugar. Ele possui múltiplas contas (bancos, carteiras, dinheiro físico), precisa de orçamentos rígidos (Budgets) para não estourar os gastos e lida com diferentes formatos de faturas.
🏦 1. Modelagem de Dados: Minhas Contas (Bancos/Carteiras)
Para que o usuário saiba exatamente onde está o seu dinheiro, precisamos de uma tabela dedicada no Isar para as instituições financeiras.
Crie lib/features/accounts/models/bank_account.dart:
import 'package:isar/isar.dart';
part 'bank_account.g.dart';
@collection
class BankAccount {
Id id = Isar.autoIncrement;
@Index(type: IndexType.value)
late String name; // Ex: Nubank, Itaú, Dinheiro Físico
late double initialBalance; // Saldo base informado no momento da criação
late String logoPath; // Caminho para a imagem/ícone do banco
late int themeColor; // Cor hexadecimal para a UI (Material 3)
}
🔗 2. Atualização da Transação (Relacionamento de Contas)
Uma transação financeira deve sair ou entrar em uma conta específica. Precisamos atualizar o modelo que criamos na Parte 1.
Atualize transaction.dart:
@collection
class Transaction {
Id id = Isar.autoIncrement;
// ... campos antigos (title, amount, isIncome, etc)
// O Relacionamento com a Categoria (feito na Parte 2)
final category = IsarLink<Category>();
// NOVO: Relacionamento com a Conta Bancária
final bankAccount = IsarLink<BankAccount>();
}
(Lembre-se de rodar dart run build_runner build após alterar o schema).
💳 3. Tela de Cadastro de Bancos e Carteiras
A tela para criar uma nova conta exige o nome, a cor de destaque e o saldo inicial, que servirá como ponto de partida para os cálculos matemáticos do Riverpod.
Arquivo: lib/features/accounts/bank_form_screen.dart:
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class BankFormScreen extends ConsumerStatefulWidget {
const BankFormScreen({super.key});
@override
ConsumerState<BankFormScreen> createState() => _BankFormScreenState();
}
class _BankFormScreenState extends ConsumerState<BankFormScreen> {
final _nameController = TextEditingController();
final _balanceController = TextEditingController();
int _selectedColor = Colors.purple.value; // Padrão
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Adicionar Conta')),
body: ListView(
padding: const EdgeInsets.all(24),
children: [
TextField(controller: _nameController, decoration: const InputDecoration(labelText: 'Nome da Instituição', border: OutlineInputBorder())),
const SizedBox(height: 16),
TextField(controller: _balanceController, keyboardType: TextInputType.number, decoration: const InputDecoration(labelText: 'Saldo Inicial (R$)', border: OutlineInputBorder(), prefixIcon: Icon(Icons.account_balance_wallet))),
const SizedBox(height: 24),
// Exemplo simplificado de seletor de cores
Wrap(
spacing: 8,
children: [Colors.purple, Colors.orange, Colors.red, Colors.blue].map((color) {
return ChoiceChip(
label: const Text(''),
selected: _selectedColor == color.value,
backgroundColor: color.withOpacity(0.5),
selectedColor: color,
onSelected: (val) => setState(() => _selectedColor = color.value),
);
}).toList(),
),
const SizedBox(height: 32),
FilledButton(
onPressed: () => _saveBank(),
child: const Text('SALVAR CONTA'),
)
],
),
);
}
Future<void> _saveBank() async {
final isar = await ref.read(isarProvider).db;
final bank = BankAccount()
..name = _nameController.text
..initialBalance = double.tryParse(_balanceController.text) ?? 0.0
..logoPath = ''
..themeColor = _selectedColor;
await isar.writeTxn(() async {
await isar.bankAccounts.put(bank);
});
ref.invalidate(accountsProvider); // Atualiza os saldos consolidados
if (mounted) Navigator.pop(context);
}
}
📊 4. Aba “Minhas Contas” e Lógica de Saldo Consolidado
A segunda aba do nosso NavigationBar precisa exibir os Cards de cada banco e calcular dinamicamente quanto o usuário tem lá agora. O saldo atual é igual ao Saldo Inicial + Entradas – Saídas vinculadas àquela conta.
Provedor (finance_providers.dart):
final accountsBalanceProvider = FutureProvider<List<Map<String, dynamic>>>((ref) async {
final isar = await ref.read(isarProvider).db;
final banks = await isar.bankAccounts.where().findAll();
List<Map<String, dynamic>> consolidated = [];
for (var bank in banks) {
// Busca transações apenas desta conta que já foram pagas/efetivadas
final transactions = await isar.transactions.filter()
.bankAccount((q) => q.idEqualTo(bank.id))
.isPaidEqualTo(true)
.findAll();
double currentBalance = bank.initialBalance;
for (var t in transactions) {
currentBalance += t.isIncome ? t.amount : -t.amount;
}
consolidated.add({'bank': bank, 'balance': currentBalance});
}
return consolidated;
});
🎯 5. Modelagem de Metas e Orçamentos (Budgets)
Um sistema financeiro profissional ajuda o usuário a não gastar demais. Criaremos a tabela de Orçamentos, que limita o gasto em uma Categoria específica para um determinado mês e ano.
Crie lib/features/budgets/models/budget.dart:
import 'package:isar/isar.dart';
import '../../transactions/models/category.dart';
part 'budget.g.dart';
@collection
class Budget {
Id id = Isar.autoIncrement;
late double limitAmount; // Limite que o usuário não quer ultrapassar
late int month;
late int year;
// Um orçamento pertence a uma categoria específica (ex: 500 reais para Delivery)
final category = IsarLink<Category>();
}
📈 6. Interface do Sistema de Metas (Barras de Progresso M3)
A interface de metas deve usar a cor primária se o gasto estiver saudável, e alertar com vermelho (errorColor) se estiver próximo de estourar.
Componente Visual:
Widget _buildBudgetCard(BuildContext context, Budget budget, double spentAmount) {
final double percentage = (spentAmount / budget.limitAmount).clamp(0.0, 1.0);
final bool isOverBudget = percentage > 0.9; // Alerta se passar de 90%
return Card(
elevation: 0,
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(budget.category.value?.name ?? 'Categoria', style: const TextStyle(fontWeight: FontWeight.bold)),
Text('R\$ $spentAmount / R\$ ${budget.limitAmount}'),
],
),
const SizedBox(height: 12),
LinearProgressIndicator(
value: percentage,
minHeight: 8,
borderRadius: BorderRadius.circular(4),
color: isOverBudget ? Theme.of(context).colorScheme.error : Theme.of(context).colorScheme.primary,
backgroundColor: Theme.of(context).colorScheme.surfaceVariant,
),
],
),
),
);
}
🧾 7. Evolução dos Boletos: Modelagem de “Tipos de Boleto”
Na Parte 2, tínhamos um leitor genérico. Agora, permitiremos que o usuário cadastre Tipos de Boleto (Ex: “Conta de Energia – Enel”, “Fatura Cartão Nubank”), podendo atrelar imagens/logos da fatura original e tabelas de dados complementares (ex: Código do Cliente).
Crie lib/features/bills/models/bill_type.dart:
import 'package:isar/isar.dart';
part 'bill_type.g.dart';
@collection
class BillType {
Id id = Isar.autoIncrement;
@Index(type: IndexType.value)
late String name; // Ex: Conta de Luz
late String logoPath; // Foto da logo salva localmente
late String defaultBarcodePrefix; // Prefixo que ajuda a identificar automaticamente pelo Scanner
// Tabela de Dados extras no formato JSON string (ex: {"Instalação": "12345", "Contrato": "XYZ"})
late String customMetadataJson;
}
Como nem toda transação é um “Boleto” (por exemplo, comprar um lanche na padaria é uma despesa, mas não tem um código de barras ou um “Tipo de Boleto” atrelado), esse relacionamento precisa ser flexível. No Isar, o IsarLink por natureza já permite que o valor seja vazio (nulo) caso você não vincule nada a ele ao salvar.
O Modelo Completo (transaction.dart)
Atualize o seu arquivo lib/features/transactions/models/transaction.dart para ficar assim:
import 'package:isar/isar.dart';
// Importes dos modelos que se relacionam com a Transação
import 'category.dart';
import '../../accounts/models/bank_account.dart';
import '../../bills/models/bill_type.dart'; // O import do nosso novo Tipo de Boleto
part 'transaction.g.dart'; // Arquivo que será gerado pelo build_runner
@collection
class Transaction {
Id id = Isar.autoIncrement;
@Index(type: IndexType.value)
late String title;
late double amount;
@Index()
late bool isIncome; // true para Ganho, false para Gasto
@Index()
late DateTime dueDate; // Data de vencimento ou da compra
@Index()
late bool isPaid; // Se já foi efetivamente pago
// ==========================================
// RELACIONAMENTOS (CHAVES ESTRANGEIRAS)
// ==========================================
// 1. Categoria (Parte 2) - Ex: Alimentação, Transporte
final category = IsarLink<Category>();
// 2. Conta Bancária (Parte 3) - Ex: Nubank, Itaú, Carteira
final bankAccount = IsarLink<BankAccount>();
// 3. Tipo de Boleto (Parte 3) - NOVO E "OPCIONAL"
// Só será preenchido se o usuário estiver pagando uma conta cadastrada (Ex: Conta de Luz da Enel)
final billType = IsarLink<BillType>();
}
💡 Explicação Técnica do Comportamento (Under the Hood)
-
Por que chamamos de “Opcional”? Diferente do SQLite tradicional onde você teria que criar uma coluna
bill_type_idpodendo serNULL, o Isar gerencia isso de forma mais limpa. Se, ao salvar a transação, você não fizertransaction.billType.value = algumBoleto, o Isar simplesmente não cria um link para esta transação específica. Ela existirá normalmente sem estar atrelada a um Boleto. -
Como salvar na prática? (Tela de Nova Despesa) Quando o usuário for cadastrar uma Despesa comum (ex: “Cinema”), o seu código de salvamento ignora o
billType:
await isar.writeTxn(() async {
await isar.transactions.put(transaction);
await transaction.category.save();
await transaction.bankAccount.save();
// Ignoramos o billType.save() aqui.
});
Mas, se o usuário escanear um código de barras e o app detectar que é uma conta de luz:
transaction.billType.value = luzBillType; // Vincula o objeto BillType
await isar.writeTxn(() async {
await isar.transactions.put(transaction);
await transaction.category.save();
await transaction.bankAccount.save();
await transaction.billType.save(); // Salva o relacionamento!
});
(Lembre-se sempre de rodar o comando dart run build_runner build no terminal toda vez que adicionar um novo IsarLink para que o Isar gere o código por trás dos panos no arquivo .g.dart).
🖼️ 8. Tela de Cadastro de Tipos de Boleto (Com Imagens)
Para armazenar imagens, usamos o pacote image_picker e salvamos a imagem no diretório seguro do aplicativo usando path_provider.
Trecho de Lógica (bill_type_form_screen.dart):
import 'dart:io';
import 'package:image_picker/image_picker.dart';
import 'package:path_provider/path_provider.dart';
File? _selectedLogo;
Future<void> _pickImage() async {
final ImagePicker picker = ImagePicker();
final XFile? image = await picker.pickImage(source: ImageSource.gallery);
if (image != null) {
// Move o arquivo da galeria para os documentos do App
final dir = await getApplicationDocumentsDirectory();
final savedImage = await File(image.path).copy('${dir.path}/bill_logos/${DateTime.now().millisecondsSinceEpoch}.png');
setState(() => _selectedLogo = savedImage);
}
}
🔄 9. Refatoração: Integrando Contas e Boletos nas Operações
As atualizações acima mudam a forma como a tela Nova Despesa ou o Scanner de Código de Barras (criados na Parte 2) se comportam.
Quando o usuário escaneia ou digita uma nova conta, o formulário (TransactionFormScreen) agora exige duas novas seleções obrigatórias:
-
De qual Conta Bancária este boleto será pago? (Dropdown lendo a tabela
BankAccount). -
Qual é o Tipo de Boleto? (Opcional, lendo a tabela
BillType). Se o código de barras lido pelo Scanner na Parte 2 começar com odefaultBarcodePrefixde umBillTypesalvo, o app já pré-seleciona a logo da empresa na tela!
Atualização no Botão de Salvar da TransactionFormScreen:
// ...
transaction.category.value = _selectedCategory;
transaction.bankAccount.value = _selectedBank; // NOVO: Relaciona a conta
transaction.billType.value = _selectedBillType; // NOVO: Relaciona o tipo de boleto (opcional)
await isar.writeTxn(() async {
await isar.transactions.put(transaction);
await transaction.category.save();
await transaction.bankAccount.save(); // Salva os links
await transaction.billType.save();
});
// Ao salvar, invalida os provedores de saldo (Passo 4) e do mês.
ref.invalidate(accountsBalanceProvider);
ref.invalidate(currentMonthTransactionsProvider);
Resumo e Próximos Passos
Nesta Parte 3, transformamos um simples registrador de transações em uma verdadeira plataforma financeira:
-
Implementamos o Multi-Contas, consolidando saldos inteligentemente.
-
Criamos o sistema de Metas e Orçamentos, permitindo uma saúde financeira visual usando os componentes de progresso do M3.
-
Modularizamos os Tipos de Boleto, introduzindo armazenamento seguro de imagens locais (
image_picker+ diretórios de sistema).