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)

  1. Por que chamamos de “Opcional”? Diferente do SQLite tradicional onde você teria que criar uma coluna bill_type_id podendo ser NULL, o Isar gerencia isso de forma mais limpa. Se, ao salvar a transação, você não fizer transaction.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.

  2. 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:

  1. De qual Conta Bancária este boleto será pago? (Dropdown lendo a tabela BankAccount).

  2. 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 o defaultBarcodePrefix de um BillType salvo, 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:

  1. Implementamos o Multi-Contas, consolidando saldos inteligentemente.

  2. Criamos o sistema de Metas e Orçamentos, permitindo uma saúde financeira visual usando os componentes de progresso do M3.

  3. Modularizamos os Tipos de Boleto, introduzindo armazenamento seguro de imagens locais (image_picker + diretórios de sistema).

Please follow and like us:
error0
fb-share-icon
Tweet 20
fb-share-icon20