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