feat: Add expense management features with tabs for expenses, balances, and settlements
- Implemented ExpensesTab to display a list of expenses with details. - Created GroupExpensesPage to manage group expenses with a tabbed interface. - Added SettlementsTab to show optimized settlements between users. - Developed data models for Expense and Balance, including necessary methods for serialization. - Introduced CountRepository for Firestore interactions related to expenses. - Added CountService to handle business logic for expenses and settlements. - Integrated image picker for receipt uploads. - Updated main.dart to include CountBloc and CountRepository. - Enhanced pubspec.yaml with new dependencies for image picking and Firebase storage. Not Tested yet
This commit is contained in:
197
lib/blocs/count/count_bloc.dart
Normal file
197
lib/blocs/count/count_bloc.dart
Normal file
@@ -0,0 +1,197 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../data/models/expense.dart';
|
||||
import '../../data/models/balance.dart';
|
||||
import '../../services/count_service.dart';
|
||||
import '../../repositories/count_repository.dart';
|
||||
import 'count_event.dart';
|
||||
import 'count_state.dart';
|
||||
|
||||
class CountBloc extends Bloc<CountEvent, CountState> {
|
||||
final CountService _countService;
|
||||
StreamSubscription<List<Expense>>? _expensesSubscription;
|
||||
Map<ExpenseCurrency, double> _exchangeRates = {};
|
||||
|
||||
CountBloc({CountService? countService})
|
||||
: _countService = countService ?? CountService(
|
||||
countRepository: CountRepository(),
|
||||
),
|
||||
super(CountInitial()) {
|
||||
on<LoadExpenses>(_onLoadExpenses);
|
||||
on<CreateExpense>(_onCreateExpense);
|
||||
on<UpdateExpense>(_onUpdateExpense);
|
||||
on<DeleteExpense>(_onDeleteExpense);
|
||||
on<ArchiveExpense>(_onArchiveExpense);
|
||||
on<MarkSplitAsPaid>(_onMarkSplitAsPaid);
|
||||
on<LoadExchangeRates>(_onLoadExchangeRates);
|
||||
on<_ExpensesUpdated>(_onExpensesUpdated);
|
||||
}
|
||||
|
||||
Future<void> _onLoadExpenses(
|
||||
LoadExpenses event,
|
||||
Emitter<CountState> emit,
|
||||
) async {
|
||||
emit(CountLoading());
|
||||
|
||||
// Charger les taux de change
|
||||
if (_exchangeRates.isEmpty) {
|
||||
_exchangeRates = await _countService.getExchangeRates();
|
||||
}
|
||||
|
||||
await _expensesSubscription?.cancel();
|
||||
|
||||
_expensesSubscription = _countService
|
||||
.getExpensesStream(event.groupId, includeArchived: event.includeArchived)
|
||||
.listen(
|
||||
(expenses) {
|
||||
add(_ExpensesUpdated(
|
||||
groupId: event.groupId,
|
||||
expenses: expenses,
|
||||
));
|
||||
},
|
||||
onError: (error) {
|
||||
add(_ExpensesError('Erreur lors du chargement des dépenses: $error'));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _onExpensesUpdated(
|
||||
_ExpensesUpdated event,
|
||||
Emitter<CountState> emit,
|
||||
) {
|
||||
// Récupérer les membres du groupe et calculer les balances
|
||||
final memberIds = <String>{};
|
||||
final memberNames = <String, String>{};
|
||||
|
||||
for (final expense in event.expenses) {
|
||||
memberIds.add(expense.paidById);
|
||||
memberNames[expense.paidById] = expense.paidByName;
|
||||
|
||||
for (final split in expense.splits) {
|
||||
memberIds.add(split.userId);
|
||||
memberNames[split.userId] = split.userName;
|
||||
}
|
||||
}
|
||||
|
||||
final balances = _countService.calculateBalances(
|
||||
event.expenses,
|
||||
memberIds.toList(),
|
||||
memberNames,
|
||||
);
|
||||
|
||||
final settlements = _countService.calculateOptimizedSettlements(balances);
|
||||
|
||||
emit(ExpensesLoaded(
|
||||
groupId: event.groupId,
|
||||
expenses: event.expenses,
|
||||
balances: balances,
|
||||
settlements: settlements,
|
||||
exchangeRates: _exchangeRates,
|
||||
));
|
||||
}
|
||||
|
||||
Future<void> _onCreateExpense(
|
||||
CreateExpense event,
|
||||
Emitter<CountState> emit,
|
||||
) async {
|
||||
try {
|
||||
await _countService.createExpense(
|
||||
event.expense,
|
||||
receiptImage: event.receiptImage,
|
||||
);
|
||||
} catch (e) {
|
||||
emit(CountError('Erreur lors de la création de la dépense: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onUpdateExpense(
|
||||
UpdateExpense event,
|
||||
Emitter<CountState> emit,
|
||||
) async {
|
||||
try {
|
||||
await _countService.updateExpense(
|
||||
event.expense,
|
||||
newReceiptImage: event.newReceiptImage,
|
||||
);
|
||||
} catch (e) {
|
||||
emit(CountError('Erreur lors de la modification de la dépense: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onDeleteExpense(
|
||||
DeleteExpense event,
|
||||
Emitter<CountState> emit,
|
||||
) async {
|
||||
try {
|
||||
await _countService.deleteExpense(event.groupId, event.expenseId);
|
||||
} catch (e) {
|
||||
emit(CountError('Erreur lors de la suppression de la dépense: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onArchiveExpense(
|
||||
ArchiveExpense event,
|
||||
Emitter<CountState> emit,
|
||||
) async {
|
||||
try {
|
||||
await _countService.archiveExpense(event.groupId, event.expenseId);
|
||||
} catch (e) {
|
||||
emit(CountError('Erreur lors de l\'archivage de la dépense: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onMarkSplitAsPaid(
|
||||
MarkSplitAsPaid event,
|
||||
Emitter<CountState> emit,
|
||||
) async {
|
||||
try {
|
||||
await _countService.markSplitAsPaid(
|
||||
event.groupId,
|
||||
event.expenseId,
|
||||
event.userId,
|
||||
);
|
||||
} catch (e) {
|
||||
emit(CountError('Erreur lors du marquage du paiement: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onLoadExchangeRates(
|
||||
LoadExchangeRates event,
|
||||
Emitter<CountState> emit,
|
||||
) async {
|
||||
try {
|
||||
_exchangeRates = await _countService.getExchangeRates();
|
||||
} catch (e) {
|
||||
emit(CountError('Erreur lors du chargement des taux de change: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
_expensesSubscription?.cancel();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Events internes
|
||||
class _ExpensesUpdated extends CountEvent {
|
||||
final String groupId;
|
||||
final List<Expense> expenses;
|
||||
|
||||
const _ExpensesUpdated({
|
||||
required this.groupId,
|
||||
required this.expenses,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [groupId, expenses];
|
||||
}
|
||||
|
||||
class _ExpensesError extends CountEvent {
|
||||
final String error;
|
||||
|
||||
const _ExpensesError(this.error);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [error];
|
||||
}
|
||||
91
lib/blocs/count/count_event.dart
Normal file
91
lib/blocs/count/count_event.dart
Normal file
@@ -0,0 +1,91 @@
|
||||
import 'dart:io';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../../data/models/expense.dart';
|
||||
|
||||
abstract class CountEvent extends Equatable {
|
||||
const CountEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class LoadExpenses extends CountEvent {
|
||||
final String groupId;
|
||||
final bool includeArchived;
|
||||
|
||||
const LoadExpenses(this.groupId, {this.includeArchived = false});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [groupId, includeArchived];
|
||||
}
|
||||
|
||||
class CreateExpense extends CountEvent {
|
||||
final Expense expense;
|
||||
final File? receiptImage;
|
||||
|
||||
const CreateExpense({
|
||||
required this.expense,
|
||||
this.receiptImage,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [expense, receiptImage];
|
||||
}
|
||||
|
||||
class UpdateExpense extends CountEvent {
|
||||
final Expense expense;
|
||||
final File? newReceiptImage;
|
||||
|
||||
const UpdateExpense({
|
||||
required this.expense,
|
||||
this.newReceiptImage,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [expense, newReceiptImage];
|
||||
}
|
||||
|
||||
class DeleteExpense extends CountEvent {
|
||||
final String groupId;
|
||||
final String expenseId;
|
||||
|
||||
const DeleteExpense({
|
||||
required this.groupId,
|
||||
required this.expenseId,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [groupId, expenseId];
|
||||
}
|
||||
|
||||
class ArchiveExpense extends CountEvent {
|
||||
final String groupId;
|
||||
final String expenseId;
|
||||
|
||||
const ArchiveExpense({
|
||||
required this.groupId,
|
||||
required this.expenseId,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [groupId, expenseId];
|
||||
}
|
||||
|
||||
class MarkSplitAsPaid extends CountEvent {
|
||||
final String groupId;
|
||||
final String expenseId;
|
||||
final String userId;
|
||||
|
||||
const MarkSplitAsPaid({
|
||||
required this.groupId,
|
||||
required this.expenseId,
|
||||
required this.userId,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [groupId, expenseId, userId];
|
||||
}
|
||||
|
||||
class LoadExchangeRates extends CountEvent {
|
||||
const LoadExchangeRates();
|
||||
}
|
||||
42
lib/blocs/count/count_state.dart
Normal file
42
lib/blocs/count/count_state.dart
Normal file
@@ -0,0 +1,42 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../../data/models/expense.dart';
|
||||
import '../../data/models/balance.dart';
|
||||
|
||||
abstract class CountState extends Equatable {
|
||||
const CountState();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class CountInitial extends CountState {}
|
||||
|
||||
class CountLoading extends CountState {}
|
||||
|
||||
class ExpensesLoaded extends CountState {
|
||||
final String groupId;
|
||||
final List<Expense> expenses;
|
||||
final List<Balance> balances;
|
||||
final List<Settlement> settlements;
|
||||
final Map<ExpenseCurrency, double> exchangeRates;
|
||||
|
||||
const ExpensesLoaded({
|
||||
required this.groupId,
|
||||
required this.expenses,
|
||||
required this.balances,
|
||||
required this.settlements,
|
||||
required this.exchangeRates,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [groupId, expenses, balances, settlements, exchangeRates];
|
||||
}
|
||||
|
||||
class CountError extends CountState {
|
||||
final String message;
|
||||
|
||||
const CountError(this.message);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
@@ -24,6 +24,12 @@ class GroupsLoaded extends GroupState {
|
||||
List<Object?> get props => [groups];
|
||||
}
|
||||
|
||||
class GroupLoaded extends GroupState {
|
||||
final List<Group> groups;
|
||||
|
||||
const GroupLoaded(this.groups);
|
||||
}
|
||||
|
||||
// Succès d'une opération
|
||||
class GroupOperationSuccess extends GroupState {
|
||||
final String message;
|
||||
|
||||
496
lib/components/count/add_expense_dialog.dart
Normal file
496
lib/components/count/add_expense_dialog.dart
Normal file
@@ -0,0 +1,496 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../blocs/count/count_bloc.dart';
|
||||
import '../../blocs/count/count_event.dart';
|
||||
import '../../blocs/count/count_state.dart';
|
||||
import '../../blocs/user/user_state.dart' as user_state;
|
||||
import '../../data/models/group.dart';
|
||||
import '../../data/models/expense.dart';
|
||||
|
||||
class AddExpenseDialog extends StatefulWidget {
|
||||
final Group group;
|
||||
final user_state.UserModel currentUser;
|
||||
final Expense? expenseToEdit;
|
||||
|
||||
const AddExpenseDialog({
|
||||
super.key,
|
||||
required this.group,
|
||||
required this.currentUser,
|
||||
this.expenseToEdit,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AddExpenseDialog> createState() => _AddExpenseDialogState();
|
||||
}
|
||||
|
||||
class _AddExpenseDialogState extends State<AddExpenseDialog> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _descriptionController = TextEditingController();
|
||||
final _amountController = TextEditingController();
|
||||
|
||||
late DateTime _selectedDate;
|
||||
late ExpenseCategory _selectedCategory;
|
||||
late ExpenseCurrency _selectedCurrency;
|
||||
late String _paidById;
|
||||
final Map<String, double> _splits = {};
|
||||
File? _receiptImage;
|
||||
bool _isLoading = false;
|
||||
bool _splitEqually = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectedDate = widget.expenseToEdit?.date ?? DateTime.now();
|
||||
_selectedCategory = widget.expenseToEdit?.category ?? ExpenseCategory.other;
|
||||
_selectedCurrency = widget.expenseToEdit?.currency ?? ExpenseCurrency.eur;
|
||||
_paidById = widget.expenseToEdit?.paidById ?? widget.currentUser.id;
|
||||
|
||||
if (widget.expenseToEdit != null) {
|
||||
_descriptionController.text = widget.expenseToEdit!.description;
|
||||
_amountController.text = widget.expenseToEdit!.amount.toString();
|
||||
|
||||
for (final split in widget.expenseToEdit!.splits) {
|
||||
_splits[split.userId] = split.amount;
|
||||
}
|
||||
_splitEqually = false;
|
||||
} else {
|
||||
// Initialiser avec tous les membres sélectionnés
|
||||
for (final member in widget.group.members) {
|
||||
_splits[member.userId] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_descriptionController.dispose();
|
||||
_amountController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _pickImage() async {
|
||||
final picker = ImagePicker();
|
||||
final pickedFile = await picker.pickImage(
|
||||
source: ImageSource.gallery,
|
||||
maxWidth: 1920,
|
||||
maxHeight: 1920,
|
||||
);
|
||||
|
||||
if (pickedFile != null) {
|
||||
final file = File(pickedFile.path);
|
||||
final fileSize = await file.length();
|
||||
|
||||
if (fileSize > 5 * 1024 * 1024) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('L\'image ne doit pas dépasser 5 Mo'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_receiptImage = file;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _calculateSplits() {
|
||||
if (!_splitEqually) return;
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
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();
|
||||
|
||||
if (selectedSplits.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Veuillez sélectionner au moins un participant'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
// Convertir en EUR
|
||||
final amountInEur = await context.read<CountBloc>().state is ExpensesLoaded
|
||||
? (context.read<CountBloc>().state as ExpensesLoaded)
|
||||
.exchangeRates[_selectedCurrency]! * amount
|
||||
: amount;
|
||||
|
||||
final payer = widget.group.members.firstWhere((m) => m.userId == _paidById);
|
||||
|
||||
final expense = Expense(
|
||||
id: widget.expenseToEdit?.id ?? '',
|
||||
groupId: widget.group.id,
|
||||
description: _descriptionController.text.trim(),
|
||||
amount: amount,
|
||||
currency: _selectedCurrency,
|
||||
amountInEur: amountInEur,
|
||||
category: _selectedCategory,
|
||||
paidById: _paidById,
|
||||
paidByName: payer.firstName,
|
||||
splits: selectedSplits,
|
||||
date: _selectedDate,
|
||||
receiptUrl: widget.expenseToEdit?.receiptUrl,
|
||||
);
|
||||
|
||||
if (widget.expenseToEdit == null) {
|
||||
context.read<CountBloc>().add(CreateExpense(
|
||||
expense: expense,
|
||||
receiptImage: _receiptImage,
|
||||
));
|
||||
} else {
|
||||
context.read<CountBloc>().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'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Erreur: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
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),
|
||||
|
||||
// Montant et devise
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: TextFormField(
|
||||
controller: _amountController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Montant',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.euro),
|
||||
),
|
||||
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(),
|
||||
),
|
||||
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: 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(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Text(
|
||||
'Division',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(_splitEqually ? 'Égale' : 'Personnalisée'),
|
||||
Switch(
|
||||
value: _splitEqually,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_splitEqually = value;
|
||||
if (value) _calculateSplits();
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
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;
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
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'),
|
||||
),
|
||||
),
|
||||
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'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
124
lib/components/count/balances_tab.dart
Normal file
124
lib/components/count/balances_tab.dart
Normal file
@@ -0,0 +1,124 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../data/models/balance.dart';
|
||||
|
||||
class BalancesTab extends StatelessWidget {
|
||||
final List<Balance> balances;
|
||||
|
||||
const BalancesTab({
|
||||
super.key,
|
||||
required this.balances,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (balances.isEmpty) {
|
||||
return const Center(
|
||||
child: Text('Aucune balance à afficher'),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: balances.length,
|
||||
itemBuilder: (context, index) {
|
||||
final balance = balances[index];
|
||||
return _buildBalanceCard(context, balance);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBalanceCard(BuildContext context, Balance balance) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
Color balanceColor;
|
||||
IconData balanceIcon;
|
||||
String balanceText;
|
||||
|
||||
if (balance.shouldReceive) {
|
||||
balanceColor = Colors.green;
|
||||
balanceIcon = Icons.arrow_downward;
|
||||
balanceText = 'À recevoir';
|
||||
} else if (balance.shouldPay) {
|
||||
balanceColor = Colors.red;
|
||||
balanceIcon = Icons.arrow_upward;
|
||||
balanceText = 'À payer';
|
||||
} else {
|
||||
balanceColor = Colors.grey;
|
||||
balanceIcon = Icons.check_circle;
|
||||
balanceText = 'Équilibré';
|
||||
}
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 24,
|
||||
backgroundColor: isDark ? Colors.grey[800] : Colors.grey[200],
|
||||
child: Text(
|
||||
balance.userName.isNotEmpty
|
||||
? balance.userName[0].toUpperCase()
|
||||
: '?',
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
balance.userName,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Payé: ${balance.totalPaid.toStringAsFixed(2)} € • Doit: ${balance.totalOwed.toStringAsFixed(2)} €',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isDark ? Colors.grey[400] : Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(balanceIcon, size: 16, color: balanceColor),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${balance.absoluteBalance.toStringAsFixed(2)} €',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: balanceColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
balanceText,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: balanceColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,143 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../blocs/group/group_bloc.dart';
|
||||
import '../../blocs/group/group_state.dart';
|
||||
import '../../data/models/group.dart';
|
||||
import 'group_expenses_page.dart';
|
||||
|
||||
class CountContent extends StatelessWidget {
|
||||
const CountContent({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<GroupBloc, GroupState>(
|
||||
builder: (context, state) {
|
||||
if (state is GroupLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (state is GroupLoaded) {
|
||||
if (state.groups.isEmpty) {
|
||||
return _buildEmptyState();
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: state.groups.length,
|
||||
itemBuilder: (context, index) {
|
||||
final group = state.groups[index];
|
||||
return _buildGroupCard(context, group);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (state is GroupError) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.error, size: 64, color: Colors.red),
|
||||
const SizedBox(height: 16),
|
||||
Text(state.message),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return _buildEmptyState();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState() {
|
||||
return const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.account_balance_wallet, size: 64, color: Colors.green),
|
||||
Icon(Icons.account_balance_wallet, size: 80, color: Colors.grey),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'Comptes et Budget',
|
||||
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
|
||||
'Aucun groupe',
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'Gérez vos dépenses de voyage',
|
||||
style: TextStyle(fontSize: 16, color: Colors.grey),
|
||||
'Créez un groupe pour commencer à gérer vos dépenses',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGroupCard(BuildContext context, Group group) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => GroupExpensesPage(group: group),
|
||||
),
|
||||
);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? Colors.blue[900] : Colors.blue[100],
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.group,
|
||||
color: Colors.blue,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
group.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${group.members.length} membre${group.members.length > 1 ? 's' : ''}',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: isDark ? Colors.grey[400] : Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
color: isDark ? Colors.grey[400] : Colors.grey[600],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
354
lib/components/count/expense_detail_dialog.dart
Normal file
354
lib/components/count/expense_detail_dialog.dart
Normal file
@@ -0,0 +1,354 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../blocs/count/count_bloc.dart';
|
||||
import '../../blocs/count/count_event.dart';
|
||||
import '../../blocs/user/user_bloc.dart';
|
||||
import '../../blocs/user/user_state.dart' as user_state;
|
||||
import '../../data/models/expense.dart';
|
||||
import '../../data/models/group.dart';
|
||||
import 'add_expense_dialog.dart';
|
||||
|
||||
class ExpenseDetailDialog extends StatelessWidget {
|
||||
final Expense expense;
|
||||
final Group group;
|
||||
|
||||
const ExpenseDetailDialog({
|
||||
super.key,
|
||||
required this.expense,
|
||||
required this.group,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final dateFormat = DateFormat('dd MMMM yyyy');
|
||||
final timeFormat = DateFormat('HH:mm');
|
||||
|
||||
return BlocBuilder<UserBloc, user_state.UserState>(
|
||||
builder: (context, userState) {
|
||||
final currentUser = userState is user_state.UserLoaded ? userState.user : null;
|
||||
final canEdit = currentUser?.id == expense.paidById;
|
||||
|
||||
return Dialog(
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 500, maxHeight: 700),
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Détails de la dépense'),
|
||||
automaticallyImplyLeading: false,
|
||||
actions: [
|
||||
if (canEdit) ...[
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
_showEditDialog(context, currentUser!);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete, color: Colors.red),
|
||||
onPressed: () => _confirmDelete(context),
|
||||
),
|
||||
],
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
// En-tête avec icône
|
||||
Center(
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.withOpacity(0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
expense.category.icon,
|
||||
size: 40,
|
||||
color: Colors.blue,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
expense.description,
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
expense.category.displayName,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Montant
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
'${expense.amount.toStringAsFixed(2)} ${expense.currency.symbol}',
|
||||
style: const TextStyle(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.green,
|
||||
),
|
||||
),
|
||||
if (expense.currency != ExpenseCurrency.eur)
|
||||
Text(
|
||||
'≈ ${expense.amountInEur.toStringAsFixed(2)} €',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Informations
|
||||
_buildInfoRow(Icons.person, 'Payé par', expense.paidByName),
|
||||
_buildInfoRow(Icons.calendar_today, 'Date', dateFormat.format(expense.date)),
|
||||
_buildInfoRow(Icons.access_time, 'Heure', timeFormat.format(expense.createdAt)),
|
||||
|
||||
if (expense.isEdited && expense.editedAt != null)
|
||||
_buildInfoRow(
|
||||
Icons.edit,
|
||||
'Modifié le',
|
||||
dateFormat.format(expense.editedAt!),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
const Divider(),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Divisions
|
||||
const Text(
|
||||
'Répartition',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
...expense.splits.map((split) => _buildSplitTile(context, split)),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Reçu
|
||||
if (expense.receiptUrl != null) ...[
|
||||
const Divider(),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Reçu',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Image.network(
|
||||
expense.receiptUrl!,
|
||||
fit: BoxFit.cover,
|
||||
loadingBuilder: (context, child, loadingProgress) {
|
||||
if (loadingProgress == null) return child;
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
},
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return const Center(
|
||||
child: Text('Erreur de chargement de l\'image'),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Bouton archiver
|
||||
if (!expense.isArchived && canEdit)
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _confirmArchive(context),
|
||||
icon: const Icon(Icons.archive),
|
||||
label: const Text('Archiver cette dépense'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoRow(IconData icon, String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 20, color: Colors.grey[600]),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSplitTile(BuildContext context, ExpenseSplit split) {
|
||||
return BlocBuilder<UserBloc, user_state.UserState>(
|
||||
builder: (context, userState) {
|
||||
final currentUser = userState is user_state.UserLoaded ? userState.user : null;
|
||||
final isCurrentUser = currentUser?.id == split.userId;
|
||||
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: split.isPaid ? Colors.green : Colors.orange,
|
||||
child: Icon(
|
||||
split.isPaid ? Icons.check : Icons.pending,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
split.userName,
|
||||
style: TextStyle(
|
||||
fontWeight: isCurrentUser ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
subtitle: Text(split.isPaid ? 'Payé' : 'En attente'),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'${split.amount.toStringAsFixed(2)} €',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
if (!split.isPaid && isCurrentUser) ...[
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.check_circle, color: Colors.green),
|
||||
onPressed: () {
|
||||
context.read<CountBloc>().add(MarkSplitAsPaid(
|
||||
groupId: expense.groupId,
|
||||
expenseId: expense.id,
|
||||
userId: split.userId,
|
||||
));
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showEditDialog(BuildContext context, user_state.UserModel currentUser) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogContext) => BlocProvider.value(
|
||||
value: context.read<CountBloc>(),
|
||||
child: AddExpenseDialog(
|
||||
group: group,
|
||||
currentUser: currentUser,
|
||||
expenseToEdit: expense,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _confirmDelete(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Supprimer la dépense'),
|
||||
content: const Text('Êtes-vous sûr de vouloir supprimer cette dépense ?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
context.read<CountBloc>().add(DeleteExpense(
|
||||
groupId: expense.groupId,
|
||||
expenseId: expense.id,
|
||||
));
|
||||
Navigator.of(dialogContext).pop();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: const Text('Supprimer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _confirmArchive(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Archiver la dépense'),
|
||||
content: const Text('Cette dépense sera archivée et n\'apparaîtra plus dans les calculs de balance.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
context.read<CountBloc>().add(ArchiveExpense(
|
||||
groupId: expense.groupId,
|
||||
expenseId: expense.id,
|
||||
));
|
||||
Navigator.of(dialogContext).pop();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('Archiver'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
142
lib/components/count/expenses_tab.dart
Normal file
142
lib/components/count/expenses_tab.dart
Normal file
@@ -0,0 +1,142 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../data/models/expense.dart';
|
||||
import '../../data/models/group.dart';
|
||||
import 'expense_detail_dialog.dart';
|
||||
|
||||
class ExpensesTab extends StatelessWidget {
|
||||
final List<Expense> expenses;
|
||||
final Group group;
|
||||
|
||||
const ExpensesTab({
|
||||
super.key,
|
||||
required this.expenses,
|
||||
required this.group,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (expenses.isEmpty) {
|
||||
return const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.receipt_long, size: 80, color: Colors.grey),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'Aucune dépense',
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'Ajoutez votre première dépense',
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: expenses.length,
|
||||
itemBuilder: (context, index) {
|
||||
final expense = expenses[index];
|
||||
return _buildExpenseCard(context, expense);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildExpenseCard(BuildContext context, Expense expense) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final dateFormat = DateFormat('dd/MM/yyyy');
|
||||
|
||||
return Card(
|
||||
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),
|
||||
),
|
||||
child: Icon(
|
||||
expense.category.icon,
|
||||
color: Colors.blue,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
expense.description,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Payé par ${expense.paidByName}',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: isDark ? Colors.grey[400] : Colors.grey[600],
|
||||
),
|
||||
),
|
||||
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],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showExpenseDetail(BuildContext context, Expense expense) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => ExpenseDetailDialog(
|
||||
expense: expense,
|
||||
group: group,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
116
lib/components/count/group_expenses_page.dart
Normal file
116
lib/components/count/group_expenses_page.dart
Normal file
@@ -0,0 +1,116 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../blocs/count/count_bloc.dart';
|
||||
import '../../blocs/count/count_event.dart';
|
||||
import '../../blocs/count/count_state.dart';
|
||||
import '../../blocs/user/user_bloc.dart';
|
||||
import '../../blocs/user/user_state.dart' as user_state;
|
||||
import '../../data/models/group.dart';
|
||||
import 'add_expense_dialog.dart';
|
||||
import 'balances_tab.dart';
|
||||
import 'expenses_tab.dart';
|
||||
import 'settlements_tab.dart';
|
||||
|
||||
class GroupExpensesPage extends StatefulWidget {
|
||||
final Group group;
|
||||
|
||||
const GroupExpensesPage({
|
||||
super.key,
|
||||
required this.group,
|
||||
});
|
||||
|
||||
@override
|
||||
State<GroupExpensesPage> createState() => _GroupExpensesPageState();
|
||||
}
|
||||
|
||||
class _GroupExpensesPageState extends State<GroupExpensesPage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 3, vsync: this);
|
||||
context.read<CountBloc>().add(LoadExpenses(widget.group.id));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.group.name),
|
||||
bottom: TabBar(
|
||||
controller: _tabController,
|
||||
tabs: const [
|
||||
Tab(text: 'Dépenses', icon: Icon(Icons.receipt_long)),
|
||||
Tab(text: 'Balances', icon: Icon(Icons.account_balance)),
|
||||
Tab(text: 'Remboursements', icon: Icon(Icons.payments)),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: BlocConsumer<CountBloc, CountState>(
|
||||
listener: (context, state) {
|
||||
if (state is CountError) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(state.message),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
if (state is CountLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (state is ExpensesLoaded) {
|
||||
return TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
ExpensesTab(
|
||||
expenses: state.expenses,
|
||||
group: widget.group,
|
||||
),
|
||||
BalancesTab(balances: state.balances),
|
||||
SettlementsTab(settlements: state.settlements),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return const Center(child: Text('Aucune donnée'));
|
||||
},
|
||||
),
|
||||
floatingActionButton: BlocBuilder<UserBloc, user_state.UserState>(
|
||||
builder: (context, userState) {
|
||||
if (userState is! user_state.UserLoaded) return const SizedBox();
|
||||
|
||||
return FloatingActionButton.extended(
|
||||
onPressed: () => _showAddExpenseDialog(context, userState.user),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Dépense'),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showAddExpenseDialog(BuildContext context, user_state.UserModel currentUser) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogContext) => BlocProvider.value(
|
||||
value: context.read<CountBloc>(),
|
||||
child: AddExpenseDialog(
|
||||
group: widget.group,
|
||||
currentUser: currentUser,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
164
lib/components/count/settlements_tab.dart
Normal file
164
lib/components/count/settlements_tab.dart
Normal file
@@ -0,0 +1,164 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../data/models/balance.dart';
|
||||
|
||||
class SettlementsTab extends StatelessWidget {
|
||||
final List<Settlement> settlements;
|
||||
|
||||
const SettlementsTab({
|
||||
super.key,
|
||||
required this.settlements,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (settlements.isEmpty) {
|
||||
return const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.check_circle, size: 80, color: Colors.green),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'Tout est réglé !',
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'Aucun remboursement nécessaire',
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
color: Colors.blue.withValues(alpha: 0.1),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.info_outline, color: Colors.blue),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Plan de remboursement optimisé (${settlements.length} transaction${settlements.length > 1 ? 's' : ''})',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: settlements.length,
|
||||
itemBuilder: (context, index) {
|
||||
final settlement = settlements[index];
|
||||
return _buildSettlementCard(context, settlement, index + 1);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSettlementCard(BuildContext context, Settlement settlement, int number) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? Colors.blue[900] : Colors.blue[100],
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'$number',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.blue,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
settlement.fromUserName,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'doit payer',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isDark ? Colors.grey[400] : Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.green),
|
||||
),
|
||||
child: Text(
|
||||
'${settlement.amount.toStringAsFixed(2)} €',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.green,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
settlement.toUserName,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.right,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'à recevoir',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isDark ? Colors.grey[400] : Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
34
lib/data/models/balance.dart
Normal file
34
lib/data/models/balance.dart
Normal file
@@ -0,0 +1,34 @@
|
||||
class Balance {
|
||||
final String userId;
|
||||
final String userName;
|
||||
final double totalPaid;
|
||||
final double totalOwed;
|
||||
final double balance;
|
||||
|
||||
Balance({
|
||||
required this.userId,
|
||||
required this.userName,
|
||||
required this.totalPaid,
|
||||
required this.totalOwed,
|
||||
}) : balance = totalPaid - totalOwed;
|
||||
|
||||
bool get shouldReceive => balance > 0;
|
||||
bool get shouldPay => balance < 0;
|
||||
double get absoluteBalance => balance.abs();
|
||||
}
|
||||
|
||||
class Settlement {
|
||||
final String fromUserId;
|
||||
final String fromUserName;
|
||||
final String toUserId;
|
||||
final String toUserName;
|
||||
final double amount;
|
||||
|
||||
Settlement({
|
||||
required this.fromUserId,
|
||||
required this.fromUserName,
|
||||
required this.toUserId,
|
||||
required this.toUserName,
|
||||
required this.amount,
|
||||
});
|
||||
}
|
||||
243
lib/data/models/expense.dart
Normal file
243
lib/data/models/expense.dart
Normal file
@@ -0,0 +1,243 @@
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
enum ExpenseCategory {
|
||||
restaurant,
|
||||
transport,
|
||||
accommodation,
|
||||
activities,
|
||||
shopping,
|
||||
other,
|
||||
}
|
||||
|
||||
enum ExpenseCurrency {
|
||||
eur,
|
||||
usd,
|
||||
gbp,
|
||||
jpy,
|
||||
chf,
|
||||
cad,
|
||||
aud,
|
||||
}
|
||||
|
||||
class ExpenseSplit {
|
||||
final String userId;
|
||||
final String userName;
|
||||
final double amount;
|
||||
final bool isPaid;
|
||||
|
||||
ExpenseSplit({
|
||||
required this.userId,
|
||||
required this.userName,
|
||||
required this.amount,
|
||||
this.isPaid = false,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'userId': userId,
|
||||
'userName': userName,
|
||||
'amount': amount,
|
||||
'isPaid': isPaid,
|
||||
};
|
||||
}
|
||||
|
||||
factory ExpenseSplit.fromMap(Map<String, dynamic> map) {
|
||||
return ExpenseSplit(
|
||||
userId: map['userId'] ?? '',
|
||||
userName: map['userName'] ?? '',
|
||||
amount: (map['amount'] ?? 0.0).toDouble(),
|
||||
isPaid: map['isPaid'] ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class Expense {
|
||||
final String id;
|
||||
final String groupId;
|
||||
final String description;
|
||||
final double amount;
|
||||
final ExpenseCurrency currency;
|
||||
final double amountInEur;
|
||||
final ExpenseCategory category;
|
||||
final String paidById;
|
||||
final String paidByName;
|
||||
final List<ExpenseSplit> splits;
|
||||
final String? receiptUrl;
|
||||
final DateTime date;
|
||||
final DateTime createdAt;
|
||||
final bool isArchived;
|
||||
final bool isEdited;
|
||||
final DateTime? editedAt;
|
||||
|
||||
Expense({
|
||||
this.id = '',
|
||||
required this.groupId,
|
||||
required this.description,
|
||||
required this.amount,
|
||||
required this.currency,
|
||||
required this.amountInEur,
|
||||
required this.category,
|
||||
required this.paidById,
|
||||
required this.paidByName,
|
||||
required this.splits,
|
||||
this.receiptUrl,
|
||||
required this.date,
|
||||
DateTime? createdAt,
|
||||
this.isArchived = false,
|
||||
this.isEdited = false,
|
||||
this.editedAt,
|
||||
}) : createdAt = createdAt ?? DateTime.now();
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'groupId': groupId,
|
||||
'description': description,
|
||||
'amount': amount,
|
||||
'currency': currency.name,
|
||||
'amountInEur': amountInEur,
|
||||
'category': category.name,
|
||||
'paidById': paidById,
|
||||
'paidByName': paidByName,
|
||||
'splits': splits.map((s) => s.toMap()).toList(),
|
||||
'receiptUrl': receiptUrl,
|
||||
'date': Timestamp.fromDate(date),
|
||||
'createdAt': Timestamp.fromDate(createdAt),
|
||||
'isArchived': isArchived,
|
||||
'isEdited': isEdited,
|
||||
'editedAt': editedAt != null ? Timestamp.fromDate(editedAt!) : null,
|
||||
};
|
||||
}
|
||||
|
||||
factory Expense.fromFirestore(DocumentSnapshot doc) {
|
||||
final data = doc.data() as Map<String, dynamic>;
|
||||
final editedAtTimestamp = data['editedAt'] as Timestamp?;
|
||||
|
||||
return Expense(
|
||||
id: doc.id,
|
||||
groupId: data['groupId'] ?? '',
|
||||
description: data['description'] ?? '',
|
||||
amount: (data['amount'] ?? 0.0).toDouble(),
|
||||
currency: ExpenseCurrency.values.firstWhere(
|
||||
(e) => e.name == data['currency'],
|
||||
orElse: () => ExpenseCurrency.eur,
|
||||
),
|
||||
amountInEur: (data['amountInEur'] ?? 0.0).toDouble(),
|
||||
category: ExpenseCategory.values.firstWhere(
|
||||
(e) => e.name == data['category'],
|
||||
orElse: () => ExpenseCategory.other,
|
||||
),
|
||||
paidById: data['paidById'] ?? '',
|
||||
paidByName: data['paidByName'] ?? '',
|
||||
splits: (data['splits'] as List<dynamic>?)
|
||||
?.map((s) => ExpenseSplit.fromMap(s as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[],
|
||||
receiptUrl: data['receiptUrl'],
|
||||
date: (data['date'] as Timestamp?)?.toDate() ?? DateTime.now(),
|
||||
createdAt: (data['createdAt'] as Timestamp?)?.toDate() ?? DateTime.now(),
|
||||
isArchived: data['isArchived'] ?? false,
|
||||
isEdited: data['isEdited'] ?? false,
|
||||
editedAt: editedAtTimestamp?.toDate(),
|
||||
);
|
||||
}
|
||||
|
||||
Expense copyWith({
|
||||
String? id,
|
||||
String? groupId,
|
||||
String? description,
|
||||
double? amount,
|
||||
ExpenseCurrency? currency,
|
||||
double? amountInEur,
|
||||
ExpenseCategory? category,
|
||||
String? paidById,
|
||||
String? paidByName,
|
||||
List<ExpenseSplit>? splits,
|
||||
String? receiptUrl,
|
||||
DateTime? date,
|
||||
DateTime? createdAt,
|
||||
bool? isArchived,
|
||||
bool? isEdited,
|
||||
DateTime? editedAt,
|
||||
}) {
|
||||
return Expense(
|
||||
id: id ?? this.id,
|
||||
groupId: groupId ?? this.groupId,
|
||||
description: description ?? this.description,
|
||||
amount: amount ?? this.amount,
|
||||
currency: currency ?? this.currency,
|
||||
amountInEur: amountInEur ?? this.amountInEur,
|
||||
category: category ?? this.category,
|
||||
paidById: paidById ?? this.paidById,
|
||||
paidByName: paidByName ?? this.paidByName,
|
||||
splits: splits ?? this.splits,
|
||||
receiptUrl: receiptUrl ?? this.receiptUrl,
|
||||
date: date ?? this.date,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
isArchived: isArchived ?? this.isArchived,
|
||||
isEdited: isEdited ?? this.isEdited,
|
||||
editedAt: editedAt ?? this.editedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension ExpenseCategoryExtension on ExpenseCategory {
|
||||
String get displayName {
|
||||
switch (this) {
|
||||
case ExpenseCategory.restaurant:
|
||||
return 'Restaurant';
|
||||
case ExpenseCategory.transport:
|
||||
return 'Transport';
|
||||
case ExpenseCategory.accommodation:
|
||||
return 'Hébergement';
|
||||
case ExpenseCategory.activities:
|
||||
return 'Activités';
|
||||
case ExpenseCategory.shopping:
|
||||
return 'Shopping';
|
||||
case ExpenseCategory.other:
|
||||
return 'Autre';
|
||||
}
|
||||
}
|
||||
|
||||
IconData get icon {
|
||||
switch (this) {
|
||||
case ExpenseCategory.restaurant:
|
||||
return Icons.restaurant;
|
||||
case ExpenseCategory.transport:
|
||||
return Icons.directions_car;
|
||||
case ExpenseCategory.accommodation:
|
||||
return Icons.hotel;
|
||||
case ExpenseCategory.activities:
|
||||
return Icons.attractions;
|
||||
case ExpenseCategory.shopping:
|
||||
return Icons.shopping_bag;
|
||||
case ExpenseCategory.other:
|
||||
return Icons.more_horiz;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ExpenseCurrencyExtension on ExpenseCurrency {
|
||||
String get symbol {
|
||||
switch (this) {
|
||||
case ExpenseCurrency.eur:
|
||||
return '€';
|
||||
case ExpenseCurrency.usd:
|
||||
return '\$';
|
||||
case ExpenseCurrency.gbp:
|
||||
return '£';
|
||||
case ExpenseCurrency.jpy:
|
||||
return '¥';
|
||||
case ExpenseCurrency.chf:
|
||||
return 'CHF';
|
||||
case ExpenseCurrency.cad:
|
||||
return 'CAD';
|
||||
case ExpenseCurrency.aud:
|
||||
return 'AUD';
|
||||
}
|
||||
}
|
||||
|
||||
String get code {
|
||||
return name.toUpperCase();
|
||||
}
|
||||
}
|
||||
@@ -12,11 +12,13 @@ import 'blocs/theme/theme_state.dart';
|
||||
import 'blocs/group/group_bloc.dart';
|
||||
import 'blocs/user/user_bloc.dart';
|
||||
import 'blocs/trip/trip_bloc.dart';
|
||||
import 'blocs/count/count_bloc.dart';
|
||||
import 'repositories/auth_repository.dart';
|
||||
import 'repositories/trip_repository.dart';
|
||||
import 'repositories/user_repository.dart';
|
||||
import 'repositories/group_repository.dart';
|
||||
import 'repositories/message_repository.dart';
|
||||
import 'repositories/count_repository.dart';
|
||||
import 'pages/login.dart';
|
||||
import 'pages/home.dart';
|
||||
import 'pages/signup.dart';
|
||||
@@ -52,6 +54,9 @@ class MyApp extends StatelessWidget {
|
||||
RepositoryProvider<MessageRepository>(
|
||||
create: (context) => MessageRepository(),
|
||||
),
|
||||
RepositoryProvider<CountRepository>(
|
||||
create: (context) => CountRepository(),
|
||||
),
|
||||
],
|
||||
child: MultiBlocProvider(
|
||||
providers: [
|
||||
@@ -74,6 +79,9 @@ class MyApp extends StatelessWidget {
|
||||
BlocProvider(
|
||||
create: (context) => MessageBloc(),
|
||||
),
|
||||
BlocProvider(
|
||||
create: (context) => CountBloc(),
|
||||
),
|
||||
],
|
||||
child: BlocBuilder<ThemeBloc, ThemeState>(
|
||||
builder: (context, themeState) {
|
||||
|
||||
165
lib/repositories/count_repository.dart
Normal file
165
lib/repositories/count_repository.dart
Normal file
@@ -0,0 +1,165 @@
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:firebase_storage/firebase_storage.dart';
|
||||
import 'dart:io';
|
||||
import '../data/models/expense.dart';
|
||||
|
||||
class CountRepository {
|
||||
final FirebaseFirestore _firestore;
|
||||
final FirebaseStorage _storage;
|
||||
|
||||
CountRepository({
|
||||
FirebaseFirestore? firestore,
|
||||
FirebaseStorage? storage,
|
||||
}) : _firestore = firestore ?? FirebaseFirestore.instance,
|
||||
_storage = storage ?? FirebaseStorage.instance;
|
||||
|
||||
// Créer une dépense
|
||||
Future<String> createExpense(Expense expense) async {
|
||||
final docRef = await _firestore
|
||||
.collection('groups')
|
||||
.doc(expense.groupId)
|
||||
.collection('expenses')
|
||||
.add(expense.toMap());
|
||||
|
||||
return docRef.id;
|
||||
}
|
||||
|
||||
// Mettre à jour une dépense
|
||||
Future<void> updateExpense(String groupId, Expense expense) async {
|
||||
await _firestore
|
||||
.collection('groups')
|
||||
.doc(groupId)
|
||||
.collection('expenses')
|
||||
.doc(expense.id)
|
||||
.update(expense.toMap());
|
||||
}
|
||||
|
||||
// Supprimer une dépense
|
||||
Future<void> deleteExpense(String groupId, String expenseId) async {
|
||||
final expense = await _firestore
|
||||
.collection('groups')
|
||||
.doc(groupId)
|
||||
.collection('expenses')
|
||||
.doc(expenseId)
|
||||
.get();
|
||||
|
||||
final data = expense.data();
|
||||
if (data != null && data['receiptUrl'] != null) {
|
||||
await deleteReceipt(data['receiptUrl']);
|
||||
}
|
||||
|
||||
await _firestore
|
||||
.collection('groups')
|
||||
.doc(groupId)
|
||||
.collection('expenses')
|
||||
.doc(expenseId)
|
||||
.delete();
|
||||
}
|
||||
|
||||
// Archiver une dépense
|
||||
Future<void> archiveExpense(String groupId, String expenseId) async {
|
||||
await _firestore
|
||||
.collection('groups')
|
||||
.doc(groupId)
|
||||
.collection('expenses')
|
||||
.doc(expenseId)
|
||||
.update({'isArchived': true});
|
||||
}
|
||||
|
||||
// Marquer une split comme payée
|
||||
Future<void> markSplitAsPaid({
|
||||
required String groupId,
|
||||
required String expenseId,
|
||||
required String userId,
|
||||
}) async {
|
||||
final doc = await _firestore
|
||||
.collection('groups')
|
||||
.doc(groupId)
|
||||
.collection('expenses')
|
||||
.doc(expenseId)
|
||||
.get();
|
||||
|
||||
final expense = Expense.fromFirestore(doc);
|
||||
final updatedSplits = expense.splits.map((split) {
|
||||
if (split.userId == userId) {
|
||||
return ExpenseSplit(
|
||||
userId: split.userId,
|
||||
userName: split.userName,
|
||||
amount: split.amount,
|
||||
isPaid: true,
|
||||
);
|
||||
}
|
||||
return split;
|
||||
}).toList();
|
||||
|
||||
await _firestore
|
||||
.collection('groups')
|
||||
.doc(groupId)
|
||||
.collection('expenses')
|
||||
.doc(expenseId)
|
||||
.update({
|
||||
'splits': updatedSplits.map((s) => s.toMap()).toList(),
|
||||
});
|
||||
}
|
||||
|
||||
// Stream des dépenses d'un groupe
|
||||
Stream<List<Expense>> getExpensesStream(String groupId, {bool includeArchived = false}) {
|
||||
Query query = _firestore
|
||||
.collection('groups')
|
||||
.doc(groupId)
|
||||
.collection('expenses')
|
||||
.orderBy('date', descending: true);
|
||||
|
||||
if (!includeArchived) {
|
||||
query = query.where('isArchived', isEqualTo: false);
|
||||
}
|
||||
|
||||
return query.snapshots().map((snapshot) {
|
||||
return snapshot.docs.map((doc) => Expense.fromFirestore(doc)).toList();
|
||||
});
|
||||
}
|
||||
|
||||
// Uploader un reçu
|
||||
Future<String> uploadReceipt(String groupId, String expenseId, File imageFile) async {
|
||||
final fileName = 'receipts/$groupId/$expenseId/${DateTime.now().millisecondsSinceEpoch}.jpg';
|
||||
final ref = _storage.ref().child(fileName);
|
||||
|
||||
final uploadTask = await ref.putFile(imageFile);
|
||||
return await uploadTask.ref.getDownloadURL();
|
||||
}
|
||||
|
||||
// Supprimer un reçu
|
||||
Future<void> deleteReceipt(String receiptUrl) async {
|
||||
try {
|
||||
final ref = _storage.refFromURL(receiptUrl);
|
||||
await ref.delete();
|
||||
} catch (e) {
|
||||
// Le fichier n'existe peut-être plus
|
||||
print('Erreur lors de la suppression du reçu: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Obtenir les taux de change (API externe ou valeurs fixes)
|
||||
Future<Map<ExpenseCurrency, double>> getExchangeRates() async {
|
||||
// TODO: Intégrer une API de taux de change réels
|
||||
// Pour l'instant, valeurs approximatives
|
||||
return {
|
||||
ExpenseCurrency.eur: 1.0,
|
||||
ExpenseCurrency.usd: 0.92,
|
||||
ExpenseCurrency.gbp: 1.17,
|
||||
ExpenseCurrency.jpy: 0.0062,
|
||||
ExpenseCurrency.chf: 1.05,
|
||||
ExpenseCurrency.cad: 0.68,
|
||||
ExpenseCurrency.aud: 0.61,
|
||||
};
|
||||
}
|
||||
|
||||
// Convertir un montant en EUR
|
||||
Future<double> convertToEur(double amount, ExpenseCurrency currency) async {
|
||||
if (currency == ExpenseCurrency.eur) return amount;
|
||||
|
||||
final rates = await getExchangeRates();
|
||||
final rate = rates[currency] ?? 1.0;
|
||||
return amount * rate;
|
||||
}
|
||||
}
|
||||
226
lib/services/count_service.dart
Normal file
226
lib/services/count_service.dart
Normal file
@@ -0,0 +1,226 @@
|
||||
import 'dart:io';
|
||||
import '../data/models/expense.dart';
|
||||
import '../data/models/balance.dart';
|
||||
import '../repositories/count_repository.dart';
|
||||
import 'error_service.dart';
|
||||
|
||||
class CountService {
|
||||
final CountRepository _countRepository;
|
||||
final ErrorService _errorService;
|
||||
|
||||
CountService({
|
||||
CountRepository? countRepository,
|
||||
ErrorService? errorService,
|
||||
}) : _countRepository = countRepository ?? CountRepository(),
|
||||
_errorService = errorService ?? ErrorService();
|
||||
|
||||
// Créer une dépense
|
||||
Future<String> createExpense(Expense expense, {File? receiptImage}) async {
|
||||
try {
|
||||
final expenseId = await _countRepository.createExpense(expense);
|
||||
|
||||
if (receiptImage != null) {
|
||||
final receiptUrl = await _countRepository.uploadReceipt(
|
||||
expense.groupId,
|
||||
expenseId,
|
||||
receiptImage,
|
||||
);
|
||||
|
||||
final updatedExpense = expense.copyWith(
|
||||
id: expenseId,
|
||||
receiptUrl: receiptUrl,
|
||||
);
|
||||
|
||||
await _countRepository.updateExpense(expense.groupId, updatedExpense);
|
||||
}
|
||||
|
||||
return expenseId;
|
||||
} catch (e) {
|
||||
_errorService.logError('count_service.dart', 'Erreur lors de la création de la dépense: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// Mettre à jour une dépense
|
||||
Future<void> updateExpense(Expense expense, {File? newReceiptImage}) async {
|
||||
try {
|
||||
if (newReceiptImage != null) {
|
||||
// Supprimer l'ancien reçu si existe
|
||||
if (expense.receiptUrl != null) {
|
||||
await _countRepository.deleteReceipt(expense.receiptUrl!);
|
||||
}
|
||||
|
||||
// Uploader le nouveau
|
||||
final receiptUrl = await _countRepository.uploadReceipt(
|
||||
expense.groupId,
|
||||
expense.id,
|
||||
newReceiptImage,
|
||||
);
|
||||
|
||||
expense = expense.copyWith(receiptUrl: receiptUrl);
|
||||
}
|
||||
|
||||
await _countRepository.updateExpense(expense.groupId, expense);
|
||||
} catch (e) {
|
||||
_errorService.logError('count_service.dart', 'Erreur lors de la mise à jour de la dépense: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// Supprimer une dépense
|
||||
Future<void> deleteExpense(String groupId, String expenseId) async {
|
||||
try {
|
||||
await _countRepository.deleteExpense(groupId, expenseId);
|
||||
} catch (e) {
|
||||
_errorService.logError('count_service.dart', 'Erreur lors de la suppression de la dépense: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// Archiver une dépense
|
||||
Future<void> archiveExpense(String groupId, String expenseId) async {
|
||||
try {
|
||||
await _countRepository.archiveExpense(groupId, expenseId);
|
||||
} catch (e) {
|
||||
_errorService.logError('count_service.dart', 'Erreur lors de l\'archivage de la dépense: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// Marquer une split comme payée
|
||||
Future<void> markSplitAsPaid(String groupId, String expenseId, String userId) async {
|
||||
try {
|
||||
await _countRepository.markSplitAsPaid(
|
||||
groupId: groupId,
|
||||
expenseId: expenseId,
|
||||
userId: userId,
|
||||
);
|
||||
} catch (e) {
|
||||
_errorService.logError('count_service.dart', 'Erreur lors du marquage du paiement: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// Stream des dépenses
|
||||
Stream<List<Expense>> getExpensesStream(String groupId, {bool includeArchived = false}) {
|
||||
try {
|
||||
return _countRepository.getExpensesStream(groupId, includeArchived: includeArchived);
|
||||
} catch (e) {
|
||||
_errorService.logError('count_service.dart', 'Erreur lors de la récupération des dépenses: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculer les balances
|
||||
List<Balance> calculateBalances(List<Expense> expenses, List<String> memberIds, Map<String, String> memberNames) {
|
||||
final balances = <String, Balance>{};
|
||||
|
||||
// Initialiser les balances
|
||||
for (final memberId in memberIds) {
|
||||
balances[memberId] = Balance(
|
||||
userId: memberId,
|
||||
userName: memberNames[memberId] ?? 'Unknown',
|
||||
totalPaid: 0,
|
||||
totalOwed: 0,
|
||||
);
|
||||
}
|
||||
|
||||
// Calculer pour chaque dépense
|
||||
for (final expense in expenses) {
|
||||
if (expense.isArchived) continue;
|
||||
|
||||
// Ajouter au total payé
|
||||
final payer = balances[expense.paidById];
|
||||
if (payer != null) {
|
||||
balances[expense.paidById] = Balance(
|
||||
userId: payer.userId,
|
||||
userName: payer.userName,
|
||||
totalPaid: payer.totalPaid + expense.amountInEur,
|
||||
totalOwed: payer.totalOwed,
|
||||
);
|
||||
}
|
||||
|
||||
// Ajouter au total dû pour chaque split
|
||||
for (final split in expense.splits) {
|
||||
if (!split.isPaid) {
|
||||
final debtor = balances[split.userId];
|
||||
if (debtor != null) {
|
||||
balances[split.userId] = Balance(
|
||||
userId: debtor.userId,
|
||||
userName: debtor.userName,
|
||||
totalPaid: debtor.totalPaid,
|
||||
totalOwed: debtor.totalOwed + split.amount,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return balances.values.toList();
|
||||
}
|
||||
|
||||
// Calculer les remboursements optimisés
|
||||
List<Settlement> calculateOptimizedSettlements(List<Balance> balances) {
|
||||
final settlements = <Settlement>[];
|
||||
|
||||
// Créer des copies mutables
|
||||
final creditors = balances.where((b) => b.shouldReceive).map((b) =>
|
||||
{'userId': b.userId, 'userName': b.userName, 'amount': b.balance}
|
||||
).toList();
|
||||
|
||||
final debtors = balances.where((b) => b.shouldPay).map((b) =>
|
||||
{'userId': b.userId, 'userName': b.userName, 'amount': b.absoluteBalance}
|
||||
).toList();
|
||||
|
||||
// Trier par montant décroissant
|
||||
creditors.sort((a, b) => (b['amount'] as double).compareTo(a['amount'] as double));
|
||||
debtors.sort((a, b) => (b['amount'] as double).compareTo(a['amount'] as double));
|
||||
|
||||
int i = 0, j = 0;
|
||||
while (i < creditors.length && j < debtors.length) {
|
||||
final creditor = creditors[i];
|
||||
final debtor = debtors[j];
|
||||
|
||||
final creditorAmount = creditor['amount'] as double;
|
||||
final debtorAmount = debtor['amount'] as double;
|
||||
|
||||
final settleAmount = creditorAmount < debtorAmount ? creditorAmount : debtorAmount;
|
||||
|
||||
settlements.add(Settlement(
|
||||
fromUserId: debtor['userId'] as String,
|
||||
fromUserName: debtor['userName'] as String,
|
||||
toUserId: creditor['userId'] as String,
|
||||
toUserName: creditor['userName'] as String,
|
||||
amount: settleAmount,
|
||||
));
|
||||
|
||||
creditor['amount'] = creditorAmount - settleAmount;
|
||||
debtor['amount'] = debtorAmount - settleAmount;
|
||||
|
||||
if (creditor['amount'] == 0) i++;
|
||||
if (debtor['amount'] == 0) j++;
|
||||
}
|
||||
|
||||
return settlements;
|
||||
}
|
||||
|
||||
// Convertir un montant en EUR
|
||||
Future<double> convertToEur(double amount, ExpenseCurrency currency) async {
|
||||
try {
|
||||
return await _countRepository.convertToEur(amount, currency);
|
||||
} catch (e) {
|
||||
_errorService.logError('count_service.dart', 'Erreur lors de la conversion: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// Obtenir les taux de change
|
||||
Future<Map<ExpenseCurrency, double>> getExchangeRates() async {
|
||||
try {
|
||||
return await _countRepository.getExchangeRates();
|
||||
} catch (e) {
|
||||
_errorService.logError('count_service.dart', 'Erreur lors de la récupération des taux: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user