/// This file defines the `ExpenseDetailDialog` widget, which provides a detailed view of a specific expense /// within a group. It allows users to view expense details, such as the amount, payer, date, and splits, and /// perform actions like editing, deleting, or archiving the expense. /// /// The dialog is highly interactive and adapts its UI based on the current user's permissions and the state /// of the expense. It also integrates with BLoC for state management and supports features like receipt display /// and split payment marking. library; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:intl/intl.dart'; import 'package:travel_mate/models/expense_split.dart'; import '../../blocs/expense/expense_bloc.dart'; import '../../blocs/expense/expense_event.dart'; import '../../blocs/user/user_bloc.dart'; import '../../blocs/user/user_state.dart' as user_state; import '../../models/expense.dart'; import '../../models/group.dart'; import 'add_expense_dialog.dart'; /// A stateless widget that displays detailed information about a specific expense. /// /// The `ExpenseDetailDialog` widget shows the expense's amount, payer, date, splits, and receipt. It also /// provides actions for editing, deleting, or archiving the expense, depending on the current user's permissions. class ExpenseDetailDialog extends StatelessWidget { /// The expense to display details for. final Expense expense; /// The group to which the expense belongs. final Group group; /// Creates an `ExpenseDetailDialog` widget. /// /// The [expense] and [group] parameters must not be null. const ExpenseDetailDialog({ super.key, required this.expense, required this.group, }); @override Widget build(BuildContext context) { // Formatters for displaying dates and times. final dateFormat = DateFormat('dd MMMM yyyy', 'fr_FR'); final theme = Theme.of(context); return BlocBuilder( builder: (context, userState) { // Determine the current user and their permissions. final currentUser = userState is user_state.UserLoaded ? userState.user : null; final canEdit = currentUser?.id == expense.paidById; return Dialog( backgroundColor: Colors.transparent, insetPadding: const EdgeInsets.all(16), child: Container( constraints: const BoxConstraints(maxWidth: 500, maxHeight: 700), decoration: BoxDecoration( color: theme.colorScheme.surface, borderRadius: BorderRadius.circular(28), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.2), blurRadius: 20, offset: const Offset(0, 10), ), ], ), child: Column( children: [ // Header with actions Padding( padding: const EdgeInsets.fromLTRB(24, 20, 16, 0), child: Row( children: [ Text( 'Détails', style: theme.textTheme.titleLarge?.copyWith( fontWeight: FontWeight.bold, ), ), const Spacer(), if (canEdit) ...[ IconButton( icon: const Icon(Icons.edit_outlined), tooltip: 'Modifier', onPressed: () { Navigator.of(context).pop(); _showEditDialog(context, currentUser!); }, ), IconButton( icon: const Icon( Icons.delete_outline, color: Colors.red, ), tooltip: 'Supprimer', onPressed: () => _confirmDelete(context), ), ], IconButton( icon: const Icon(Icons.close), onPressed: () => Navigator.of(context).pop(), ), ], ), ), Expanded( child: ListView( padding: const EdgeInsets.fromLTRB(24, 10, 24, 24), children: [ // Icon and Category Center( child: Column( children: [ Container( width: 80, height: 80, decoration: BoxDecoration( color: theme.colorScheme.primaryContainer .withValues(alpha: 0.3), shape: BoxShape.circle, ), child: Icon( expense.category.icon, size: 36, color: theme.colorScheme.primary, ), ), const SizedBox(height: 16), Text( expense.description, style: theme.textTheme.headlineSmall?.copyWith( fontWeight: FontWeight.bold, ), textAlign: TextAlign.center, ), const SizedBox(height: 4), Container( padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 6, ), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(20), ), child: Text( expense.category.displayName, style: theme.textTheme.labelMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ), ], ), ), const SizedBox(height: 32), // Amount Display Container( padding: const EdgeInsets.all(24), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHighest .withValues(alpha: 0.3), borderRadius: BorderRadius.circular(24), border: Border.all( color: theme.colorScheme.outline.withValues( alpha: 0.1, ), ), ), child: Column( children: [ Text( 'Montant total', style: theme.textTheme.labelLarge?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(height: 8), Text( '${expense.amount.toStringAsFixed(2)} ${expense.currency.symbol}', style: theme.textTheme.displaySmall?.copyWith( fontWeight: FontWeight.w800, color: theme.colorScheme.primary, ), ), if (expense.currency != ExpenseCurrency.eur) Padding( padding: const EdgeInsets.only(top: 4), child: Text( '≈ ${expense.amountInEur.toStringAsFixed(2)} €', style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ), ], ), ), const SizedBox(height: 24), // Info Grid Row( children: [ Expanded( child: _buildInfoCard( context, Icons.person_outline, 'Payé par', expense.paidByName, ), ), const SizedBox(width: 12), Expanded( child: _buildInfoCard( context, Icons.calendar_today_outlined, 'Date', dateFormat.format(expense.date), ), ), ], ), const SizedBox(height: 24), // Splits Section Text( 'Répartition', style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, ), ), const SizedBox(height: 12), Container( decoration: BoxDecoration( color: theme.colorScheme.surface, borderRadius: BorderRadius.circular(16), border: Border.all( color: theme.colorScheme.outline.withValues( alpha: 0.1, ), ), ), child: Column( children: expense.splits.asMap().entries.map((entry) { final index = entry.key; final split = entry.value; final isLast = index == expense.splits.length - 1; return Column( children: [ _buildSplitTile(context, split), if (!isLast) Divider( height: 1, indent: 16, endIndent: 16, color: theme.colorScheme.outline.withValues( alpha: 0.1, ), ), ], ); }).toList(), ), ), // Receipt Section if (expense.receiptUrl != null) ...[ const SizedBox(height: 24), Text( 'Reçu', style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, ), ), const SizedBox(height: 12), ClipRRect( borderRadius: BorderRadius.circular(16), child: Stack( alignment: Alignment.center, children: [ Image.network( expense.receiptUrl!, width: double.infinity, height: 200, fit: BoxFit.cover, loadingBuilder: (context, child, loadingProgress) { if (loadingProgress == null) return child; return Container( height: 200, color: theme .colorScheme .surfaceContainerHighest, child: const Center( child: CircularProgressIndicator(), ), ); }, errorBuilder: (context, error, stackTrace) { return Container( height: 200, color: theme .colorScheme .surfaceContainerHighest, child: const Center( child: Icon(Icons.broken_image_outlined), ), ); }, ), Positioned.fill( child: Material( color: Colors.transparent, child: InkWell( onTap: () { showDialog( context: context, builder: (context) => Dialog( backgroundColor: Colors.transparent, insetPadding: EdgeInsets.zero, child: Stack( alignment: Alignment.center, children: [ InteractiveViewer( minScale: 0.5, maxScale: 4.0, child: Image.network( expense.receiptUrl!, fit: BoxFit.contain, ), ), Positioned( top: 40, right: 20, child: IconButton( icon: const Icon( Icons.close, color: Colors.white, size: 30, ), onPressed: () => Navigator.of( context, ).pop(), ), ), ], ), ), ); }, ), ), ), ], ), ), ], // Archive Button if (!expense.isArchived && canEdit) ...[ const SizedBox(height: 32), SizedBox( width: double.infinity, child: OutlinedButton.icon( onPressed: () => _confirmArchive(context), icon: const Icon(Icons.archive_outlined), label: const Text('Archiver cette dépense'), style: OutlinedButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 16), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), ), ), ), ], ], ), ), ], ), ), ); }, ); } Widget _buildInfoCard( BuildContext context, IconData icon, String label, String value, ) { final theme = Theme.of(context); return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: theme.colorScheme.surface, borderRadius: BorderRadius.circular(16), border: Border.all( color: theme.colorScheme.outline.withValues(alpha: 0.1), ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(icon, size: 16, color: theme.colorScheme.onSurfaceVariant), const SizedBox(width: 8), Text( label, style: theme.textTheme.labelMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], ), const SizedBox(height: 8), Text( value, style: theme.textTheme.bodyLarge?.copyWith( fontWeight: FontWeight.w600, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ), ); } Widget _buildSplitTile(BuildContext context, ExpenseSplit split) { return BlocBuilder( builder: (context, userState) { final currentUser = userState is user_state.UserLoaded ? userState.user : null; final isCurrentUser = currentUser?.id == split.userId; final theme = Theme.of(context); return Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( children: [ Container( width: 40, height: 40, decoration: BoxDecoration( color: split.isPaid ? Colors.green.withValues(alpha: 0.1) : Colors.orange.withValues(alpha: 0.1), shape: BoxShape.circle, ), child: Icon( split.isPaid ? Icons.check : Icons.access_time_rounded, color: split.isPaid ? Colors.green : Colors.orange, size: 20, ), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( isCurrentUser ? 'Moi' : split.userName, style: theme.textTheme.bodyLarge?.copyWith( fontWeight: isCurrentUser ? FontWeight.bold : FontWeight.w500, ), ), Text( split.isPaid ? 'Payé' : 'En attente', style: theme.textTheme.bodySmall?.copyWith( color: split.isPaid ? Colors.green : Colors.orange, fontWeight: FontWeight.w500, ), ), ], ), ), Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( '${split.amount.toStringAsFixed(2)} €', style: theme.textTheme.bodyLarge?.copyWith( fontWeight: FontWeight.bold, ), ), if (!split.isPaid && isCurrentUser) GestureDetector( onTap: () { context.read().add( MarkSplitAsPaid( expenseId: expense.id, userId: split.userId, ), ); Navigator.of(context).pop(); }, child: Padding( padding: const EdgeInsets.only(top: 4), child: Text( 'Marquer payé', style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.primary, fontWeight: FontWeight.bold, ), ), ), ), ], ), ], ), ); }, ); } void _showEditDialog(BuildContext context, user_state.UserModel currentUser) { showDialog( context: context, builder: (dialogContext) => BlocProvider.value( value: context.read(), 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().add(DeleteExpense(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().add(ArchiveExpense(expense.id)); Navigator.of(dialogContext).pop(); Navigator.of(context).pop(); }, child: const Text('Archiver'), ), ], ), ); } }