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:
Dayron
2025-10-20 19:22:57 +02:00
parent 633d2c5e5c
commit ce754c1e6c
18 changed files with 2668 additions and 5 deletions

View 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];
}

View 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();
}

View 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];
}

View File

@@ -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;

View 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'),
),
),
],
),
],
),
),
),
),
);
}
}

View 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,
),
),
],
),
],
),
),
);
}
}

View File

@@ -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],
),
],
),
),
),
);
}
}

View 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'),
),
],
),
);
}
}

View 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,
),
);
}
}

View 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,
),
),
);
}
}

View 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],
),
),
],
),
),
],
),
),
);
}
}

View 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,
});
}

View 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();
}
}

View File

@@ -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) {

View 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;
}
}

View 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;
}
}
}