556 lines
20 KiB
Dart
556 lines
20 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:intl/intl.dart';
|
|
import 'package:kzeduca_app/services/transaction_list_notifier.dart';
|
|
import 'package:kzeduca_app/models/transaction.dart';
|
|
import 'package:fl_chart/fl_chart.dart';
|
|
import 'package:csv/csv.dart';
|
|
import 'package:pdf/widgets.dart' as pw;
|
|
import 'package:printing/printing.dart';
|
|
import 'dart:typed_data';
|
|
import 'package:kzeduca_app/services/i18n_service.dart';
|
|
|
|
// Definimos uma paleta de cores para o design futurista e luxuoso.
|
|
const Color kPrimaryDark = Color(0xFF0A0A1A);
|
|
const Color kSecondaryDark = Color(0xFF16162E);
|
|
const Color kAccentColor = Color(0xFF8B5CF6);
|
|
const Color kTextPrimary = Color.fromARGB(255, 24, 52, 65);
|
|
const Color kTextSecondary = Color.fromARGB(255, 18, 49, 37);
|
|
const Color kIncomeColor = Color(0xFF10B981);
|
|
const Color kExpenseColor = Color(0xFFEF4444);
|
|
|
|
// A tela principal com um design aprimorado.
|
|
class ReceiveHistoryScreen extends StatefulWidget {
|
|
const ReceiveHistoryScreen({super.key});
|
|
|
|
@override
|
|
State<ReceiveHistoryScreen> createState() => _ReceiveHistoryScreenState();
|
|
}
|
|
|
|
class _ReceiveHistoryScreenState extends State<ReceiveHistoryScreen> {
|
|
String _search = '';
|
|
String? _selectedCategory;
|
|
String? _selectedType;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final i18n = Provider.of<I18nService>(context, listen: false);
|
|
return Scaffold(
|
|
appBar: _buildAppBar(),
|
|
body: Container(
|
|
decoration: const BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: [kPrimaryDark, Color(0xFF16162E)],
|
|
),
|
|
),
|
|
child: Column(
|
|
children: [
|
|
_TransactionFilterBar(
|
|
search: _search,
|
|
selectedCategory: _selectedCategory,
|
|
selectedType: _selectedType,
|
|
onSearch: (v) => setState(() => _search = v),
|
|
onCategory: (v) => setState(() => _selectedCategory = v),
|
|
onType: (v) => setState(() => _selectedType = v),
|
|
),
|
|
Expanded(
|
|
child: Consumer<TransactionListNotifier>(
|
|
builder: (context, txnList, _) {
|
|
final transactions = _filterTransactions(txnList.transactions);
|
|
if (transactions.isEmpty) {
|
|
return Center(
|
|
child: Text(
|
|
'Nenhuma transação encontrada.',
|
|
style: TextStyle(color: kTextSecondary.withOpacity(0.7), fontSize: 18),
|
|
),
|
|
);
|
|
}
|
|
// Agrupar por data
|
|
final Map<String, List<Transaction>> grouped = {};
|
|
for (final t in transactions) {
|
|
final dateKey = DateFormat('dd MMM yyyy', 'pt_BR').format(t.date);
|
|
grouped.putIfAbsent(dateKey, () => []).add(t);
|
|
}
|
|
final sortedKeys = grouped.keys.toList()..sort((a, b) => DateFormat('dd MMM yyyy', 'pt_BR').parse(b).compareTo(DateFormat('dd MMM yyyy', 'pt_BR').parse(a)));
|
|
return ListView(
|
|
padding: const EdgeInsets.all(16),
|
|
children: [
|
|
for (final date in sortedKeys) ...[
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 12.0),
|
|
child: Text(
|
|
date,
|
|
style: const TextStyle(color: kTextPrimary, fontSize: 18, fontWeight: FontWeight.bold),
|
|
),
|
|
),
|
|
...grouped[date]!.map((t) => _TransactionTile(transaction: t)).toList(),
|
|
]
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
AppBar _buildAppBar() {
|
|
final i18n = Provider.of<I18nService>(context, listen: false);
|
|
return AppBar(
|
|
title: const Text(
|
|
'Histórico',
|
|
style: TextStyle(
|
|
color: kTextPrimary,
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 24,
|
|
),
|
|
),
|
|
centerTitle: false,
|
|
backgroundColor: Colors.transparent,
|
|
elevation: 0,
|
|
actions: [
|
|
IconButton(
|
|
icon: const Icon(Icons.show_chart_rounded, color: kTextPrimary),
|
|
tooltip: 'Ver Gráficos',
|
|
onPressed: () {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
backgroundColor: Colors.transparent,
|
|
isScrollControlled: true,
|
|
builder: (_) => DraggableScrollableSheet(
|
|
initialChildSize: 0.8,
|
|
minChildSize: 0.5,
|
|
maxChildSize: 0.95,
|
|
builder: (_, controller) => Container(
|
|
decoration: const BoxDecoration(
|
|
color: kSecondaryDark,
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
|
),
|
|
child: SingleChildScrollView(
|
|
controller: controller,
|
|
child: _TransactionCharts(
|
|
search: _search,
|
|
category: _selectedCategory,
|
|
type: _selectedType,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.download_rounded, color: kTextPrimary),
|
|
tooltip: 'Exportar CSV',
|
|
onPressed: () async {
|
|
final txnList = Provider.of<TransactionListNotifier>(context, listen: false);
|
|
final filtered = _filterTransactions(txnList.transactions);
|
|
final csvData = [
|
|
['Título', 'Valor', 'Tipo', 'Categoria', 'Data', 'Nota'],
|
|
...filtered.map((t) => [
|
|
t.title,
|
|
t.amount.toStringAsFixed(2),
|
|
t.type.name,
|
|
t.category.name,
|
|
DateFormat('dd/MM/yyyy HH:mm').format(t.date),
|
|
t.note ?? '',
|
|
])
|
|
];
|
|
final csv = const ListToCsvConverter().convert(csvData);
|
|
await Printing.sharePdf(bytes: Uint8List.fromList(csv.codeUnits), filename: 'transacoes.csv');
|
|
},
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.picture_as_pdf_rounded, color: kTextPrimary),
|
|
tooltip: 'Exportar PDF',
|
|
onPressed: () async {
|
|
final txnList = Provider.of<TransactionListNotifier>(context, listen: false);
|
|
final filtered = _filterTransactions(txnList.transactions);
|
|
final pdf = pw.Document();
|
|
pdf.addPage(
|
|
pw.Page(
|
|
build: (pw.Context context) {
|
|
return pw.Table.fromTextArray(
|
|
headers: ['Título', 'Valor', 'Tipo', 'Categoria', 'Data', 'Nota'],
|
|
data: filtered.map((t) => [
|
|
t.title,
|
|
t.amount.toStringAsFixed(2),
|
|
t.type.name,
|
|
t.category.name,
|
|
DateFormat('dd/MM/yyyy HH:mm').format(t.date),
|
|
t.note ?? '',
|
|
]).toList(),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
await Printing.layoutPdf(onLayout: (format) async => pdf.save());
|
|
},
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
List<Transaction> _filterTransactions(List<Transaction> txns) {
|
|
return txns.where((t) {
|
|
final matchesSearch = _search.isEmpty ||
|
|
t.title.toLowerCase().contains(_search.toLowerCase()) ||
|
|
(t.note ?? '').toLowerCase().contains(_search.toLowerCase());
|
|
final matchesCategory = _selectedCategory == null || t.category.name == _selectedCategory;
|
|
final matchesType = _selectedType == null || t.type.name == _selectedType;
|
|
return matchesSearch && matchesCategory && matchesType;
|
|
}).toList();
|
|
}
|
|
}
|
|
|
|
// Uma barra de filtro mais elegante e moderna.
|
|
class _TransactionFilterBar extends StatelessWidget {
|
|
final String search;
|
|
final String? selectedCategory;
|
|
final String? selectedType;
|
|
final ValueChanged<String> onSearch;
|
|
final ValueChanged<String?> onCategory;
|
|
final ValueChanged<String?> onType;
|
|
|
|
const _TransactionFilterBar({
|
|
Key? key,
|
|
required this.search,
|
|
required this.selectedCategory,
|
|
required this.selectedType,
|
|
required this.onSearch,
|
|
required this.onCategory,
|
|
required this.onType,
|
|
}) : super(key: key);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
child: Column(
|
|
children: [
|
|
_StyledTextField(
|
|
hintText: 'Buscar transações...',
|
|
onChanged: onSearch,
|
|
),
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
|
children: [
|
|
_StyledDropdown(
|
|
value: selectedCategory,
|
|
hintText: 'Categoria',
|
|
items: const ['food', 'transport', 'social', 'education', 'medical', 'shopping', 'others', 'salary', 'invest', 'business'],
|
|
onChanged: onCategory,
|
|
),
|
|
_StyledDropdown(
|
|
value: selectedType,
|
|
hintText: 'Tipo',
|
|
items: const ['income', 'expense'],
|
|
onChanged: onType,
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// Estilização para o campo de busca.
|
|
class _StyledTextField extends StatelessWidget {
|
|
final String hintText;
|
|
final ValueChanged<String> onChanged;
|
|
|
|
const _StyledTextField({required this.hintText, required this.onChanged});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color: kSecondaryDark.withOpacity(0.5),
|
|
borderRadius: BorderRadius.circular(16),
|
|
border: Border.all(color: kAccentColor.withOpacity(0.2)),
|
|
),
|
|
child: TextField(
|
|
decoration: InputDecoration(
|
|
hintText: hintText,
|
|
prefixIcon: const Icon(Icons.search, color: kTextSecondary),
|
|
border: InputBorder.none,
|
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
|
hintStyle: const TextStyle(color: kTextSecondary),
|
|
),
|
|
style: const TextStyle(color: kTextPrimary),
|
|
onChanged: onChanged,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// Estilização para os dropdowns de filtro.
|
|
class _StyledDropdown extends StatelessWidget {
|
|
final String? value;
|
|
final String hintText;
|
|
final List<String> items;
|
|
final ValueChanged<String?> onChanged;
|
|
|
|
const _StyledDropdown({
|
|
required this.value,
|
|
required this.hintText,
|
|
required this.items,
|
|
required this.onChanged,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Expanded(
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
decoration: BoxDecoration(
|
|
color: kSecondaryDark.withOpacity(0.5),
|
|
borderRadius: BorderRadius.circular(16),
|
|
border: Border.all(color: kAccentColor.withOpacity(0.2)),
|
|
),
|
|
child: DropdownButtonHideUnderline(
|
|
child: DropdownButton<String>(
|
|
value: value,
|
|
isExpanded: true,
|
|
dropdownColor: kSecondaryDark,
|
|
hint: Text(hintText, style: const TextStyle(color: kTextSecondary)),
|
|
items: [null, ...items]
|
|
.map((item) => DropdownMenuItem<String>(
|
|
value: item,
|
|
child: Text(
|
|
item == null ? 'Todos' : item,
|
|
style: const TextStyle(color: kTextPrimary),
|
|
),
|
|
))
|
|
.toList(),
|
|
onChanged: onChanged,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// Design para cada item da transação na lista.
|
|
class _TransactionTile extends StatelessWidget {
|
|
final Transaction transaction;
|
|
const _TransactionTile({required this.transaction});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final isIncome = transaction.type == TransactionType.income;
|
|
final color = isIncome ? kIncomeColor : kExpenseColor;
|
|
final sign = isIncome ? '+' : '-';
|
|
final formattedAmount = NumberFormat.currency(locale: 'pt_BR', symbol: 'Kz', decimalDigits: 2).format(transaction.amount);
|
|
final formattedTime = DateFormat('HH:mm').format(transaction.date);
|
|
|
|
return Container(
|
|
margin: const EdgeInsets.symmetric(vertical: 8),
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: kSecondaryDark,
|
|
borderRadius: BorderRadius.circular(20),
|
|
border: Border.all(color: color.withOpacity(0.4), width: 1.5),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: color.withOpacity(0.1),
|
|
blurRadius: 10,
|
|
offset: const Offset(0, 5),
|
|
),
|
|
],
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: color.withOpacity(0.2),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Icon(
|
|
isIncome ? Icons.arrow_downward_rounded : Icons.arrow_upward_rounded,
|
|
color: color,
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
transaction.title,
|
|
style: const TextStyle(color: kTextPrimary, fontWeight: FontWeight.bold, fontSize: 18),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
transaction.category.name,
|
|
style: const TextStyle(color: kTextSecondary),
|
|
),
|
|
if (transaction.note != null && transaction.note!.isNotEmpty) ...[
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'Obs: ${transaction.note}',
|
|
style: const TextStyle(color: kTextSecondary, fontSize: 12),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [
|
|
Text(
|
|
'$sign$formattedAmount',
|
|
style: TextStyle(color: color, fontWeight: FontWeight.bold, fontSize: 18),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
formattedTime,
|
|
style: const TextStyle(color: kTextSecondary, fontSize: 12),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// Estilização para os gráficos no bottom sheet.
|
|
class _TransactionCharts extends StatelessWidget {
|
|
final String search;
|
|
final String? category;
|
|
final String? type;
|
|
|
|
const _TransactionCharts({this.search = '', this.category, this.type});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final txnList = Provider.of<TransactionListNotifier>(context, listen: false);
|
|
final transactions = _filterTransactions(txnList.transactions);
|
|
|
|
// Agrupa por categoria
|
|
final Map<String, double> byCategory = {};
|
|
for (final t in transactions) {
|
|
final cat = t.category.name;
|
|
byCategory[cat] = (byCategory[cat] ?? 0) + t.amount * (t.type == TransactionType.expense ? -1 : 1);
|
|
}
|
|
final List<PieChartSectionData> sections = byCategory.entries.map((e) {
|
|
final isNegative = e.value < 0;
|
|
final color = isNegative ? kExpenseColor : kIncomeColor;
|
|
return PieChartSectionData(
|
|
color: color,
|
|
value: e.value.abs(),
|
|
title: '${e.key}\n${e.value.toStringAsFixed(2)}',
|
|
radius: 60,
|
|
titleStyle: const TextStyle(fontSize: 12, color: kTextPrimary, fontWeight: FontWeight.bold),
|
|
);
|
|
}).toList();
|
|
|
|
// Evolução do saldo
|
|
final sorted = List.of(transactions)..sort((a, b) => a.date.compareTo(b.date));
|
|
double running = 0;
|
|
final List<FlSpot> saldoSpots = [];
|
|
for (int i = 0; i < sorted.length; i++) {
|
|
running += sorted[i].type == TransactionType.income ? sorted[i].amount : -sorted[i].amount;
|
|
saldoSpots.add(FlSpot(i.toDouble(), running));
|
|
}
|
|
return Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Center(
|
|
child: Container(
|
|
height: 5,
|
|
width: 40,
|
|
decoration: BoxDecoration(
|
|
color: kTextSecondary.withOpacity(0.5),
|
|
borderRadius: BorderRadius.circular(20),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 24),
|
|
const Text(
|
|
'Distribuição por Categoria',
|
|
style: TextStyle(color: kTextPrimary, fontWeight: FontWeight.bold, fontSize: 20),
|
|
),
|
|
const SizedBox(height: 16),
|
|
SizedBox(
|
|
height: 250,
|
|
child: PieChart(
|
|
PieChartData(
|
|
sections: sections,
|
|
centerSpaceRadius: 50,
|
|
sectionsSpace: 4,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 32),
|
|
const Text(
|
|
'Evolução do Saldo',
|
|
style: TextStyle(color: kTextPrimary, fontWeight: FontWeight.bold, fontSize: 20),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Container(
|
|
height: 200,
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: kSecondaryDark,
|
|
borderRadius: BorderRadius.circular(20),
|
|
border: Border.all(color: kAccentColor.withOpacity(0.2)),
|
|
),
|
|
child: LineChart(
|
|
LineChartData(
|
|
lineBarsData: [
|
|
LineChartBarData(
|
|
spots: saldoSpots,
|
|
isCurved: true,
|
|
color: kAccentColor,
|
|
barWidth: 4,
|
|
isStrokeCapRound: true,
|
|
dotData: FlDotData(show: false),
|
|
),
|
|
],
|
|
titlesData: FlTitlesData(show: false),
|
|
gridData: FlGridData(
|
|
show: true,
|
|
drawVerticalLine: false,
|
|
getDrawingHorizontalLine: (value) {
|
|
return FlLine(color: kTextSecondary.withOpacity(0.2), strokeWidth: 1);
|
|
},
|
|
),
|
|
borderData: FlBorderData(
|
|
show: true,
|
|
border: Border.all(color: kTextSecondary.withOpacity(0.2)),
|
|
),
|
|
minX: 0,
|
|
maxX: saldoSpots.isNotEmpty ? saldoSpots.length.toDouble() - 1 : 0,
|
|
minY: saldoSpots.isNotEmpty ? saldoSpots.map((e) => e.y).reduce((a, b) => a < b ? a : b) : 0,
|
|
maxY: saldoSpots.isNotEmpty ? saldoSpots.map((e) => e.y).reduce((a, b) => a > b ? a : b) : 0,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
List<Transaction> _filterTransactions(List<Transaction> txns) {
|
|
return txns.where((t) {
|
|
final matchesSearch = search.isEmpty ||
|
|
t.title.toLowerCase().contains(search.toLowerCase()) ||
|
|
(t.note ?? '').toLowerCase().contains(search.toLowerCase());
|
|
final matchesCategory = category == null || t.category.name == category;
|
|
final matchesType = type == null || t.type.name == type;
|
|
return matchesSearch && matchesCategory && matchesType;
|
|
}).toList();
|
|
}
|
|
}
|