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.