Initial commit - app-padrao-1.0
This commit is contained in:
87
lib/features/finances/data/models/transaction_model.dart
Normal file
87
lib/features/finances/data/models/transaction_model.dart
Normal file
@@ -0,0 +1,87 @@
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
part 'transaction_model.g.dart';
|
||||
|
||||
@HiveType(typeId: 3)
|
||||
enum TransactionType {
|
||||
@HiveField(0)
|
||||
revenue, // Receita
|
||||
@HiveField(1)
|
||||
expense, // Despesa
|
||||
}
|
||||
|
||||
@HiveType(typeId: 4)
|
||||
class TransactionModel extends HiveObject {
|
||||
@HiveField(0)
|
||||
final String id;
|
||||
|
||||
@HiveField(1)
|
||||
final String userId;
|
||||
|
||||
@HiveField(2)
|
||||
final TransactionType type;
|
||||
|
||||
@HiveField(3)
|
||||
final double amount;
|
||||
|
||||
@HiveField(4)
|
||||
final String description;
|
||||
|
||||
@HiveField(5)
|
||||
final DateTime dueDate;
|
||||
|
||||
@HiveField(6)
|
||||
final DateTime? paidDate;
|
||||
|
||||
@HiveField(7)
|
||||
final bool isPaid;
|
||||
|
||||
@HiveField(8)
|
||||
final String? haircutId; // Vinculado a um corte (receita automática)
|
||||
|
||||
@HiveField(9)
|
||||
final DateTime createdAt;
|
||||
|
||||
TransactionModel({
|
||||
required this.id,
|
||||
required this.userId,
|
||||
required this.type,
|
||||
required this.amount,
|
||||
required this.description,
|
||||
required this.dueDate,
|
||||
this.paidDate,
|
||||
required this.isPaid,
|
||||
this.haircutId,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
bool get isFromHaircut => haircutId != null;
|
||||
|
||||
bool get isOverdue => !isPaid && dueDate.isBefore(DateTime.now());
|
||||
|
||||
TransactionModel copyWith({
|
||||
String? id,
|
||||
String? userId,
|
||||
TransactionType? type,
|
||||
double? amount,
|
||||
String? description,
|
||||
DateTime? dueDate,
|
||||
DateTime? paidDate,
|
||||
bool? isPaid,
|
||||
String? haircutId,
|
||||
DateTime? createdAt,
|
||||
}) {
|
||||
return TransactionModel(
|
||||
id: id ?? this.id,
|
||||
userId: userId ?? this.userId,
|
||||
type: type ?? this.type,
|
||||
amount: amount ?? this.amount,
|
||||
description: description ?? this.description,
|
||||
dueDate: dueDate ?? this.dueDate,
|
||||
paidDate: paidDate ?? this.paidDate,
|
||||
isPaid: isPaid ?? this.isPaid,
|
||||
haircutId: haircutId ?? this.haircutId,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
107
lib/features/finances/data/models/transaction_model.g.dart
Normal file
107
lib/features/finances/data/models/transaction_model.g.dart
Normal file
@@ -0,0 +1,107 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'transaction_model.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// TypeAdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
class TransactionModelAdapter extends TypeAdapter<TransactionModel> {
|
||||
@override
|
||||
final int typeId = 4;
|
||||
|
||||
@override
|
||||
TransactionModel read(BinaryReader reader) {
|
||||
final numOfFields = reader.readByte();
|
||||
final fields = <int, dynamic>{
|
||||
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||
};
|
||||
return TransactionModel(
|
||||
id: fields[0] as String,
|
||||
userId: fields[1] as String,
|
||||
type: fields[2] as TransactionType,
|
||||
amount: fields[3] as double,
|
||||
description: fields[4] as String,
|
||||
dueDate: fields[5] as DateTime,
|
||||
paidDate: fields[6] as DateTime?,
|
||||
isPaid: fields[7] as bool,
|
||||
haircutId: fields[8] as String?,
|
||||
createdAt: fields[9] as DateTime,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, TransactionModel obj) {
|
||||
writer
|
||||
..writeByte(10)
|
||||
..writeByte(0)
|
||||
..write(obj.id)
|
||||
..writeByte(1)
|
||||
..write(obj.userId)
|
||||
..writeByte(2)
|
||||
..write(obj.type)
|
||||
..writeByte(3)
|
||||
..write(obj.amount)
|
||||
..writeByte(4)
|
||||
..write(obj.description)
|
||||
..writeByte(5)
|
||||
..write(obj.dueDate)
|
||||
..writeByte(6)
|
||||
..write(obj.paidDate)
|
||||
..writeByte(7)
|
||||
..write(obj.isPaid)
|
||||
..writeByte(8)
|
||||
..write(obj.haircutId)
|
||||
..writeByte(9)
|
||||
..write(obj.createdAt);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is TransactionModelAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
|
||||
class TransactionTypeAdapter extends TypeAdapter<TransactionType> {
|
||||
@override
|
||||
final int typeId = 3;
|
||||
|
||||
@override
|
||||
TransactionType read(BinaryReader reader) {
|
||||
switch (reader.readByte()) {
|
||||
case 0:
|
||||
return TransactionType.revenue;
|
||||
case 1:
|
||||
return TransactionType.expense;
|
||||
default:
|
||||
return TransactionType.revenue;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, TransactionType obj) {
|
||||
switch (obj) {
|
||||
case TransactionType.revenue:
|
||||
writer.writeByte(0);
|
||||
break;
|
||||
case TransactionType.expense:
|
||||
writer.writeByte(1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is TransactionTypeAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import 'package:uuid/uuid.dart';
|
||||
import 'package:barber_app/core/database/database_service.dart';
|
||||
import 'package:barber_app/features/finances/data/models/transaction_model.dart';
|
||||
|
||||
class TransactionRepository {
|
||||
final _uuid = const Uuid();
|
||||
|
||||
String? get _currentUserId => DatabaseService.getCurrentUserId();
|
||||
|
||||
// Criar transação de receita a partir de um corte
|
||||
Future<TransactionModel?> createRevenueFromHaircut({
|
||||
required String haircutId,
|
||||
required double amount,
|
||||
required String clientName,
|
||||
required String serviceType,
|
||||
bool isPaid = true,
|
||||
}) async {
|
||||
if (_currentUserId == null) return null;
|
||||
|
||||
final transaction = TransactionModel(
|
||||
id: _uuid.v4(),
|
||||
userId: _currentUserId!,
|
||||
type: TransactionType.revenue,
|
||||
amount: amount,
|
||||
description: '$serviceType - $clientName',
|
||||
dueDate: DateTime.now(),
|
||||
isPaid: isPaid,
|
||||
paidDate: isPaid ? DateTime.now() : null,
|
||||
haircutId: haircutId,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
|
||||
await DatabaseService.transactionsBoxInstance.put(
|
||||
transaction.id,
|
||||
transaction,
|
||||
);
|
||||
return transaction;
|
||||
}
|
||||
|
||||
// Criar despesa
|
||||
Future<TransactionModel?> createExpense({
|
||||
required double amount,
|
||||
required String description,
|
||||
required DateTime dueDate,
|
||||
bool isPaid = false,
|
||||
}) async {
|
||||
if (_currentUserId == null) return null;
|
||||
|
||||
final transaction = TransactionModel(
|
||||
id: _uuid.v4(),
|
||||
userId: _currentUserId!,
|
||||
type: TransactionType.expense,
|
||||
amount: amount,
|
||||
description: description.trim(),
|
||||
dueDate: dueDate,
|
||||
isPaid: isPaid,
|
||||
paidDate: isPaid ? DateTime.now() : null,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
|
||||
await DatabaseService.transactionsBoxInstance.put(
|
||||
transaction.id,
|
||||
transaction,
|
||||
);
|
||||
return transaction;
|
||||
}
|
||||
|
||||
// Criar receita manual
|
||||
Future<TransactionModel?> createRevenue({
|
||||
required double amount,
|
||||
required String description,
|
||||
required DateTime dueDate,
|
||||
bool isPaid = false,
|
||||
}) async {
|
||||
if (_currentUserId == null) return null;
|
||||
|
||||
final transaction = TransactionModel(
|
||||
id: _uuid.v4(),
|
||||
userId: _currentUserId!,
|
||||
type: TransactionType.revenue,
|
||||
amount: amount,
|
||||
description: description.trim(),
|
||||
dueDate: dueDate,
|
||||
isPaid: isPaid,
|
||||
paidDate: isPaid ? DateTime.now() : null,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
|
||||
await DatabaseService.transactionsBoxInstance.put(
|
||||
transaction.id,
|
||||
transaction,
|
||||
);
|
||||
return transaction;
|
||||
}
|
||||
|
||||
// Listar todas as transações
|
||||
List<TransactionModel> getAllTransactions() {
|
||||
if (_currentUserId == null) return [];
|
||||
return DatabaseService.transactionsBoxInstance.values
|
||||
.where((t) => t.userId == _currentUserId)
|
||||
.toList()
|
||||
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||
}
|
||||
|
||||
// Receitas
|
||||
List<TransactionModel> getRevenues() {
|
||||
return getAllTransactions()
|
||||
.where((t) => t.type == TransactionType.revenue)
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Despesas
|
||||
List<TransactionModel> getExpenses() {
|
||||
return getAllTransactions()
|
||||
.where((t) => t.type == TransactionType.expense)
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Pendentes a receber
|
||||
double getPendingReceivables() {
|
||||
return getRevenues()
|
||||
.where((t) => !t.isPaid)
|
||||
.fold(0.0, (sum, t) => sum + t.amount);
|
||||
}
|
||||
|
||||
// Pendentes a pagar
|
||||
double getPendingPayables() {
|
||||
return getExpenses()
|
||||
.where((t) => !t.isPaid)
|
||||
.fold(0.0, (sum, t) => sum + t.amount);
|
||||
}
|
||||
|
||||
// Total recebido (pago)
|
||||
double getTotalReceived() {
|
||||
return getRevenues()
|
||||
.where((t) => t.isPaid)
|
||||
.fold(0.0, (sum, t) => sum + t.amount);
|
||||
}
|
||||
|
||||
// Total pago
|
||||
double getTotalPaid() {
|
||||
return getExpenses()
|
||||
.where((t) => t.isPaid)
|
||||
.fold(0.0, (sum, t) => sum + t.amount);
|
||||
}
|
||||
|
||||
// Dashboard Helpers
|
||||
|
||||
List<double> getLast7DaysRevenue() {
|
||||
final now = DateTime.now();
|
||||
final List<double> dailyRevenue = List.filled(7, 0.0);
|
||||
|
||||
// Obtém todas as transações
|
||||
final allTransactions =
|
||||
getAllTransactions(); // Use o helper que filtra pelo usuário
|
||||
if (allTransactions.isEmpty) return dailyRevenue;
|
||||
|
||||
for (int i = 0; i < 7; i++) {
|
||||
final targetDate = now.subtract(Duration(days: 6 - i));
|
||||
|
||||
final dayRevenue = allTransactions
|
||||
.where(
|
||||
(t) =>
|
||||
t.type == TransactionType.revenue &&
|
||||
t.dueDate.year == targetDate.year &&
|
||||
t.dueDate.month == targetDate.month &&
|
||||
t.dueDate.day == targetDate.day,
|
||||
)
|
||||
.fold(0.0, (sum, t) => sum + t.amount);
|
||||
|
||||
dailyRevenue[i] = dayRevenue;
|
||||
}
|
||||
|
||||
return dailyRevenue;
|
||||
}
|
||||
|
||||
// Saldo
|
||||
double getBalance() {
|
||||
return getTotalReceived() - getTotalPaid();
|
||||
}
|
||||
|
||||
// Marcar como pago
|
||||
Future<TransactionModel?> markAsPaid(String id) async {
|
||||
final transaction = DatabaseService.transactionsBoxInstance.get(id);
|
||||
if (transaction == null) return null;
|
||||
|
||||
final updated = transaction.copyWith(
|
||||
isPaid: true,
|
||||
paidDate: DateTime.now(),
|
||||
);
|
||||
|
||||
await DatabaseService.transactionsBoxInstance.put(id, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
// Deletar transação
|
||||
Future<void> deleteTransaction(String id) async {
|
||||
await DatabaseService.transactionsBoxInstance.delete(id);
|
||||
}
|
||||
|
||||
// Transações do mês
|
||||
List<TransactionModel> getMonthTransactions() {
|
||||
final now = DateTime.now();
|
||||
final monthStart = DateTime(now.year, now.month, 1);
|
||||
|
||||
return getAllTransactions()
|
||||
.where((t) => t.createdAt.isAfter(monthStart))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<void> addTransaction(TransactionModel transaction) async {
|
||||
await DatabaseService.transactionsBoxInstance.put(
|
||||
transaction.id,
|
||||
transaction,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:barber_app/core/theme/app_theme.dart';
|
||||
import 'package:barber_app/core/constants/app_strings.dart';
|
||||
import 'package:barber_app/features/finances/data/models/transaction_model.dart';
|
||||
import 'package:barber_app/features/finances/data/repositories/transaction_repository.dart';
|
||||
import 'package:barber_app/shared/widgets/custom_text_field.dart';
|
||||
import 'package:barber_app/shared/widgets/loading_button.dart';
|
||||
|
||||
class AddTransactionPage extends StatefulWidget {
|
||||
const AddTransactionPage({super.key});
|
||||
|
||||
@override
|
||||
State<AddTransactionPage> createState() => _AddTransactionPageState();
|
||||
}
|
||||
|
||||
class _AddTransactionPageState extends State<AddTransactionPage> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _descriptionController = TextEditingController();
|
||||
final _amountController = TextEditingController();
|
||||
TransactionType _type = TransactionType.expense;
|
||||
DateTime _dueDate = DateTime.now();
|
||||
bool _isPaid = false;
|
||||
bool _isLoading = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_descriptionController.dispose();
|
||||
_amountController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _selectDate() async {
|
||||
final date = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _dueDate,
|
||||
firstDate: DateTime(2020),
|
||||
lastDate: DateTime(2030),
|
||||
builder: (context, child) {
|
||||
return Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
colorScheme: ColorScheme.dark(
|
||||
primary: AppColors.primaryColor,
|
||||
surface: AppColors.surface,
|
||||
),
|
||||
),
|
||||
child: child!,
|
||||
);
|
||||
},
|
||||
);
|
||||
if (date != null) {
|
||||
setState(() => _dueDate = date);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveTransaction() async {
|
||||
if (!(_formKey.currentState?.validate() ?? false)) return;
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
final repo = TransactionRepository();
|
||||
|
||||
if (_type == TransactionType.expense) {
|
||||
await repo.createExpense(
|
||||
amount: double.parse(_amountController.text.replaceAll(',', '.')),
|
||||
description: _descriptionController.text,
|
||||
dueDate: _dueDate,
|
||||
isPaid: _isPaid,
|
||||
);
|
||||
} else {
|
||||
await repo.createRevenue(
|
||||
amount: double.parse(_amountController.text.replaceAll(',', '.')),
|
||||
description: _descriptionController.text,
|
||||
dueDate: _dueDate,
|
||||
isPaid: _isPaid,
|
||||
);
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(AppStrings.savedSuccessfully),
|
||||
backgroundColor: AppColors.success,
|
||||
),
|
||||
);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Erro: $e'), backgroundColor: AppColors.error),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Nova Transação'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Tipo
|
||||
const Text(
|
||||
'Tipo',
|
||||
style: TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildTypeButton(
|
||||
'Despesa',
|
||||
TransactionType.expense,
|
||||
Icons.arrow_upward,
|
||||
AppColors.error,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _buildTypeButton(
|
||||
'Receita',
|
||||
TransactionType.revenue,
|
||||
Icons.arrow_downward,
|
||||
AppColors.success,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
CustomTextField(
|
||||
controller: _descriptionController,
|
||||
label: AppStrings.description,
|
||||
hint: 'Ex: Aluguel, Luz, Produto...',
|
||||
prefixIcon: Icons.description_outlined,
|
||||
validator: (v) =>
|
||||
v?.isEmpty ?? true ? 'Informe a descrição' : null,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
CustomTextField(
|
||||
controller: _amountController,
|
||||
label: 'Valor',
|
||||
hint: '0,00',
|
||||
prefixIcon: Icons.attach_money,
|
||||
keyboardType: TextInputType.number,
|
||||
validator: (v) =>
|
||||
v?.isEmpty ?? true ? 'Informe o valor' : null,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Data
|
||||
const Text(
|
||||
AppStrings.dueDate,
|
||||
style: TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
GestureDetector(
|
||||
onTap: _selectDate,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceLight,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.calendar_today,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'${_dueDate.day.toString().padLeft(2, '0')}/${_dueDate.month.toString().padLeft(2, '0')}/${_dueDate.year}',
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Já pago?
|
||||
SwitchListTile(
|
||||
title: const Text(
|
||||
'Já foi pago?',
|
||||
style: TextStyle(color: AppColors.textPrimary),
|
||||
),
|
||||
value: _isPaid,
|
||||
onChanged: (v) => setState(() => _isPaid = v),
|
||||
activeThumbColor: AppColors.primaryColor,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
LoadingButton(
|
||||
text: AppStrings.save,
|
||||
isLoading: _isLoading,
|
||||
onPressed: _saveTransaction,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTypeButton(
|
||||
String label,
|
||||
TransactionType type,
|
||||
IconData icon,
|
||||
Color color,
|
||||
) {
|
||||
final isSelected = _type == type;
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _type = type),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? color.withValues(alpha: 0.2) : AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isSelected ? color : AppColors.surfaceLight,
|
||||
width: isSelected ? 2 : 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, color: isSelected ? color : AppColors.textSecondary),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: isSelected ? color : AppColors.textSecondary,
|
||||
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
807
lib/features/finances/presentation/pages/finances_page.dart
Normal file
807
lib/features/finances/presentation/pages/finances_page.dart
Normal file
@@ -0,0 +1,807 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:barber_app/core/theme/app_theme.dart';
|
||||
import 'package:barber_app/core/constants/app_strings.dart';
|
||||
import 'package:barber_app/features/finances/data/models/transaction_model.dart';
|
||||
import 'package:barber_app/features/finances/data/repositories/transaction_repository.dart';
|
||||
import 'package:barber_app/features/finances/presentation/pages/add_transaction_page.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:barber_app/core/database/database_service.dart';
|
||||
|
||||
class FinancesPage extends StatefulWidget {
|
||||
const FinancesPage({super.key});
|
||||
|
||||
@override
|
||||
State<FinancesPage> createState() => _FinancesPageState();
|
||||
}
|
||||
|
||||
class _FinancesPageState extends State<FinancesPage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
final _transactionRepo = TransactionRepository();
|
||||
final _currencyFormat = NumberFormat.currency(locale: 'pt_BR', symbol: 'R\$');
|
||||
DateTime _selectedDate = DateTime.now();
|
||||
String _searchQuery = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 4, vsync: this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _refreshData() => setState(() {});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text(AppStrings.finances),
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(110),
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
child: TextField(
|
||||
onChanged: (value) => setState(() => _searchQuery = value),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Buscar transação...',
|
||||
prefixIcon: Icon(
|
||||
Icons.search,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: AppColors.surface,
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 0),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
TabBar(
|
||||
controller: _tabController,
|
||||
labelColor: AppColors.primaryColor,
|
||||
unselectedLabelColor: AppColors.textSecondary,
|
||||
indicatorColor: AppColors.primaryColor,
|
||||
tabs: const [
|
||||
Tab(text: 'Resumo'),
|
||||
Tab(text: 'A Receber'),
|
||||
Tab(text: 'A Pagar'),
|
||||
Tab(text: 'Extrato'),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.psychology_alt_rounded),
|
||||
tooltip: 'Gerar Dados Teste',
|
||||
onPressed: _generateMockData,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.calendar_month),
|
||||
onPressed: _showMonthPicker,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ValueListenableBuilder(
|
||||
valueListenable: DatabaseService.transactionsBoxInstance.listenable(),
|
||||
builder: (context, _, _) {
|
||||
final balance = _transactionRepo.getBalance();
|
||||
final pendingReceivables = _transactionRepo.getPendingReceivables();
|
||||
final pendingPayables = _transactionRepo.getPendingPayables();
|
||||
|
||||
return TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_buildSummaryTab(balance, pendingReceivables, pendingPayables),
|
||||
_buildTransactionList(TransactionType.revenue),
|
||||
_buildTransactionList(TransactionType.expense),
|
||||
_buildAllTransactionsList(),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
floatingActionButton: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 80),
|
||||
child: FloatingActionButton.extended(
|
||||
heroTag: 'finances_fab',
|
||||
onPressed: () async {
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const AddTransactionPage()),
|
||||
);
|
||||
_refreshData();
|
||||
},
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Nova Transação'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _generateMockData() async {
|
||||
final now = DateTime.now();
|
||||
final random = DateTime.now().millisecondsSinceEpoch;
|
||||
|
||||
for (int i = 0; i < 20; i++) {
|
||||
final day = (i % 28) + 1;
|
||||
await _transactionRepo.addTransaction(
|
||||
TransactionModel(
|
||||
id: 'mock_rev_${random}_$i',
|
||||
userId: DatabaseService.getCurrentUserId() ?? 'user_1',
|
||||
type: TransactionType.revenue,
|
||||
amount: (100 + (i * 15)).toDouble(),
|
||||
description: 'Corte Cliente ${i + 1}',
|
||||
dueDate: DateTime(now.year, now.month, day),
|
||||
isPaid: i % 3 != 0,
|
||||
createdAt: DateTime.now(),
|
||||
paidDate: i % 3 != 0 ? DateTime(now.year, now.month, day) : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
for (int i = 0; i < 8; i++) {
|
||||
final day = (i * 3) + 2;
|
||||
if (day > 28) {
|
||||
continue;
|
||||
}
|
||||
await _transactionRepo.addTransaction(
|
||||
TransactionModel(
|
||||
id: 'mock_exp_${random}_$i',
|
||||
userId: DatabaseService.getCurrentUserId() ?? 'user_1',
|
||||
type: TransactionType.expense,
|
||||
amount: (40 + (i * 20)).toDouble(),
|
||||
description: 'Despesa Insumos $i',
|
||||
dueDate: DateTime(now.year, now.month, day),
|
||||
isPaid: true,
|
||||
createdAt: DateTime.now(),
|
||||
paidDate: DateTime(now.year, now.month, day),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_refreshData();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Dashboard populada com dados de teste! 🚀'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showMonthPicker() async {
|
||||
final DateTime? picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _selectedDate,
|
||||
firstDate: DateTime(2020),
|
||||
lastDate: DateTime(2030),
|
||||
initialDatePickerMode: DatePickerMode.year,
|
||||
);
|
||||
if (picked != null &&
|
||||
(picked.month != _selectedDate.month ||
|
||||
picked.year != _selectedDate.year)) {
|
||||
setState(() {
|
||||
_selectedDate = picked;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildSummaryTab(double balance, double receivables, double payables) {
|
||||
final monthlyTransactions = _transactionRepo.getAllTransactions().where((
|
||||
t,
|
||||
) {
|
||||
return t.dueDate.month == _selectedDate.month &&
|
||||
t.dueDate.year == _selectedDate.year;
|
||||
}).toList();
|
||||
|
||||
double monthlyReceived = 0;
|
||||
double monthlyPaid = 0;
|
||||
double monthlyProjectedRevenue = 0;
|
||||
double monthlyProjectedExpense = 0;
|
||||
|
||||
for (var t in monthlyTransactions) {
|
||||
if (t.type == TransactionType.revenue) {
|
||||
monthlyProjectedRevenue += t.amount;
|
||||
if (t.isPaid) {
|
||||
monthlyReceived += t.amount;
|
||||
}
|
||||
} else {
|
||||
monthlyProjectedExpense += t.amount;
|
||||
if (t.isPaid) {
|
||||
monthlyPaid += t.amount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildMonthHeader(),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildSummaryCard(
|
||||
'Faturamento',
|
||||
monthlyReceived,
|
||||
monthlyProjectedRevenue,
|
||||
AppColors.success,
|
||||
Icons.trending_up_rounded,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: _buildSummaryCard(
|
||||
'Despesas',
|
||||
monthlyPaid,
|
||||
monthlyProjectedExpense,
|
||||
AppColors.error,
|
||||
Icons.trending_down_rounded,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
'DESEMPENHO DIÁRIO',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: AppColors.textSecondary,
|
||||
letterSpacing: 1.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildYieldChart(monthlyTransactions),
|
||||
const SizedBox(height: 24),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(color: Colors.white.withAlpha(10)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Resumo de Caixa',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_buildBarChartRow(
|
||||
'Entradas Realizadas',
|
||||
monthlyReceived,
|
||||
monthlyProjectedRevenue,
|
||||
AppColors.success,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_buildBarChartRow(
|
||||
'Saídas Efetuadas',
|
||||
monthlyPaid,
|
||||
monthlyProjectedExpense,
|
||||
AppColors.error,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 120),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMonthHeader() {
|
||||
return Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primaryColor.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
border: Border.all(
|
||||
color: AppColors.primaryColor.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.analytics_outlined,
|
||||
size: 18,
|
||||
color: AppColors.primaryColor,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
DateFormat(
|
||||
'MMMM yyyy',
|
||||
'pt_BR',
|
||||
).format(_selectedDate).toUpperCase(),
|
||||
style: TextStyle(
|
||||
color: AppColors.primaryColor,
|
||||
fontWeight: FontWeight.w900,
|
||||
fontSize: 13,
|
||||
letterSpacing: 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildYieldChart(List<TransactionModel> transactions) {
|
||||
final Map<int, double> dailyRevenue = {};
|
||||
for (var t in transactions) {
|
||||
if (t.type == TransactionType.revenue && t.isPaid) {
|
||||
final day = t.dueDate.day;
|
||||
dailyRevenue[day] = (dailyRevenue[day] ?? 0) + t.amount;
|
||||
}
|
||||
}
|
||||
|
||||
final List<FlSpot> spots = [];
|
||||
for (int i = 1; i <= 31; i++) {
|
||||
spots.add(FlSpot(i.toDouble(), dailyRevenue[i] ?? 0));
|
||||
}
|
||||
|
||||
return Container(
|
||||
height: 220,
|
||||
padding: const EdgeInsets.fromLTRB(10, 20, 20, 10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
border: Border.all(color: Colors.white.withAlpha(10)),
|
||||
),
|
||||
child: LineChart(
|
||||
LineChartData(
|
||||
gridData: FlGridData(
|
||||
show: true,
|
||||
drawVerticalLine: false,
|
||||
getDrawingHorizontalLine: (value) =>
|
||||
FlLine(color: Colors.white.withAlpha(5), strokeWidth: 1),
|
||||
),
|
||||
titlesData: FlTitlesData(
|
||||
show: true,
|
||||
rightTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
topTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
leftTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
bottomTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 30,
|
||||
interval: 5,
|
||||
getTitlesWidget: (value, meta) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Text(
|
||||
'${value.toInt()}',
|
||||
style: TextStyle(
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.5),
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
borderData: FlBorderData(show: false),
|
||||
lineBarsData: [
|
||||
LineChartBarData(
|
||||
spots: spots,
|
||||
isCurved: true,
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
AppColors.primaryColor,
|
||||
AppColors.primaryColor.withValues(alpha: 0.5),
|
||||
],
|
||||
),
|
||||
barWidth: 4,
|
||||
isStrokeCapRound: true,
|
||||
dotData: const FlDotData(show: false),
|
||||
belowBarData: BarAreaData(
|
||||
show: true,
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
AppColors.primaryColor.withValues(alpha: 0.2),
|
||||
AppColors.primaryColor.withValues(alpha: 0.0),
|
||||
],
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSummaryCard(
|
||||
String title,
|
||||
double realized,
|
||||
double projected,
|
||||
Color color,
|
||||
IconData icon,
|
||||
) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(color: color.withValues(alpha: 0.1)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(icon, color: color, size: 24),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
_currencyFormat.format(realized),
|
||||
style: TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (projected > realized)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
'Meta: ${_currencyFormat.format(projected)}',
|
||||
style: TextStyle(
|
||||
color: color.withValues(alpha: 0.5),
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBarChartRow(
|
||||
String label,
|
||||
double value,
|
||||
double total,
|
||||
Color color,
|
||||
) {
|
||||
if (total == 0) {
|
||||
total = 1;
|
||||
}
|
||||
final percentage = (value / total).clamp(0.0, 1.0);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_currencyFormat.format(value),
|
||||
style: TextStyle(color: color, fontWeight: FontWeight.w900),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Stack(
|
||||
children: [
|
||||
Container(
|
||||
height: 12,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceLight,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
),
|
||||
FractionallySizedBox(
|
||||
widthFactor: percentage,
|
||||
child: Container(
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [color, color.withValues(alpha: 0.6)],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: color.withValues(alpha: 0.3),
|
||||
blurRadius: 8,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGroupedList(List<TransactionModel> transactions) {
|
||||
if (transactions.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.receipt_long_outlined,
|
||||
size: 64,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.2),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Nenhuma transação encontrada',
|
||||
style: TextStyle(color: AppColors.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
transactions.sort((a, b) => b.dueDate.compareTo(a.dueDate));
|
||||
|
||||
var filtered = transactions;
|
||||
if (_searchQuery.isNotEmpty) {
|
||||
filtered = filtered
|
||||
.where(
|
||||
(t) => t.description.toLowerCase().contains(
|
||||
_searchQuery.toLowerCase(),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
if (filtered.isEmpty) {
|
||||
return const Center(
|
||||
child: Text(
|
||||
'Nenhum resultado para a busca',
|
||||
style: TextStyle(color: AppColors.textSecondary),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final Map<String, List<TransactionModel>> grouped = {};
|
||||
for (var t in filtered) {
|
||||
final key = DateFormat('yyyy-MM-dd').format(t.dueDate);
|
||||
grouped.putIfAbsent(key, () => []).add(t);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 100),
|
||||
itemCount: grouped.keys.length,
|
||||
itemBuilder: (context, index) {
|
||||
final dateKey = grouped.keys.elementAt(index);
|
||||
final dayTransactions = grouped[dateKey]!;
|
||||
final date = DateTime.parse(dateKey);
|
||||
|
||||
String header;
|
||||
final now = DateTime.now();
|
||||
if (date.year == now.year &&
|
||||
date.month == now.month &&
|
||||
date.day == now.day) {
|
||||
header = 'HOJE';
|
||||
} else if (date.year == now.year &&
|
||||
date.month == now.month &&
|
||||
date.day == now.day - 1) {
|
||||
header = 'ONTEM';
|
||||
} else {
|
||||
header = DateFormat('dd MMM', 'pt_BR').format(date).toUpperCase();
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4, top: 20, bottom: 12),
|
||||
child: Text(
|
||||
header,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontWeight: FontWeight.w900,
|
||||
fontSize: 12,
|
||||
letterSpacing: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
...dayTransactions.map((t) => _buildTransactionCard(t)),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTransactionList(TransactionType type) {
|
||||
final filtered = _transactionRepo.getAllTransactions().where((t) {
|
||||
return t.type == type &&
|
||||
t.dueDate.month == _selectedDate.month &&
|
||||
t.dueDate.year == _selectedDate.year;
|
||||
}).toList();
|
||||
return _buildGroupedList(filtered);
|
||||
}
|
||||
|
||||
Widget _buildAllTransactionsList() {
|
||||
final filtered = _transactionRepo.getAllTransactions().where((t) {
|
||||
return t.dueDate.month == _selectedDate.month &&
|
||||
t.dueDate.year == _selectedDate.year;
|
||||
}).toList();
|
||||
return _buildGroupedList(filtered);
|
||||
}
|
||||
|
||||
Widget _buildTransactionCard(TransactionModel transaction) {
|
||||
final isRevenue = transaction.type == TransactionType.revenue;
|
||||
return GestureDetector(
|
||||
onTap: transaction.isPaid ? null : () => _markAsPaid(transaction),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: transaction.isOverdue
|
||||
? AppColors.error.withValues(alpha: 0.3)
|
||||
: Colors.white.withAlpha(5),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: (isRevenue ? AppColors.success : AppColors.error)
|
||||
.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Icon(
|
||||
isRevenue ? Icons.add_rounded : Icons.remove_rounded,
|
||||
color: isRevenue ? AppColors.success : AppColors.error,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
transaction.description,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
DateFormat('HH:mm').format(transaction.createdAt) == '00:00'
|
||||
? DateFormat('dd/MM').format(transaction.dueDate)
|
||||
: DateFormat(
|
||||
'HH:mm - dd/MM',
|
||||
).format(transaction.dueDate),
|
||||
style: TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
_currencyFormat.format(transaction.amount),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w900,
|
||||
fontSize: 15,
|
||||
color: isRevenue ? AppColors.success : AppColors.error,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
if (!transaction.isPaid)
|
||||
_buildBadge(AppStrings.pending, AppColors.primaryColor)
|
||||
else
|
||||
_buildBadge('Pago', AppColors.success),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBadge(String text, Color color) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _markAsPaid(TransactionModel transaction) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: AppColors.surface,
|
||||
title: const Text('Confirmar Pagamento'),
|
||||
content: Text(
|
||||
'Confirmar o recebimento/pagamento de ${_currencyFormat.format(transaction.amount)}?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancelar'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
final navigator = Navigator.of(context);
|
||||
await _transactionRepo.markAsPaid(transaction.id);
|
||||
navigator.pop();
|
||||
_refreshData();
|
||||
},
|
||||
child: const Text(
|
||||
'Confirmar',
|
||||
style: TextStyle(color: AppColors.success),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user