feat: Add logger service and improve expense dialog with enhanced receipt management and calculation logic.
This commit is contained in:
@@ -56,6 +56,7 @@
|
||||
/// - Group
|
||||
/// - ExpenseBloc
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@@ -76,8 +77,10 @@ import '../../models/expense.dart';
|
||||
class AddExpenseDialog extends StatefulWidget {
|
||||
/// The group to which the expense belongs.
|
||||
final Group group;
|
||||
|
||||
/// The user creating or editing the expense.
|
||||
final user_state.UserModel currentUser;
|
||||
|
||||
/// The expense to edit (null for new expense).
|
||||
final Expense? expenseToEdit;
|
||||
|
||||
@@ -103,27 +106,40 @@ class AddExpenseDialog extends StatefulWidget {
|
||||
class _AddExpenseDialogState extends State<AddExpenseDialog> {
|
||||
/// Form key for validating the expense form.
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
/// Controller for the expense description field.
|
||||
final _descriptionController = TextEditingController();
|
||||
|
||||
/// Controller for the expense amount field.
|
||||
final _amountController = TextEditingController();
|
||||
|
||||
/// The selected date for the expense.
|
||||
late DateTime _selectedDate;
|
||||
|
||||
/// The selected category for the expense.
|
||||
late ExpenseCategory _selectedCategory;
|
||||
|
||||
/// The selected currency for the expense.
|
||||
late ExpenseCurrency _selectedCurrency;
|
||||
|
||||
/// The user ID of the payer.
|
||||
late String _paidById;
|
||||
|
||||
/// Map of userId to split amount for each participant.
|
||||
final Map<String, double> _splits = {};
|
||||
|
||||
/// The selected receipt image file, if any.
|
||||
File? _receiptImage;
|
||||
|
||||
/// Whether the dialog is currently submitting data.
|
||||
bool _isLoading = false;
|
||||
|
||||
/// Whether the expense is split equally among participants.
|
||||
bool _splitEqually = true;
|
||||
|
||||
/// Whether the existing receipt has been removed.
|
||||
bool _receiptRemoved = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -172,7 +188,7 @@ class _AddExpenseDialogState extends State<AddExpenseDialog> {
|
||||
if (pickedFile != null) {
|
||||
final file = File(pickedFile.path);
|
||||
final fileSize = await file.length();
|
||||
|
||||
|
||||
if (fileSize > 5 * 1024 * 1024) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -199,11 +215,11 @@ class _AddExpenseDialogState extends State<AddExpenseDialog> {
|
||||
|
||||
final amount = double.tryParse(_amountController.text) ?? 0;
|
||||
final selectedMembers = _splits.entries.where((e) => e.value >= 0).toList();
|
||||
|
||||
|
||||
if (selectedMembers.isEmpty) return;
|
||||
|
||||
final splitAmount = amount / selectedMembers.length;
|
||||
|
||||
|
||||
setState(() {
|
||||
for (final entry in selectedMembers) {
|
||||
_splits[entry.key] = splitAmount;
|
||||
@@ -221,17 +237,14 @@ class _AddExpenseDialogState extends State<AddExpenseDialog> {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
final amount = double.parse(_amountController.text);
|
||||
final selectedSplits = _splits.entries
|
||||
.where((e) => e.value > 0)
|
||||
.map((e) {
|
||||
final member = widget.group.members.firstWhere((m) => m.userId == e.key);
|
||||
return ExpenseSplit(
|
||||
userId: e.key,
|
||||
userName: member.firstName,
|
||||
amount: e.value,
|
||||
);
|
||||
})
|
||||
.toList();
|
||||
final selectedSplits = _splits.entries.where((e) => e.value > 0).map((e) {
|
||||
final member = widget.group.members.firstWhere((m) => m.userId == e.key);
|
||||
return ExpenseSplit(
|
||||
userId: e.key,
|
||||
userName: member.firstName,
|
||||
amount: e.value,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
if (selectedSplits.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -248,11 +261,15 @@ class _AddExpenseDialogState extends State<AddExpenseDialog> {
|
||||
try {
|
||||
// Convertir en EUR
|
||||
final amountInEur = context.read<ExpenseBloc>().state is ExpensesLoaded
|
||||
? (context.read<ExpenseBloc>().state as ExpensesLoaded)
|
||||
.exchangeRates[_selectedCurrency]! * amount
|
||||
? ((context.read<ExpenseBloc>().state as ExpensesLoaded)
|
||||
.exchangeRates[_selectedCurrency.code] ??
|
||||
1.0) *
|
||||
amount
|
||||
: amount;
|
||||
|
||||
final payer = widget.group.members.firstWhere((m) => m.userId == _paidById);
|
||||
final payer = widget.group.members.firstWhere(
|
||||
(m) => m.userId == _paidById,
|
||||
);
|
||||
|
||||
final expense = Expense(
|
||||
id: widget.expenseToEdit?.id ?? '',
|
||||
@@ -266,29 +283,29 @@ class _AddExpenseDialogState extends State<AddExpenseDialog> {
|
||||
paidByName: payer.firstName,
|
||||
splits: selectedSplits,
|
||||
date: _selectedDate,
|
||||
receiptUrl: widget.expenseToEdit?.receiptUrl,
|
||||
receiptUrl: _receiptRemoved ? null : widget.expenseToEdit?.receiptUrl,
|
||||
createdAt: widget.expenseToEdit?.createdAt ?? DateTime.now(),
|
||||
);
|
||||
|
||||
if (widget.expenseToEdit == null) {
|
||||
context.read<ExpenseBloc>().add(CreateExpense(
|
||||
expense: expense,
|
||||
receiptImage: _receiptImage,
|
||||
));
|
||||
context.read<ExpenseBloc>().add(
|
||||
CreateExpense(expense: expense, receiptImage: _receiptImage),
|
||||
);
|
||||
} else {
|
||||
context.read<ExpenseBloc>().add(UpdateExpense(
|
||||
expense: expense,
|
||||
newReceiptImage: _receiptImage,
|
||||
));
|
||||
context.read<ExpenseBloc>().add(
|
||||
UpdateExpense(expense: expense, newReceiptImage: _receiptImage),
|
||||
);
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(widget.expenseToEdit == null
|
||||
? 'Dépense ajoutée'
|
||||
: 'Dépense modifiée'),
|
||||
content: Text(
|
||||
widget.expenseToEdit == null
|
||||
? 'Dépense ajoutée'
|
||||
: 'Dépense modifiée',
|
||||
),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
@@ -296,10 +313,7 @@ class _AddExpenseDialogState extends State<AddExpenseDialog> {
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Erreur: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
SnackBar(content: Text('Erreur: $e'), backgroundColor: Colors.red),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
@@ -314,284 +328,425 @@ class _AddExpenseDialogState extends State<AddExpenseDialog> {
|
||||
/// Returns a Dialog widget containing the expense form.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
backgroundColor: isDark ? theme.scaffoldBackgroundColor : Colors.white,
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 600, maxHeight: 700),
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.expenseToEdit == null
|
||||
? 'Nouvelle dépense'
|
||||
: 'Modifier la dépense'),
|
||||
automaticallyImplyLeading: false,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
constraints: const BoxConstraints(maxWidth: 500, maxHeight: 800),
|
||||
child: Column(
|
||||
children: [
|
||||
// Header
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 16, 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.expenseToEdit == null
|
||||
? 'Nouvelle dépense'
|
||||
: 'Modifier la dépense',
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Form(
|
||||
key: _formKey,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
// Description
|
||||
TextFormField(
|
||||
controller: _descriptionController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Description',
|
||||
hintText: 'Ex: Restaurant, Essence...',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.description),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Veuillez entrer une description';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
|
||||
// Montant et devise
|
||||
Row(
|
||||
// Form Content
|
||||
Expanded(
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: TextFormField(
|
||||
controller: _amountController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Montant',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.euro),
|
||||
// Description
|
||||
TextFormField(
|
||||
controller: _descriptionController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Description',
|
||||
hintText: 'Ex: Restaurant, Essence...',
|
||||
prefixIcon: const Icon(Icons.description_outlined),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
onChanged: (_) => _calculateSplits(),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Requis';
|
||||
}
|
||||
if (double.tryParse(value) == null || double.parse(value) <= 0) {
|
||||
return 'Montant invalide';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: DropdownButtonFormField<ExpenseCurrency>(
|
||||
initialValue: _selectedCurrency,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Devise',
|
||||
border: OutlineInputBorder(),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 16,
|
||||
),
|
||||
items: ExpenseCurrency.values.map((currency) {
|
||||
return DropdownMenuItem(
|
||||
value: currency,
|
||||
child: Text(currency.code),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
setState(() => _selectedCurrency = value);
|
||||
}
|
||||
},
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Requis';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Catégorie
|
||||
DropdownButtonFormField<ExpenseCategory>(
|
||||
initialValue: _selectedCategory,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Catégorie',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.category),
|
||||
),
|
||||
items: ExpenseCategory.values.map((category) {
|
||||
return DropdownMenuItem(
|
||||
value: category,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(category.icon, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text(category.displayName),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
setState(() => _selectedCategory = value);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Date
|
||||
ListTile(
|
||||
leading: const Icon(Icons.calendar_today),
|
||||
title: const Text('Date'),
|
||||
subtitle: Text(DateFormat('dd/MM/yyyy').format(_selectedDate)),
|
||||
shape: RoundedRectangleBorder(
|
||||
side: BorderSide(color: Colors.grey[300]!),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
onTap: () async {
|
||||
final date = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _selectedDate,
|
||||
firstDate: DateTime(2020),
|
||||
lastDate: DateTime.now().add(const Duration(days: 365)),
|
||||
);
|
||||
if (date != null) {
|
||||
setState(() => _selectedDate = date);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Payé par
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: _paidById,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Payé par',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.person),
|
||||
),
|
||||
items: widget.group.members.map((member) {
|
||||
return DropdownMenuItem(
|
||||
value: member.userId,
|
||||
child: Text(member.firstName),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
setState(() => _paidById = value);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Division
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
// Montant et Devise
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Text(
|
||||
'Division',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: TextFormField(
|
||||
controller: _amountController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Montant',
|
||||
prefixIcon: const Icon(Icons.euro_symbol),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 16,
|
||||
),
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
),
|
||||
onChanged: (_) => _calculateSplits(),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Requis';
|
||||
}
|
||||
if (double.tryParse(value) == null ||
|
||||
double.parse(value) <= 0) {
|
||||
return 'Invalide';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: DropdownButtonFormField<ExpenseCurrency>(
|
||||
initialValue: _selectedCurrency,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Devise',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 16,
|
||||
),
|
||||
),
|
||||
items: ExpenseCurrency.values.map((currency) {
|
||||
return DropdownMenuItem(
|
||||
value: currency,
|
||||
child: Text(currency.code),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
setState(() => _selectedCurrency = value);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Catégorie
|
||||
DropdownButtonFormField<ExpenseCategory>(
|
||||
initialValue: _selectedCategory,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Catégorie',
|
||||
prefixIcon: Icon(_selectedCategory.icon),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 16,
|
||||
),
|
||||
),
|
||||
items: ExpenseCategory.values.map((category) {
|
||||
return DropdownMenuItem(
|
||||
value: category,
|
||||
child: Text(category.displayName),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
setState(() => _selectedCategory = value);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Date
|
||||
InkWell(
|
||||
onTap: () async {
|
||||
final date = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _selectedDate,
|
||||
firstDate: DateTime(2020),
|
||||
lastDate: DateTime.now().add(
|
||||
const Duration(days: 365),
|
||||
),
|
||||
);
|
||||
if (date != null) setState(() => _selectedDate = date);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: InputDecorator(
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Date',
|
||||
prefixIcon: const Icon(Icons.calendar_today_outlined),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 16,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
DateFormat('dd/MM/yyyy').format(_selectedDate),
|
||||
style: theme.textTheme.bodyLarge,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Payé par
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: _paidById,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Payé par',
|
||||
prefixIcon: const Icon(Icons.person_outline),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 16,
|
||||
),
|
||||
),
|
||||
items: widget.group.members.map((member) {
|
||||
return DropdownMenuItem(
|
||||
value: member.userId,
|
||||
child: Text(member.firstName),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (value) {
|
||||
if (value != null) setState(() => _paidById = value);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Division Section
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? Colors.grey[800] : Colors.grey[50],
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: theme.dividerColor),
|
||||
),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Text(
|
||||
'Division',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'Égale',
|
||||
style: TextStyle(
|
||||
color: isDark
|
||||
? Colors.grey[400]
|
||||
: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Switch(
|
||||
value: _splitEqually,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_splitEqually = value;
|
||||
if (value) _calculateSplits();
|
||||
});
|
||||
},
|
||||
activeThumbColor: theme.colorScheme.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(),
|
||||
...widget.group.members.map((member) {
|
||||
final isSelected =
|
||||
_splits.containsKey(member.userId) &&
|
||||
_splits[member.userId]! >= 0;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
member.firstName,
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
if (!_splitEqually && isSelected)
|
||||
SizedBox(
|
||||
width: 100,
|
||||
child: TextFormField(
|
||||
initialValue: _splits[member.userId]
|
||||
?.toStringAsFixed(2),
|
||||
decoration: const InputDecoration(
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 8,
|
||||
),
|
||||
border: OutlineInputBorder(),
|
||||
suffixText: '€',
|
||||
),
|
||||
keyboardType:
|
||||
const TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
),
|
||||
onChanged: (value) {
|
||||
final amount =
|
||||
double.tryParse(value) ?? 0;
|
||||
setState(
|
||||
() =>
|
||||
_splits[member.userId] = amount,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Checkbox(
|
||||
value: isSelected,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
if (value == true) {
|
||||
_splits[member.userId] = 0;
|
||||
if (_splitEqually) _calculateSplits();
|
||||
} else {
|
||||
_splits[member.userId] = -1;
|
||||
}
|
||||
});
|
||||
},
|
||||
activeColor: theme.colorScheme.primary,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Reçu (Optional - keeping simple for now as per design focus)
|
||||
if (_receiptImage != null ||
|
||||
(widget.expenseToEdit?.receiptUrl != null &&
|
||||
!_receiptRemoved))
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: theme.dividerColor),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.receipt_long, color: Colors.green),
|
||||
const SizedBox(width: 8),
|
||||
const Text('Reçu joint'),
|
||||
const Spacer(),
|
||||
Text(_splitEqually ? 'Égale' : 'Personnalisée'),
|
||||
Switch(
|
||||
value: _splitEqually,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_splitEqually = value;
|
||||
if (value) _calculateSplits();
|
||||
});
|
||||
},
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => setState(() {
|
||||
_receiptImage = null;
|
||||
_receiptRemoved = true;
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(),
|
||||
...widget.group.members.map((member) {
|
||||
final isSelected = _splits.containsKey(member.userId) &&
|
||||
_splits[member.userId]! >= 0;
|
||||
|
||||
return CheckboxListTile(
|
||||
title: Text(member.firstName),
|
||||
subtitle: _splitEqually || !isSelected
|
||||
? null
|
||||
: TextFormField(
|
||||
initialValue: _splits[member.userId]?.toStringAsFixed(2),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Montant',
|
||||
isDense: true,
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
onChanged: (value) {
|
||||
final amount = double.tryParse(value) ?? 0;
|
||||
setState(() => _splits[member.userId] = amount);
|
||||
},
|
||||
),
|
||||
value: isSelected,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
if (value == true) {
|
||||
_splits[member.userId] = 0;
|
||||
if (_splitEqually) _calculateSplits();
|
||||
} else {
|
||||
_splits[member.userId] = -1;
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Reçu
|
||||
ListTile(
|
||||
leading: const Icon(Icons.receipt),
|
||||
title: Text(_receiptImage != null || widget.expenseToEdit?.receiptUrl != null
|
||||
? 'Reçu ajouté'
|
||||
: 'Ajouter un reçu'),
|
||||
subtitle: _receiptImage != null
|
||||
? const Text('Nouveau reçu sélectionné')
|
||||
: null,
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.add_photo_alternate),
|
||||
onPressed: _pickImage,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
side: BorderSide(color: Colors.grey[300]!),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Boutons
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: _isLoading ? null : () => Navigator.of(context).pop(),
|
||||
child: const Text('Annuler'),
|
||||
)
|
||||
else
|
||||
OutlinedButton.icon(
|
||||
onPressed: _pickImage,
|
||||
icon: const Icon(Icons.camera_alt_outlined),
|
||||
label: const Text('Ajouter un reçu'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: _isLoading ? null : _submit,
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Text(widget.expenseToEdit == null ? 'Ajouter' : 'Modifier'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Bottom Button
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: _isLoading ? null : _submit,
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
backgroundColor: theme.colorScheme.primary,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
),
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
Colors.white,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
widget.expenseToEdit == null
|
||||
? 'Ajouter'
|
||||
: 'Enregistrer',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -7,11 +7,13 @@ import 'expense_detail_dialog.dart';
|
||||
class ExpensesTab extends StatelessWidget {
|
||||
final List<Expense> expenses;
|
||||
final Group group;
|
||||
final String currentUserId;
|
||||
|
||||
const ExpensesTab({
|
||||
super.key,
|
||||
required this.expenses,
|
||||
required this.group,
|
||||
required this.currentUserId,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -48,95 +50,157 @@ class ExpensesTab extends StatelessWidget {
|
||||
}
|
||||
|
||||
Widget _buildExpenseCard(BuildContext context, Expense expense) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final dateFormat = DateFormat('dd/MM/yyyy');
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
final dateFormat = DateFormat('dd/MM');
|
||||
|
||||
return Card(
|
||||
// Logique pour déterminer l'impact sur l'utilisateur
|
||||
bool isPayer = expense.paidById == currentUserId;
|
||||
double amountToDisplay = expense.amount;
|
||||
bool isPositive = isPayer;
|
||||
|
||||
// Si je suis le payeur, je suis en positif (on me doit de l'argent)
|
||||
// Si je ne suis pas le payeur, je suis en négatif (je dois de l'argent)
|
||||
// Note: Pour être précis, il faudrait calculer ma part exacte, mais pour l'instant
|
||||
// on affiche le total avec la couleur indiquant si j'ai payé ou non.
|
||||
|
||||
final amountColor = isPositive ? Colors.green : Colors.red;
|
||||
final prefix = isPositive ? '+' : '-';
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
child: InkWell(
|
||||
onTap: () => _showExpenseDetail(context, expense),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? Colors.blue[900] : Colors.blue[100],
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? theme.cardColor : Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () => _showExpenseDetail(context, expense),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
// Icone circulaire
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: _getCategoryColor(
|
||||
expense.category,
|
||||
).withValues(alpha: 0.2),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
expense.category.icon,
|
||||
color: _getCategoryColor(expense.category),
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
expense.category.icon,
|
||||
color: Colors.blue,
|
||||
const SizedBox(width: 16),
|
||||
|
||||
// Détails
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
expense.description,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
isPayer
|
||||
? 'Payé par vous'
|
||||
: 'Payé par ${expense.paidByName}',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: isDark
|
||||
? Colors.grey[400]
|
||||
: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
' • ${dateFormat.format(expense.date)}',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: isDark
|
||||
? Colors.grey[500]
|
||||
: Colors.grey[500],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
||||
// Montant
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
expense.description,
|
||||
style: const TextStyle(
|
||||
'$prefix${amountToDisplay.toStringAsFixed(2)} ${expense.currency.symbol}',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: amountColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Payé par ${expense.paidByName}',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: isDark ? Colors.grey[400] : Colors.grey[600],
|
||||
if (expense.currency != ExpenseCurrency.eur)
|
||||
Text(
|
||||
'Total ${expense.amountInEur.toStringAsFixed(2)} €',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: isDark ? Colors.grey[500] : Colors.grey[400],
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
dateFormat.format(expense.date),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isDark ? Colors.grey[500] : Colors.grey[500],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
'${expense.amount.toStringAsFixed(2)} ${expense.currency.symbol}',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.green,
|
||||
),
|
||||
),
|
||||
if (expense.currency != ExpenseCurrency.eur)
|
||||
Text(
|
||||
'${expense.amountInEur.toStringAsFixed(2)} €',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isDark ? Colors.grey[400] : Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _getCategoryColor(ExpenseCategory category) {
|
||||
switch (category) {
|
||||
case ExpenseCategory.restaurant:
|
||||
return Colors.orange;
|
||||
case ExpenseCategory.transport:
|
||||
return Colors.blue;
|
||||
case ExpenseCategory.accommodation:
|
||||
return Colors.purple;
|
||||
case ExpenseCategory.entertainment:
|
||||
return Colors.pink;
|
||||
case ExpenseCategory.shopping:
|
||||
return Colors.teal;
|
||||
case ExpenseCategory.other:
|
||||
return Colors.grey;
|
||||
}
|
||||
}
|
||||
|
||||
void _showExpenseDetail(BuildContext context, Expense expense) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => ExpenseDetailDialog(
|
||||
expense: expense,
|
||||
group: group,
|
||||
),
|
||||
builder: (context) => ExpenseDetailDialog(expense: expense, group: group),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,8 @@ import '../../models/group.dart';
|
||||
import 'add_expense_dialog.dart';
|
||||
import 'balances_tab.dart';
|
||||
import 'expenses_tab.dart';
|
||||
import 'settlements_tab.dart';
|
||||
import '../../models/user_balance.dart';
|
||||
import '../../models/expense.dart';
|
||||
|
||||
class GroupExpensesPage extends StatefulWidget {
|
||||
final Account account;
|
||||
@@ -31,13 +32,14 @@ class GroupExpensesPage extends StatefulWidget {
|
||||
|
||||
class _GroupExpensesPageState extends State<GroupExpensesPage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
|
||||
late TabController _tabController;
|
||||
|
||||
ExpenseCategory? _selectedCategory;
|
||||
String? _selectedPayerId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 3, vsync: this);
|
||||
_tabController = TabController(length: 2, vsync: this);
|
||||
_loadData();
|
||||
}
|
||||
|
||||
@@ -50,39 +52,41 @@ class _GroupExpensesPageState extends State<GroupExpensesPage>
|
||||
void _loadData() {
|
||||
// Charger les dépenses du groupe
|
||||
context.read<ExpenseBloc>().add(LoadExpensesByGroup(widget.group.id));
|
||||
|
||||
|
||||
// Charger les balances du groupe
|
||||
context.read<BalanceBloc>().add(LoadGroupBalances(widget.group.id));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isDarkMode = theme.brightness == Brightness.dark;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: isDarkMode
|
||||
? theme.scaffoldBackgroundColor
|
||||
: Colors.grey[50],
|
||||
appBar: AppBar(
|
||||
title: Text(widget.account.name),
|
||||
backgroundColor: Theme.of(context).primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
bottom: TabBar(
|
||||
controller: _tabController,
|
||||
indicatorColor: Colors.white,
|
||||
labelColor: Colors.white,
|
||||
unselectedLabelColor: Colors.white70,
|
||||
tabs: const [
|
||||
Tab(
|
||||
icon: Icon(Icons.balance),
|
||||
text: 'Balances',
|
||||
),
|
||||
Tab(
|
||||
icon: Icon(Icons.receipt_long),
|
||||
text: 'Dépenses',
|
||||
),
|
||||
Tab(
|
||||
icon: Icon(Icons.payment),
|
||||
text: 'Règlements',
|
||||
),
|
||||
],
|
||||
title: const Text(
|
||||
'Dépenses du voyage',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
centerTitle: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
foregroundColor: theme.colorScheme.onSurface,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.filter_list),
|
||||
onPressed: () {
|
||||
_showFilterDialog();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: MultiBlocListener(
|
||||
listeners: [
|
||||
@@ -103,56 +107,107 @@ class _GroupExpensesPageState extends State<GroupExpensesPage>
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
} else if (state is ExpensesLoaded) {
|
||||
// Rafraîchir les balances quand les dépenses changent (ex: via stream)
|
||||
context.read<BalanceBloc>().add(
|
||||
RefreshBalance(widget.group.id),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
child: Column(
|
||||
children: [
|
||||
// Onglet Balances
|
||||
// Summary Card
|
||||
BlocBuilder<BalanceBloc, BalanceState>(
|
||||
builder: (context, state) {
|
||||
if (state is BalanceLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
} else if (state is GroupBalancesLoaded) {
|
||||
return BalancesTab(balances: state.balances);
|
||||
} else if (state is BalanceError) {
|
||||
return _buildErrorState('Erreur lors du chargement des balances: ${state.message}');
|
||||
if (state is GroupBalancesLoaded) {
|
||||
return _buildSummaryCard(state.balances, isDarkMode);
|
||||
}
|
||||
return _buildEmptyState('Aucune balance disponible');
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
|
||||
// Onglet Dépenses
|
||||
BlocBuilder<ExpenseBloc, ExpenseState>(
|
||||
builder: (context, state) {
|
||||
if (state is ExpenseLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
} else if (state is ExpensesLoaded) {
|
||||
return ExpensesTab(
|
||||
expenses: state.expenses,
|
||||
group: widget.group,
|
||||
);
|
||||
} else if (state is ExpenseError) {
|
||||
return _buildErrorState('Erreur lors du chargement des dépenses: ${state.message}');
|
||||
}
|
||||
return _buildEmptyState('Aucune dépense trouvée');
|
||||
},
|
||||
|
||||
// Tabs
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(color: theme.dividerColor, width: 1),
|
||||
),
|
||||
),
|
||||
child: TabBar(
|
||||
controller: _tabController,
|
||||
labelColor: theme.colorScheme.primary,
|
||||
unselectedLabelColor: theme.colorScheme.onSurface.withValues(
|
||||
alpha: 0.6,
|
||||
),
|
||||
indicatorColor: theme.colorScheme.primary,
|
||||
indicatorWeight: 3,
|
||||
labelStyle: const TextStyle(fontWeight: FontWeight.bold),
|
||||
tabs: const [
|
||||
Tab(text: 'Toutes les dépenses'),
|
||||
Tab(text: 'Mes soldes'),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Onglet Règlements
|
||||
BlocBuilder<BalanceBloc, BalanceState>(
|
||||
builder: (context, state) {
|
||||
if (state is BalanceLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
} else if (state is GroupBalancesLoaded) {
|
||||
return SettlementsTab(settlements: state.settlements);
|
||||
} else if (state is BalanceError) {
|
||||
return _buildErrorState('Erreur lors du chargement des règlements: ${state.message}');
|
||||
}
|
||||
return _buildEmptyState('Aucun règlement nécessaire');
|
||||
},
|
||||
|
||||
// Tab View
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
// Onglet Dépenses
|
||||
BlocBuilder<ExpenseBloc, ExpenseState>(
|
||||
builder: (context, state) {
|
||||
if (state is ExpenseLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
} else if (state is ExpensesLoaded) {
|
||||
final userState = context.read<UserBloc>().state;
|
||||
final currentUserId = userState is user_state.UserLoaded
|
||||
? userState.user.id
|
||||
: '';
|
||||
|
||||
var filteredExpenses = state.expenses;
|
||||
|
||||
if (_selectedCategory != null) {
|
||||
filteredExpenses = filteredExpenses
|
||||
.where((e) => e.category == _selectedCategory)
|
||||
.toList();
|
||||
}
|
||||
|
||||
if (_selectedPayerId != null) {
|
||||
filteredExpenses = filteredExpenses
|
||||
.where((e) => e.paidById == _selectedPayerId)
|
||||
.toList();
|
||||
}
|
||||
|
||||
return ExpensesTab(
|
||||
expenses: filteredExpenses,
|
||||
group: widget.group,
|
||||
currentUserId: currentUserId,
|
||||
);
|
||||
} else if (state is ExpenseError) {
|
||||
return _buildErrorState('Erreur: ${state.message}');
|
||||
}
|
||||
return _buildEmptyState('Aucune dépense trouvée');
|
||||
},
|
||||
),
|
||||
|
||||
// Onglet Balances (Combiné)
|
||||
BlocBuilder<BalanceBloc, BalanceState>(
|
||||
builder: (context, state) {
|
||||
if (state is BalanceLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
} else if (state is GroupBalancesLoaded) {
|
||||
return BalancesTab(balances: state.balances);
|
||||
} else if (state is BalanceError) {
|
||||
return _buildErrorState('Erreur: ${state.message}');
|
||||
}
|
||||
return _buildEmptyState('Aucune balance disponible');
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -160,8 +215,109 @@ class _GroupExpensesPageState extends State<GroupExpensesPage>
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: _showAddExpenseDialog,
|
||||
heroTag: "add_expense_fab",
|
||||
backgroundColor: Colors.blue,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 4,
|
||||
shape: const CircleBorder(),
|
||||
tooltip: 'Ajouter une dépense',
|
||||
child: const Icon(Icons.add),
|
||||
child: const Icon(Icons.add, size: 32),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSummaryCard(List<UserBalance> balances, bool isDarkMode) {
|
||||
// Trouver la balance de l'utilisateur courant
|
||||
final userState = context.read<UserBloc>().state;
|
||||
double myBalance = 0;
|
||||
|
||||
if (userState is user_state.UserLoaded) {
|
||||
final myBalanceObj = balances.firstWhere(
|
||||
(b) => b.userId == userState.user.id,
|
||||
orElse: () => const UserBalance(
|
||||
userId: '',
|
||||
userName: '',
|
||||
totalPaid: 0,
|
||||
totalOwed: 0,
|
||||
balance: 0,
|
||||
),
|
||||
);
|
||||
myBalance = myBalanceObj.balance;
|
||||
}
|
||||
|
||||
final isPositive = myBalance >= 0;
|
||||
final color = isPositive ? Colors.green : Colors.red;
|
||||
final amountStr = '${myBalance.abs().toStringAsFixed(2)} €';
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: isDarkMode ? Colors.grey[800] : Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Votre solde total',
|
||||
style: TextStyle(
|
||||
color: isDarkMode ? Colors.grey[400] : Colors.grey[600],
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
isPositive ? 'On vous doit ' : 'Vous devez ',
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
amountStr,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
isPositive
|
||||
? 'Vous êtes en positif sur ce voyage.'
|
||||
: 'Vous êtes en négatif sur ce voyage.',
|
||||
style: TextStyle(
|
||||
color: isDarkMode ? Colors.grey[400] : Colors.grey[600],
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
_tabController.animateTo(1); // Aller à l'onglet Balances
|
||||
},
|
||||
child: Text(
|
||||
'Voir le détail des soldes',
|
||||
style: TextStyle(
|
||||
color: Colors.blue[400],
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -171,11 +327,7 @@ class _GroupExpensesPageState extends State<GroupExpensesPage>
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 80,
|
||||
color: Colors.red[300],
|
||||
),
|
||||
Icon(Icons.error_outline, size: 80, color: Colors.red[300]),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Erreur',
|
||||
@@ -190,10 +342,7 @@ class _GroupExpensesPageState extends State<GroupExpensesPage>
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Text(
|
||||
message,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
@@ -213,11 +362,7 @@ class _GroupExpensesPageState extends State<GroupExpensesPage>
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
size: 80,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
Icon(Icons.info_outline, size: 80, color: Colors.grey[400]),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Aucune donnée',
|
||||
@@ -230,10 +375,7 @@ class _GroupExpensesPageState extends State<GroupExpensesPage>
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
message,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
style: TextStyle(fontSize: 16, color: Colors.grey[500]),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
@@ -243,14 +385,12 @@ class _GroupExpensesPageState extends State<GroupExpensesPage>
|
||||
|
||||
void _showAddExpenseDialog() {
|
||||
final userState = context.read<UserBloc>().state;
|
||||
|
||||
|
||||
if (userState is user_state.UserLoaded) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AddExpenseDialog(
|
||||
group: widget.group,
|
||||
currentUser: userState.user,
|
||||
),
|
||||
builder: (context) =>
|
||||
AddExpenseDialog(group: widget.group, currentUser: userState.user),
|
||||
);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -261,4 +401,96 @@ class _GroupExpensesPageState extends State<GroupExpensesPage>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showFilterDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
return AlertDialog(
|
||||
title: const Text('Filtrer les dépenses'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
DropdownButtonFormField<ExpenseCategory>(
|
||||
// ignore: deprecated_member_use
|
||||
value: _selectedCategory,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Catégorie',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: [
|
||||
const DropdownMenuItem<ExpenseCategory>(
|
||||
value: null,
|
||||
child: Text('Toutes'),
|
||||
),
|
||||
...ExpenseCategory.values.map((category) {
|
||||
return DropdownMenuItem(
|
||||
value: category,
|
||||
child: Text(category.displayName),
|
||||
);
|
||||
}),
|
||||
],
|
||||
onChanged: (value) {
|
||||
setState(() => _selectedCategory = value);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<String>(
|
||||
// ignore: deprecated_member_use
|
||||
value: _selectedPayerId,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Payé par',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: [
|
||||
const DropdownMenuItem<String>(
|
||||
value: null,
|
||||
child: Text('Tous'),
|
||||
),
|
||||
...widget.group.members.map((member) {
|
||||
return DropdownMenuItem(
|
||||
value: member.userId,
|
||||
child: Text(member.firstName),
|
||||
);
|
||||
}),
|
||||
],
|
||||
onChanged: (value) {
|
||||
setState(() => _selectedPayerId = value);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_selectedCategory = null;
|
||||
_selectedPayerId = null;
|
||||
});
|
||||
// Also update parent state
|
||||
this.setState(() {
|
||||
_selectedCategory = null;
|
||||
_selectedPayerId = null;
|
||||
});
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text('Réinitialiser'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
// Update parent state
|
||||
this.setState(() {});
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text('Appliquer'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user