kz_educa/lib/screens/receive_history_screen.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();
}
}