feat: Implement account management features

- Added ExpenseDetailDialog for displaying expense details and actions.
- Created ExpensesTab to list expenses for a group.
- Developed GroupExpensesPage to manage group expenses with tabs for expenses, balances, and settlements.
- Introduced SettlementsTab to show optimized repayment plans.
- Refactored create_trip_content.dart to remove CountBloc and related logic.
- Added Account model to manage user accounts and group members.
- Replaced CountRepository with AccountRepository for account-related operations.
- Removed CountService and CountRepository as part of the refactor.
- Updated main.dart and home.dart to integrate new account management components.
This commit is contained in:
Dayron
2025-10-21 00:42:36 +02:00
parent a3ced0e812
commit c69618cbd9
21 changed files with 182 additions and 747 deletions

View File

@@ -0,0 +1,143 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../blocs/group/group_bloc.dart';
import '../../blocs/group/group_state.dart';
import '../../data/models/group.dart';
import 'group_expenses_page.dart';
class CountContent extends StatelessWidget {
const CountContent({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<GroupBloc, GroupState>(
builder: (context, state) {
if (state is GroupLoading) {
return const Center(child: CircularProgressIndicator());
}
if (state is GroupLoaded) {
if (state.groups.isEmpty) {
return _buildEmptyState();
}
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: state.groups.length,
itemBuilder: (context, index) {
final group = state.groups[index];
return _buildGroupCard(context, group);
},
);
}
if (state is GroupError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error, size: 64, color: Colors.red),
const SizedBox(height: 16),
Text(state.message),
],
),
);
}
return _buildEmptyState();
},
);
}
Widget _buildEmptyState() {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.account_balance_wallet, size: 80, color: Colors.grey),
SizedBox(height: 16),
Text(
'Aucun groupe',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
SizedBox(height: 8),
Text(
'Créez un groupe pour commencer à gérer vos dépenses',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 14, color: Colors.grey),
),
],
),
);
}
Widget _buildGroupCard(BuildContext context, Group group) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Card(
margin: const EdgeInsets.only(bottom: 12),
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: InkWell(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => GroupExpensesPage(group: group),
),
);
},
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: isDark ? Colors.blue[900] : Colors.blue[100],
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.group,
color: Colors.blue,
size: 32,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
group.name,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
'${group.members.length} membre${group.members.length > 1 ? 's' : ''}',
style: TextStyle(
fontSize: 14,
color: isDark ? Colors.grey[400] : Colors.grey[600],
),
),
],
),
),
Icon(
Icons.chevron_right,
color: isDark ? Colors.grey[400] : Colors.grey[600],
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,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/account/account_bloc.dart';
import '../../blocs/account/account_event.dart';
import '../../blocs/account/account_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 = 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;
}
});
},
);
}),
],
),
),
),
const SizedBox(height: 16),
// Reçu
ListTile(
leading: const Icon(Icons.receipt),
title: Text(_receiptImage != null || widget.expenseToEdit?.receiptUrl != null
? 'Reçu ajouté'
: 'Ajouter un reçu'),
subtitle: _receiptImage != null
? const Text('Nouveau reçu sélectionné')
: null,
trailing: IconButton(
icon: const Icon(Icons.add_photo_alternate),
onPressed: _pickImage,
),
shape: RoundedRectangleBorder(
side: BorderSide(color: Colors.grey[300]!),
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 24),
// Boutons
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: _isLoading ? null : () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
),
const SizedBox(width: 16),
Expanded(
child: ElevatedButton(
onPressed: _isLoading ? null : _submit,
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(widget.expenseToEdit == null ? 'Ajouter' : 'Modifier'),
),
),
],
),
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,124 @@
import 'package:flutter/material.dart';
import '../../data/models/balance.dart';
class BalancesTab extends StatelessWidget {
final List<Balance> balances;
const BalancesTab({
super.key,
required this.balances,
});
@override
Widget build(BuildContext context) {
if (balances.isEmpty) {
return const Center(
child: Text('Aucune balance à afficher'),
);
}
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: balances.length,
itemBuilder: (context, index) {
final balance = balances[index];
return _buildBalanceCard(context, balance);
},
);
}
Widget _buildBalanceCard(BuildContext context, Balance balance) {
final isDark = Theme.of(context).brightness == Brightness.dark;
Color balanceColor;
IconData balanceIcon;
String balanceText;
if (balance.shouldReceive) {
balanceColor = Colors.green;
balanceIcon = Icons.arrow_downward;
balanceText = 'À recevoir';
} else if (balance.shouldPay) {
balanceColor = Colors.red;
balanceIcon = Icons.arrow_upward;
balanceText = 'À payer';
} else {
balanceColor = Colors.grey;
balanceIcon = Icons.check_circle;
balanceText = 'Équilibré';
}
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
CircleAvatar(
radius: 24,
backgroundColor: isDark ? Colors.grey[800] : Colors.grey[200],
child: Text(
balance.userName.isNotEmpty
? balance.userName[0].toUpperCase()
: '?',
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
balance.userName,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
'Payé: ${balance.totalPaid.toStringAsFixed(2)} € • Doit: ${balance.totalOwed.toStringAsFixed(2)}',
style: TextStyle(
fontSize: 12,
color: isDark ? Colors.grey[400] : Colors.grey[600],
),
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Row(
children: [
Icon(balanceIcon, size: 16, color: balanceColor),
const SizedBox(width: 4),
Text(
'${balance.absoluteBalance.toStringAsFixed(2)}',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: balanceColor,
),
),
],
),
Text(
balanceText,
style: TextStyle(
fontSize: 12,
color: balanceColor,
),
),
],
),
],
),
),
);
}
}

View File

@@ -0,0 +1,354 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart';
import '../../blocs/account/account_bloc.dart';
import '../../blocs/account/account_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.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Icon(
expense.category.icon,
size: 40,
color: Colors.blue,
),
),
const SizedBox(height: 16),
Text(
expense.description,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
expense.category.displayName,
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
),
],
),
),
const SizedBox(height: 24),
// Montant
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Text(
'${expense.amount.toStringAsFixed(2)} ${expense.currency.symbol}',
style: const TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
color: Colors.green,
),
),
if (expense.currency != ExpenseCurrency.eur)
Text(
'${expense.amountInEur.toStringAsFixed(2)}',
style: TextStyle(
fontSize: 16,
color: Colors.grey[600],
),
),
],
),
),
),
const SizedBox(height: 16),
// Informations
_buildInfoRow(Icons.person, 'Payé par', expense.paidByName),
_buildInfoRow(Icons.calendar_today, 'Date', dateFormat.format(expense.date)),
_buildInfoRow(Icons.access_time, 'Heure', timeFormat.format(expense.createdAt)),
if (expense.isEdited && expense.editedAt != null)
_buildInfoRow(
Icons.edit,
'Modifié le',
dateFormat.format(expense.editedAt!),
),
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 8),
// Divisions
const Text(
'Répartition',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
...expense.splits.map((split) => _buildSplitTile(context, split)),
const SizedBox(height: 16),
// Reçu
if (expense.receiptUrl != null) ...[
const Divider(),
const SizedBox(height: 8),
const Text(
'Reçu',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.network(
expense.receiptUrl!,
fit: BoxFit.cover,
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return const Center(child: CircularProgressIndicator());
},
errorBuilder: (context, error, stackTrace) {
return const Center(
child: Text('Erreur de chargement de l\'image'),
);
},
),
),
],
const SizedBox(height: 16),
// Bouton archiver
if (!expense.isArchived && canEdit)
OutlinedButton.icon(
onPressed: () => _confirmArchive(context),
icon: const Icon(Icons.archive),
label: const Text('Archiver cette dépense'),
),
],
),
),
),
);
},
);
}
Widget _buildInfoRow(IconData icon, String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Icon(icon, size: 20, color: Colors.grey[600]),
const SizedBox(width: 12),
Text(
label,
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
),
const Spacer(),
Text(
value,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
],
),
);
}
Widget _buildSplitTile(BuildContext context, ExpenseSplit split) {
return BlocBuilder<UserBloc, user_state.UserState>(
builder: (context, userState) {
final currentUser = userState is user_state.UserLoaded ? userState.user : null;
final isCurrentUser = currentUser?.id == split.userId;
return ListTile(
leading: CircleAvatar(
backgroundColor: split.isPaid ? Colors.green : Colors.orange,
child: Icon(
split.isPaid ? Icons.check : Icons.pending,
color: Colors.white,
size: 20,
),
),
title: Text(
split.userName,
style: TextStyle(
fontWeight: isCurrentUser ? FontWeight.bold : FontWeight.normal,
),
),
subtitle: Text(split.isPaid ? 'Payé' : 'En attente'),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${split.amount.toStringAsFixed(2)}',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
if (!split.isPaid && isCurrentUser) ...[
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.check_circle, color: Colors.green),
onPressed: () {
context.read<CountBloc>().add(MarkSplitAsPaid(
groupId: expense.groupId,
expenseId: expense.id,
userId: split.userId,
));
Navigator.of(context).pop();
},
),
],
],
),
);
},
);
}
void _showEditDialog(BuildContext context, user_state.UserModel currentUser) {
showDialog(
context: context,
builder: (dialogContext) => BlocProvider.value(
value: context.read<CountBloc>(),
child: AddExpenseDialog(
group: group,
currentUser: currentUser,
expenseToEdit: expense,
),
),
);
}
void _confirmDelete(BuildContext context) {
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Supprimer la dépense'),
content: const Text('Êtes-vous sûr de vouloir supprimer cette dépense ?'),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('Annuler'),
),
TextButton(
onPressed: () {
context.read<CountBloc>().add(DeleteExpense(
groupId: expense.groupId,
expenseId: expense.id,
));
Navigator.of(dialogContext).pop();
Navigator.of(context).pop();
},
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Supprimer'),
),
],
),
);
}
void _confirmArchive(BuildContext context) {
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Archiver la dépense'),
content: const Text('Cette dépense sera archivée et n\'apparaîtra plus dans les calculs de balance.'),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('Annuler'),
),
TextButton(
onPressed: () {
context.read<CountBloc>().add(ArchiveExpense(
groupId: expense.groupId,
expenseId: expense.id,
));
Navigator.of(dialogContext).pop();
Navigator.of(context).pop();
},
child: const Text('Archiver'),
),
],
),
);
}
}

View File

@@ -0,0 +1,142 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../data/models/expense.dart';
import '../../data/models/group.dart';
import 'expense_detail_dialog.dart';
class ExpensesTab extends StatelessWidget {
final List<Expense> expenses;
final Group group;
const ExpensesTab({
super.key,
required this.expenses,
required this.group,
});
@override
Widget build(BuildContext context) {
if (expenses.isEmpty) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.receipt_long, size: 80, color: Colors.grey),
SizedBox(height: 16),
Text(
'Aucune dépense',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
SizedBox(height: 8),
Text(
'Ajoutez votre première dépense',
style: TextStyle(fontSize: 14, color: Colors.grey),
),
],
),
);
}
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: expenses.length,
itemBuilder: (context, index) {
final expense = expenses[index];
return _buildExpenseCard(context, expense);
},
);
}
Widget _buildExpenseCard(BuildContext context, Expense expense) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final dateFormat = DateFormat('dd/MM/yyyy');
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: InkWell(
onTap: () => _showExpenseDetail(context, expense),
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: isDark ? Colors.blue[900] : Colors.blue[100],
borderRadius: BorderRadius.circular(12),
),
child: Icon(
expense.category.icon,
color: Colors.blue,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
expense.description,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
'Payé par ${expense.paidByName}',
style: TextStyle(
fontSize: 14,
color: isDark ? Colors.grey[400] : Colors.grey[600],
),
),
Text(
dateFormat.format(expense.date),
style: TextStyle(
fontSize: 12,
color: isDark ? Colors.grey[500] : Colors.grey[500],
),
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${expense.amount.toStringAsFixed(2)} ${expense.currency.symbol}',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.green,
),
),
if (expense.currency != ExpenseCurrency.eur)
Text(
'${expense.amountInEur.toStringAsFixed(2)}',
style: TextStyle(
fontSize: 12,
color: isDark ? Colors.grey[400] : Colors.grey[600],
),
),
],
),
],
),
),
),
);
}
void _showExpenseDetail(BuildContext context, Expense expense) {
showDialog(
context: context,
builder: (context) => ExpenseDetailDialog(
expense: expense,
group: group,
),
);
}
}

View File

@@ -0,0 +1,116 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../blocs/account/account_bloc.dart';
import '../../blocs/account/account_event.dart';
import '../../blocs/account/account_state.dart';
import '../../blocs/user/user_bloc.dart';
import '../../blocs/user/user_state.dart' as user_state;
import '../../data/models/group.dart';
import 'add_expense_dialog.dart';
import 'balances_tab.dart';
import 'expenses_tab.dart';
import 'settlements_tab.dart';
class GroupExpensesPage extends StatefulWidget {
final Group group;
const GroupExpensesPage({
super.key,
required this.group,
});
@override
State<GroupExpensesPage> createState() => _GroupExpensesPageState();
}
class _GroupExpensesPageState extends State<GroupExpensesPage>
with SingleTickerProviderStateMixin {
late TabController _tabController;
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
context.read<CountBloc>().add(LoadExpenses(widget.group.id));
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.group.name),
bottom: TabBar(
controller: _tabController,
tabs: const [
Tab(text: 'Dépenses', icon: Icon(Icons.receipt_long)),
Tab(text: 'Balances', icon: Icon(Icons.account_balance)),
Tab(text: 'Remboursements', icon: Icon(Icons.payments)),
],
),
),
body: BlocConsumer<CountBloc, CountState>(
listener: (context, state) {
if (state is CountError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: Colors.red,
),
);
}
},
builder: (context, state) {
if (state is CountLoading) {
return const Center(child: CircularProgressIndicator());
}
if (state is ExpensesLoaded) {
return TabBarView(
controller: _tabController,
children: [
ExpensesTab(
expenses: state.expenses,
group: widget.group,
),
BalancesTab(balances: state.balances),
SettlementsTab(settlements: state.settlements),
],
);
}
return const Center(child: Text('Aucune donnée'));
},
),
floatingActionButton: BlocBuilder<UserBloc, user_state.UserState>(
builder: (context, userState) {
if (userState is! user_state.UserLoaded) return const SizedBox();
return FloatingActionButton.extended(
onPressed: () => _showAddExpenseDialog(context, userState.user),
icon: const Icon(Icons.add),
label: const Text('Dépense'),
);
},
),
);
}
void _showAddExpenseDialog(BuildContext context, user_state.UserModel currentUser) {
showDialog(
context: context,
builder: (dialogContext) => BlocProvider.value(
value: context.read<CountBloc>(),
child: AddExpenseDialog(
group: widget.group,
currentUser: currentUser,
),
),
);
}
}

View File

@@ -0,0 +1,164 @@
import 'package:flutter/material.dart';
import '../../data/models/balance.dart';
class SettlementsTab extends StatelessWidget {
final List<Settlement> settlements;
const SettlementsTab({
super.key,
required this.settlements,
});
@override
Widget build(BuildContext context) {
if (settlements.isEmpty) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.check_circle, size: 80, color: Colors.green),
SizedBox(height: 16),
Text(
'Tout est réglé !',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
SizedBox(height: 8),
Text(
'Aucun remboursement nécessaire',
style: TextStyle(fontSize: 14, color: Colors.grey),
),
],
),
);
}
return Column(
children: [
Container(
padding: const EdgeInsets.all(16),
color: Colors.blue.withValues(alpha: 0.1),
child: Row(
children: [
const Icon(Icons.info_outline, color: Colors.blue),
const SizedBox(width: 12),
Expanded(
child: Text(
'Plan de remboursement optimisé (${settlements.length} transaction${settlements.length > 1 ? 's' : ''})',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
Expanded(
child: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: settlements.length,
itemBuilder: (context, index) {
final settlement = settlements[index];
return _buildSettlementCard(context, settlement, index + 1);
},
),
),
],
);
}
Widget _buildSettlementCard(BuildContext context, Settlement settlement, int number) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: isDark ? Colors.blue[900] : Colors.blue[100],
shape: BoxShape.circle,
),
child: Center(
child: Text(
'$number',
style: const TextStyle(
fontWeight: FontWeight.bold,
color: Colors.blue,
),
),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
settlement.fromUserName,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
'doit payer',
style: TextStyle(
fontSize: 12,
color: isDark ? Colors.grey[400] : Colors.grey[600],
),
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.green.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.green),
),
child: Text(
'${settlement.amount.toStringAsFixed(2)}',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.green,
),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
settlement.toUserName,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.right,
),
const SizedBox(height: 4),
Text(
'à recevoir',
style: TextStyle(
fontSize: 12,
color: isDark ? Colors.grey[400] : Colors.grey[600],
),
),
],
),
),
],
),
),
);
}
}