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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user