Construindo um App Financeiro com Flutter 3.41: Operações, Gráficos e Leitor de Boletos (Parte 2)

Para suportar essa complexidade com altíssima performance, aprofundaremos o uso do Riverpod para reatividade e implementaremos relacionamentos no Isar Community.

🗄️ 1. Evolução do Isar: Relacionamentos (Links) e Categorias

Para organizar as finanças, cada transação deve pertencer a uma Categoria (ex: Alimentação, Transporte, Salário). No Isar, lidamos com isso através de IsarLink.

Atualização dos Modelos (lib/features/transactions/models/):

Primeiro, crie o modelo Category:

import 'package:isar/isar.dart';

part 'category.g.dart';

@collection
class Category {
  Id id = Isar.autoIncrement;

  @Index(type: IndexType.value)
  late String name;
  
  late String iconData; // Armazenaremos o código do ícone
  late int colorHex;    // Cor representativa no M3
  late bool isIncome;   // Define se é categoria de Ganhos ou Gastos
}

Agora, atualize a Transaction para incluir o relacionamento:

@collection
class Transaction {
  Id id = Isar.autoIncrement;
  late String title;
  late double amount;
  late bool isIncome;
  late DateTime dueDate;
  late bool isPaid;

  // Relacionamento (Chave Estrangeira do Isar)
  final category = IsarLink<Category>(); 
}

(Execute dart run build_runner build para gerar os novos schemas).

🎛️ 2. Tela de Operações (O Hub de Ações)

A aba de “Operações” (a quarta aba do nosso menu inferior) servirá como um painel de controle rápido para o usuário criar novas entidades ou acessar o leitor de boletos.

Arquivo: lib/features/operations/operations_screen.dart

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

class OperationsScreen extends StatelessWidget {
  const OperationsScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Operações Rápidas')),
      body: GridView.count(
        padding: const EdgeInsets.all(16),
        crossAxisCount: 2,
        mainAxisSpacing: 16,
        crossAxisSpacing: 16,
        children: [
          _buildActionCard(context, 'Nova Receita', Icons.arrow_upward, Colors.green, () => context.push('/transaction/new/income')),
          _buildActionCard(context, 'Nova Despesa', Icons.arrow_downward, Colors.red, () => context.push('/transaction/new/expense')),
          _buildActionCard(context, 'Ler Boleto / QR', Icons.qr_code_scanner, Theme.of(context).colorScheme.primary, () => context.push('/scanner')),
          _buildActionCard(context, 'Gerenciar Categorias', Icons.category, Colors.orange, () => context.push('/categories')),
        ],
      ),
    );
  }

  Widget _buildActionCard(BuildContext context, String title, IconData icon, Color color, VoidCallback onTap) {
    return Card(
      color: Theme.of(context).colorScheme.surfaceContainerHighest,
      elevation: 0,
      clipBehavior: Clip.antiAlias,
      child: InkWell(
        onTap: onTap,
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            CircleAvatar(backgroundColor: color.withOpacity(0.1), radius: 30, child: Icon(icon, color: color, size: 32)),
            const SizedBox(height: 16),
            Text(title, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
          ],
        ),
      ),
    );
  }
}

🏷️ 3. Cadastro de Categorias (CRUD)

O usuário precisa poder criar suas próprias categorias (ex: “Ifood”, “Uber”).

Lógica de Inserção (Provider/Isar):

Future<void> saveCategory(WidgetRef ref, Category category) async {
  final isar = await ref.read(isarProvider).db;
  await isar.writeTxn(() async {
    await isar.categorys.put(category);
  });
  // Invalida a lista para atualizar a UI instantaneamente
  ref.invalidate(categoriesProvider); 
}

📝 4. Cadastro e Edição Unificados (Transações)

Uma das maiores dificuldades de arquitetura é não duplicar telas. Usaremos a mesma TransactionFormScreen para Criar Receita, Criar Despesa e Editar uma transação existente.

Arquivo: lib/features/transactions/transaction_form_screen.dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

class TransactionFormScreen extends ConsumerStatefulWidget {
  final bool isIncome;
  final Transaction? transactionToEdit; // Se for nulo, é criação. Se existir, é edição.

  const TransactionFormScreen({super.key, required this.isIncome, this.transactionToEdit});

  @override
  ConsumerState<TransactionFormScreen> createState() => _TransactionFormScreenState();
}

class _TransactionFormScreenState extends ConsumerState<TransactionFormScreen> {
  late TextEditingController _titleController;
  late TextEditingController _amountController;
  late DateTime _selectedDate;
  Category? _selectedCategory;

  @override
  void initState() {
    super.initState();
    // Preenche os dados se for uma Edição
    final t = widget.transactionToEdit;
    _titleController = TextEditingController(text: t?.title ?? '');
    _amountController = TextEditingController(text: t?.amount.toString() ?? '');
    _selectedDate = t?.dueDate ?? DateTime.now();
    _selectedCategory = t?.category.value; // Carrega o relacionamento do Isar
  }

  @override
  Widget build(BuildContext context) {
    // A UI consome os controladores e Dropdown para selecionar a categoria...
    return Scaffold(
      appBar: AppBar(title: Text(widget.transactionToEdit == null ? 'Nova Operação' : 'Editar Operação')),
      body: ListView(
        padding: const EdgeInsets.all(24),
        children: [
          // Campo de Valor (R$)
          TextField(controller: _amountController, keyboardType: TextInputType.number),
          // Campo de Título
          TextField(controller: _titleController),
          // Dropdown de Categorias (filtrando por isIncome)
          // ...
          const SizedBox(height: 32),
          FilledButton(
            onPressed: () => _saveTransaction(),
            child: const Text('SALVAR'),
          )
        ],
      ),
    );
  }

  Future<void> _saveTransaction() async {
    final isar = await ref.read(isarProvider).db;
    
    final transaction = widget.transactionToEdit ?? Transaction()
      ..title = _titleController.text
      ..amount = double.parse(_amountController.text)
      ..isIncome = widget.isIncome
      ..dueDate = _selectedDate
      ..isPaid = false; // Por padrão, pendente

    // O relacionamento IsarLink precisa ser salvo dentro da transação
    transaction.category.value = _selectedCategory;

    await isar.writeTxn(() async {
      await isar.transactions.put(transaction);
      await transaction.category.save(); // Salva o link
    });

    ref.invalidate(currentMonthTransactionsProvider); // Atualiza a Home
    if (mounted) Navigator.pop(context);
  }
}

📸 5. Leitura de Código de Barras (Boletos)

A digitação manual de faturas é fonte de erros. Usaremos o pacote mobile_scanner para acessar a câmera e ler a linha digitável ou o QR Code do Pix.

Dependência: mobile_scanner: ^4.0.0

Arquivo: lib/features/scanner/barcode_scanner_screen.dart

import 'package:flutter/material.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import 'package:go_router/go_router.dart';

class BarcodeScannerScreen extends StatelessWidget {
  const BarcodeScannerScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Ler Boleto')),
      body: Stack(
        children: [
          MobileScanner(
            onDetect: (capture) {
              final List<Barcode> barcodes = capture.barcodes;
              for (final barcode in barcodes) {
                if (barcode.rawValue != null) {
                  // Ao detectar, pausa a câmera e processa o código
                  final code = barcode.rawValue!;
                  _processScannedCode(context, code);
                  break; 
                }
              }
            },
          ),
          // Uma máscara visual com um quadrado no meio para mirar o código
          _buildScannerOverlay(context),
        ],
      ),
    );
  }

  void _processScannedCode(BuildContext context, String code) {
    // Extrai o valor do código (Lógica do tópico 6)
    // Redireciona para o form de Despesa com os dados pré-preenchidos
    context.pushReplacement('/transaction/new/expense?barcode=$code');
  }
}

🧠 6. Lógica de Processamento de Boletos

A linha digitável dos boletos brasileiros (FEBRABAN) segue um padrão. Os últimos dígitos geralmente representam o valor. Criaremos um utilitário para tentar decifrar o valor.

class BarcodeParser {
  /// Tenta extrair o valor de uma linha digitável de boleto bancário (47 dígitos)
  static double? extractAmount(String barcode) {
    final cleanCode = barcode.replaceAll(RegExp(r'[^0-9]'), '');
    
    // Simplificação educacional: Os últimos 10 dígitos formam o valor (os 2 últimos são centavos)
    if (cleanCode.length >= 47) {
      final amountString = cleanCode.substring(cleanCode.length - 10);
      final amountValue = double.tryParse(amountString);
      if (amountValue != null) return amountValue / 100.0;
    }
    return null;
  }
}

🧾 7. A Aba de Extrato (Timeline)

O Extrato (primeira aba do menu inferior) lista todas as transações, históricas e futuras. Diferente da Home (que foca no mês atual), aqui o usuário pode rolar meses atrás.

Usaremos o Riverpod para buscar as transações ordenadas por data decrescente.

Trecho Principal (ExtractScreen):

// Consumindo um provedor 'allTransactionsProvider'
final extractAsync = ref.watch(allTransactionsProvider);

return extractAsync.when(
  loading: () => const CircularProgressIndicator(),
  error: (err, stack) => Text('Erro: $err'),
  data: (transactions) {
    // O ideal aqui é agrupar as transações por Mês/Dia para criar os cabeçalhos.
    return ListView.builder(
      itemCount: transactions.length,
      itemBuilder: (context, index) {
        final t = transactions[index];
        // Precisamos carregar o link da categoria sincronicamente
        t.category.loadSync(); 
        
        return ListTile(
          leading: Icon(IconData(int.parse(t.category.value!.iconData), fontFamily: 'MaterialIcons')),
          title: Text(t.title),
          subtitle: Text('${t.dueDate.day}/${t.dueDate.month}/${t.dueDate.year}'),
          trailing: Text(
            '${t.isIncome ? '+' : '-'} R\$ ${t.amount.toStringAsFixed(2)}',
            style: TextStyle(color: t.isIncome ? Colors.green : Colors.red, fontWeight: FontWeight.bold),
          ),
          onTap: () {
            // Abre para edição (Tópico 4)
            context.push('/transaction/edit', extra: t);
          },
        );
      },
    );
  },
);

📈 8. Gráficos de Evolução Financeira (fl_chart)

No topo da aba de Extrato, vamos inserir um gráfico comparativo mensal de Receitas vs Despesas. Isso traz inteligência de dados (BI) para o usuário.

Dependência: fl_chart: ^0.66.0

Arquivo: lib/features/extract/widgets/evolution_chart.dart

import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';

class EvolutionChart extends StatelessWidget {
  final List<double> monthlyIncomes;
  final List<double> monthlyExpenses;

  const EvolutionChart({super.key, required this.monthlyIncomes, required this.monthlyExpenses});

  @override
  Widget build(BuildContext context) {
    return AspectRatio(
      aspectRatio: 1.5,
      child: Card(
        elevation: 0,
        color: Theme.of(context).colorScheme.surfaceContainerHighest,
        child: Padding(
          padding: const EdgeInsets.all(16),
          child: BarChart(
            BarChartData(
              alignment: BarChartAlignment.spaceAround,
              barGroups: List.generate(
                monthlyIncomes.length, 
                (index) => BarChartGroupData(
                  x: index,
                  barRods: [
                    BarChartRodData(toY: monthlyIncomes[index], color: Colors.green, width: 12),
                    BarChartRodData(toY: monthlyExpenses[index], color: Colors.red, width: 12),
                  ],
                ),
              ),
              titlesData: FlTitlesData(
                bottomTitles: AxisTitles(
                  sideTitles: SideTitles(showTitles: true, getTitlesWidget: (value, meta) => Text('Mês ${value.toInt() + 1}')),
                ),
              ),
              borderData: FlBorderData(show: false),
            ),
          ),
        ),
      ),
    );
  }
}

🔁 9. Sincronização e Reatividade Impecável (Riverpod)

A maior vantagem desta arquitetura é a propagação do estado.

Quando o usuário está na tela de Operações, usa o Scanner e salva uma nova fatura (Despesa), nós apenas chamamos:

ref.invalidate(allTransactionsProvider);
ref.invalidate(currentMonthTransactionsProvider);

No momento em que ele clica no botão “Salvar” e a tela é fechada, o Riverpod refaz as consultas no Isar em milissegundos. Automaticamente:

  • O Card de “Gastos do Mês” na Home aumenta o valor.

  • O alerta de Vencimentos é atualizado se a fatura for para hoje.

  • O Gráfico da aba de Extrato recalcula a barra vermelha do mês.

  • A lista do extrato insere a nova linha.

Tudo isso sem usar um único setState para passar dados entre abas.

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