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];
|
List<Object?> get props => [groups];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class GroupLoaded extends GroupState {
|
||||||
|
final List<Group> groups;
|
||||||
|
|
||||||
|
const GroupLoaded(this.groups);
|
||||||
|
}
|
||||||
|
|
||||||
// Succès d'une opération
|
// Succès d'une opération
|
||||||
class GroupOperationSuccess extends GroupState {
|
class GroupOperationSuccess extends GroupState {
|
||||||
final String message;
|
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/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 {
|
class CountContent extends StatelessWidget {
|
||||||
const CountContent({super.key});
|
const CountContent({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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(
|
return const Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.account_balance_wallet, size: 64, color: Colors.green),
|
Icon(Icons.account_balance_wallet, size: 80, color: Colors.grey),
|
||||||
SizedBox(height: 16),
|
SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'Comptes et Budget',
|
'Aucun groupe',
|
||||||
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
|
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
SizedBox(height: 8),
|
SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'Gérez vos dépenses de voyage',
|
'Créez un groupe pour commencer à gérer vos dépenses',
|
||||||
style: TextStyle(fontSize: 16, color: Colors.grey),
|
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/group/group_bloc.dart';
|
||||||
import 'blocs/user/user_bloc.dart';
|
import 'blocs/user/user_bloc.dart';
|
||||||
import 'blocs/trip/trip_bloc.dart';
|
import 'blocs/trip/trip_bloc.dart';
|
||||||
|
import 'blocs/count/count_bloc.dart';
|
||||||
import 'repositories/auth_repository.dart';
|
import 'repositories/auth_repository.dart';
|
||||||
import 'repositories/trip_repository.dart';
|
import 'repositories/trip_repository.dart';
|
||||||
import 'repositories/user_repository.dart';
|
import 'repositories/user_repository.dart';
|
||||||
import 'repositories/group_repository.dart';
|
import 'repositories/group_repository.dart';
|
||||||
import 'repositories/message_repository.dart';
|
import 'repositories/message_repository.dart';
|
||||||
|
import 'repositories/count_repository.dart';
|
||||||
import 'pages/login.dart';
|
import 'pages/login.dart';
|
||||||
import 'pages/home.dart';
|
import 'pages/home.dart';
|
||||||
import 'pages/signup.dart';
|
import 'pages/signup.dart';
|
||||||
@@ -52,6 +54,9 @@ class MyApp extends StatelessWidget {
|
|||||||
RepositoryProvider<MessageRepository>(
|
RepositoryProvider<MessageRepository>(
|
||||||
create: (context) => MessageRepository(),
|
create: (context) => MessageRepository(),
|
||||||
),
|
),
|
||||||
|
RepositoryProvider<CountRepository>(
|
||||||
|
create: (context) => CountRepository(),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
child: MultiBlocProvider(
|
child: MultiBlocProvider(
|
||||||
providers: [
|
providers: [
|
||||||
@@ -74,6 +79,9 @@ class MyApp extends StatelessWidget {
|
|||||||
BlocProvider(
|
BlocProvider(
|
||||||
create: (context) => MessageBloc(),
|
create: (context) => MessageBloc(),
|
||||||
),
|
),
|
||||||
|
BlocProvider(
|
||||||
|
create: (context) => CountBloc(),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
child: BlocBuilder<ThemeBloc, ThemeState>(
|
child: BlocBuilder<ThemeBloc, ThemeState>(
|
||||||
builder: (context, 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
136
pubspec.lock
136
pubspec.lock
@@ -121,6 +121,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.19.1"
|
version: "1.19.1"
|
||||||
|
cross_file:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: cross_file
|
||||||
|
sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.3.4+2"
|
||||||
crypto:
|
crypto:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -201,6 +209,38 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.1"
|
version: "7.0.1"
|
||||||
|
file_selector_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_selector_linux
|
||||||
|
sha256: "54cbbd957e1156d29548c7d9b9ec0c0ebb6de0a90452198683a7d23aed617a33"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.9.3+2"
|
||||||
|
file_selector_macos:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_selector_macos
|
||||||
|
sha256: "88707a3bec4b988aaed3b4df5d7441ee4e987f20b286cddca5d6a8270cab23f2"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.9.4+5"
|
||||||
|
file_selector_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_selector_platform_interface
|
||||||
|
sha256: a3994c26f10378a039faa11de174d7b78eb8f79e4dd0af2a451410c1a5c3f66b
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.6.2"
|
||||||
|
file_selector_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_selector_windows
|
||||||
|
sha256: "320fcfb6f33caa90f0b58380489fc5ac05d99ee94b61aa96ec2bff0ba81d3c2b"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.9.3+4"
|
||||||
firebase_auth:
|
firebase_auth:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -249,6 +289,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.2.0"
|
version: "3.2.0"
|
||||||
|
firebase_storage:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: firebase_storage
|
||||||
|
sha256: d740b9ea0105f27d7286d7ad5957d778bf7fa967796c6e3fc26491cf5245b486
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "13.0.3"
|
||||||
|
firebase_storage_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: firebase_storage_platform_interface
|
||||||
|
sha256: "52a1dfb2c93f49a8e800d4b9aed107d1c0f2f6dd3ebf10a5f7e2222442960e50"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.2.14"
|
||||||
|
firebase_storage_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: firebase_storage_web
|
||||||
|
sha256: d5ce115e3d5a58fddd7631d5246aa8389d8000fa7790ecb04f65bf751bbf6a92
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.10.21"
|
||||||
fixnum:
|
fixnum:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -520,6 +584,78 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.5.4"
|
version: "4.5.4"
|
||||||
|
image_picker:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: image_picker
|
||||||
|
sha256: "736eb56a911cf24d1859315ad09ddec0b66104bc41a7f8c5b96b4e2620cf5041"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.0"
|
||||||
|
image_picker_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image_picker_android
|
||||||
|
sha256: "58a85e6f09fe9c4484d53d18a0bd6271b72c53fce1d05e6f745ae36d8c18efca"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.8.13+5"
|
||||||
|
image_picker_for_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image_picker_for_web
|
||||||
|
sha256: "40c2a6a0da15556dc0f8e38a3246064a971a9f512386c3339b89f76db87269b6"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.0"
|
||||||
|
image_picker_ios:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image_picker_ios
|
||||||
|
sha256: e675c22790bcc24e9abd455deead2b7a88de4b79f7327a281812f14de1a56f58
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.8.13+1"
|
||||||
|
image_picker_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image_picker_linux
|
||||||
|
sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.2.2"
|
||||||
|
image_picker_macos:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image_picker_macos
|
||||||
|
sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.2.2+1"
|
||||||
|
image_picker_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image_picker_platform_interface
|
||||||
|
sha256: "9f143b0dba3e459553209e20cc425c9801af48e6dfa4f01a0fcf927be3f41665"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.11.0"
|
||||||
|
image_picker_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image_picker_windows
|
||||||
|
sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.2.2"
|
||||||
|
intl:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: intl
|
||||||
|
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.20.2"
|
||||||
json_annotation:
|
json_annotation:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ dependencies:
|
|||||||
google_places_flutter: ^2.1.1
|
google_places_flutter: ^2.1.1
|
||||||
http: ^1.5.0
|
http: ^1.5.0
|
||||||
flutter_dotenv: ^6.0.0
|
flutter_dotenv: ^6.0.0
|
||||||
|
image_picker: ^1.2.0
|
||||||
|
intl: ^0.20.2
|
||||||
|
firebase_storage: ^13.0.3
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_launcher_icons: ^0.13.1
|
flutter_launcher_icons: ^0.13.1
|
||||||
|
|||||||
Reference in New Issue
Block a user