Restructuration majeure du projet: migration de flutt vers app, ajout de l'API et mise à jour du site web
This commit is contained in:
262
app/lib/presentation/user/user_communication_page.dart
Normal file
262
app/lib/presentation/user/user_communication_page.dart
Normal file
@@ -0,0 +1,262 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/core/theme/app_theme.dart';
|
||||
import 'package:geosector_app/chat/widgets/conversations_list.dart';
|
||||
import 'package:geosector_app/chat/widgets/chat_screen.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/chat/models/conversation_model.dart';
|
||||
|
||||
class UserCommunicationPage extends StatefulWidget {
|
||||
const UserCommunicationPage({super.key});
|
||||
|
||||
@override
|
||||
State<UserCommunicationPage> createState() => _UserCommunicationPageState();
|
||||
}
|
||||
|
||||
class _UserCommunicationPageState extends State<UserCommunicationPage> {
|
||||
String? _selectedConversationId;
|
||||
late Box<ConversationModel> _conversationsBox;
|
||||
bool _hasConversations = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_checkConversations();
|
||||
}
|
||||
|
||||
Future<void> _checkConversations() async {
|
||||
try {
|
||||
_conversationsBox = Hive.box<ConversationModel>(AppKeys.chatConversationsBoxName);
|
||||
setState(() {
|
||||
_hasConversations = _conversationsBox.values.isNotEmpty;
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la vérification des conversations: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
body: Container(
|
||||
margin: const EdgeInsets.all(16.0),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: theme.shadowColor.withOpacity(0.1),
|
||||
blurRadius: 20,
|
||||
spreadRadius: 1,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: Column(
|
||||
children: [
|
||||
// En-tête du chat
|
||||
Container(
|
||||
height: 70,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primary.withOpacity(0.05),
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: theme.dividerColor.withOpacity(0.1),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.chat_bubble_outline,
|
||||
color: theme.colorScheme.primary,
|
||||
size: 26,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'Messages d\'équipe',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (_hasConversations) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.secondaryColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.green,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'5 en ligne',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: AppTheme.secondaryColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add_circle_outline),
|
||||
iconSize: 28,
|
||||
color: theme.colorScheme.primary,
|
||||
onPressed: () {
|
||||
// TODO: Créer une nouvelle conversation
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Contenu principal
|
||||
Expanded(
|
||||
child: _hasConversations
|
||||
? Row(
|
||||
children: [
|
||||
// Liste des conversations (gauche)
|
||||
Container(
|
||||
width: 320,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
border: Border(
|
||||
right: BorderSide(
|
||||
color: theme.dividerColor.withOpacity(0.1),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: ConversationsList(
|
||||
onConversationSelected: (conversation) {
|
||||
setState(() {
|
||||
// TODO: obtenir l'ID de la conversation à partir de l'objet conversation
|
||||
_selectedConversationId = 'test-conversation-id';
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Zone de conversation (droite)
|
||||
Expanded(
|
||||
child: Container(
|
||||
color: theme.colorScheme.surface,
|
||||
child: _selectedConversationId != null
|
||||
? ChatScreen(conversationId: _selectedConversationId!)
|
||||
: _buildEmptyState(theme),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: _buildNoConversationsMessage(theme),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState(ThemeData theme) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.chat_bubble_outline,
|
||||
size: 80,
|
||||
color: theme.colorScheme.primary.withOpacity(0.3),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Sélectionnez une conversation',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.5),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Choisissez une conversation dans la liste\npour commencer à discuter',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNoConversationsMessage(ThemeData theme) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.forum_outlined,
|
||||
size: 100,
|
||||
color: theme.colorScheme.primary.withOpacity(0.3),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Aucune conversation',
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Vous n\'avez pas encore de conversations.\nCommencez une discussion avec votre équipe !',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
// TODO: Créer une nouvelle conversation
|
||||
},
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Démarrer une conversation'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: theme.colorScheme.primary,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 16,
|
||||
),
|
||||
textStyle: const TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
965
app/lib/presentation/user/user_dashboard_home_page.dart
Normal file
965
app/lib/presentation/user/user_dashboard_home_page.dart
Normal file
@@ -0,0 +1,965 @@
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:geosector_app/core/repositories/user_repository.dart';
|
||||
import 'package:geosector_app/core/repositories/passage_repository.dart';
|
||||
import 'package:geosector_app/core/theme/app_theme.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/presentation/widgets/passages/passages_list_widget.dart';
|
||||
import 'package:geosector_app/presentation/widgets/charts/charts.dart';
|
||||
|
||||
class UserDashboardHomePage extends StatefulWidget {
|
||||
const UserDashboardHomePage({super.key});
|
||||
|
||||
@override
|
||||
State<UserDashboardHomePage> createState() => _UserDashboardHomePageState();
|
||||
}
|
||||
|
||||
class _UserDashboardHomePageState extends State<UserDashboardHomePage> {
|
||||
// Formater une date au format JJ/MM/YYYY
|
||||
String _formatDate(DateTime date) {
|
||||
return '${date.day.toString().padLeft(2, '0')}/${date.month.toString().padLeft(2, '0')}/${date.year}';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
// Utiliser l'instance globale définie dans app.dart
|
||||
final size = MediaQuery.of(context).size;
|
||||
final isDesktop = size.width > 900;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Builder(builder: (context) {
|
||||
// Récupérer l'opération actuelle
|
||||
final operation = userRepository.getCurrentOperation();
|
||||
if (operation != null) {
|
||||
return Text(
|
||||
'${operation.name} (${_formatDate(operation.dateDebut)}-${_formatDate(operation.dateFin)})',
|
||||
style: theme.textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return Text(
|
||||
'Tableau de bord',
|
||||
style: theme.textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
);
|
||||
}
|
||||
}),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Synthèse des passages
|
||||
_buildSummaryCards(isDesktop),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Graphique des passages
|
||||
_buildPassagesChart(context, theme),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Derniers passages
|
||||
_buildRecentPassages(context, theme),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Construction des cartes de synthèse
|
||||
Widget _buildSummaryCards(bool isDesktop) {
|
||||
return Column(
|
||||
children: [
|
||||
_buildCombinedPassagesCard(context, isDesktop),
|
||||
const SizedBox(height: 16),
|
||||
_buildCombinedPaymentsCard(isDesktop),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Méthode pour charger les données de règlements de manière asynchrone
|
||||
Future<Map<String, dynamic>> _loadPaymentData() async {
|
||||
// Utiliser un délai plus long pour s'assurer que les données sont chargées
|
||||
await Future.delayed(const Duration(milliseconds: 1500));
|
||||
|
||||
// Utiliser les instances globales définies dans app.dart
|
||||
final currentUser = userRepository.getCurrentUser();
|
||||
final int? currentUserId = currentUser?.id;
|
||||
|
||||
// Récupérer tous les passages
|
||||
final passages = passageRepository.getAllPassages();
|
||||
|
||||
// Vérifier si les données sont complètement chargées
|
||||
final int totalPassages = passages.length;
|
||||
debugPrint(
|
||||
'Nombre total de passages chargés pour règlements: $totalPassages');
|
||||
|
||||
// Si le nombre de passages est trop faible, on considère que les données ne sont pas complètement chargées
|
||||
if (totalPassages < 100 && !_dataFullyLoaded) {
|
||||
// Attendre un peu plus et réessayer
|
||||
await Future.delayed(const Duration(milliseconds: 1000));
|
||||
// Récupérer à nouveau les passages
|
||||
final newPassages = passageRepository.getAllPassages();
|
||||
final newTotalPassages = newPassages.length;
|
||||
debugPrint(
|
||||
'Nouveau nombre total de passages chargés pour règlements: $newTotalPassages');
|
||||
|
||||
// Si le nombre a augmenté, utiliser les nouvelles données
|
||||
if (newTotalPassages > totalPassages) {
|
||||
passages.clear();
|
||||
passages.addAll(newPassages);
|
||||
debugPrint(
|
||||
'Utilisation des nouvelles données de passages pour règlements');
|
||||
}
|
||||
}
|
||||
|
||||
// Initialiser les montants par type de règlement
|
||||
final Map<int, double> paymentAmounts = {
|
||||
0: 0.0, // Pas de règlement
|
||||
1: 0.0, // Espèces
|
||||
2: 0.0, // Chèques
|
||||
3: 0.0, // CB
|
||||
};
|
||||
|
||||
// Compteur pour les passages avec montant > 0
|
||||
int passagesWithPaymentCount = 0;
|
||||
|
||||
// Parcourir les passages et calculer les montants par type de règlement
|
||||
for (final passage in passages) {
|
||||
// Vérifier si le passage appartient à l'utilisateur actuel
|
||||
if (currentUserId != null && passage.fkUser == currentUserId) {
|
||||
final int typeReglement = passage.fkTypeReglement;
|
||||
|
||||
// Convertir la chaîne de montant en double
|
||||
double montant = 0.0;
|
||||
try {
|
||||
// Gérer les formats possibles (virgule ou point)
|
||||
String montantStr = passage.montant.replaceAll(',', '.');
|
||||
montant = double.tryParse(montantStr) ?? 0.0;
|
||||
} catch (e) {
|
||||
debugPrint('Erreur de conversion du montant: ${passage.montant}');
|
||||
}
|
||||
|
||||
// Ne compter que les passages avec un montant > 0
|
||||
if (montant > 0) {
|
||||
passagesWithPaymentCount++;
|
||||
|
||||
// Ajouter au montant total par type de règlement
|
||||
if (paymentAmounts.containsKey(typeReglement)) {
|
||||
paymentAmounts[typeReglement] =
|
||||
(paymentAmounts[typeReglement] ?? 0.0) + montant;
|
||||
} else {
|
||||
// Si le type n'est pas dans notre map, l'ajouter à la catégorie par défaut (0: Pas de règlement)
|
||||
paymentAmounts[0] = (paymentAmounts[0] ?? 0.0) + montant;
|
||||
// Type de règlement inconnu, ajouté à la catégorie 'Pas de règlement'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Afficher les montants par type de règlement pour le débogage
|
||||
debugPrint('=== MONTANTS PAR TYPE DE RÈGLEMENT ===');
|
||||
paymentAmounts.forEach((typeId, amount) {
|
||||
final typeTitle = AppKeys.typesReglements[typeId]?['titre'] ?? 'Inconnu';
|
||||
debugPrint('Type $typeId ($typeTitle): ${amount.toStringAsFixed(2)} €');
|
||||
});
|
||||
debugPrint('=====================================');
|
||||
|
||||
// Calculer le total des règlements
|
||||
final double totalPayments =
|
||||
paymentAmounts.values.fold(0.0, (sum, amount) => sum + amount);
|
||||
|
||||
// Convertir les montants en objets PaymentData pour le graphique
|
||||
final List<PaymentData> paymentDataList =
|
||||
PaymentUtils.getPaymentDataFromAmounts(paymentAmounts);
|
||||
|
||||
// Vérifier si des types de règlement ont un montant de 0
|
||||
// Si c'est le cas, ajouter un petit montant pour qu'ils apparaissent dans le graphique
|
||||
for (var payment in paymentDataList) {
|
||||
if (payment.amount == 0 && payment.typeId != 0) {
|
||||
// Ignorer le type 0 (Pas de règlement)
|
||||
debugPrint(
|
||||
'Type ${payment.typeId} (${payment.title}) a un montant de 0, ajout d\'un petit montant pour l\'affichage');
|
||||
// Trouver l'index dans la liste
|
||||
final index = paymentDataList.indexOf(payment);
|
||||
// Remplacer par un nouvel objet avec un petit montant
|
||||
paymentDataList[index] = PaymentData(
|
||||
typeId: payment.typeId,
|
||||
amount: 0.01, // Petit montant pour qu'il apparaisse dans le graphique
|
||||
color: payment.color,
|
||||
icon: payment.icon,
|
||||
title: payment.title,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Retourner les données calculées
|
||||
return {
|
||||
'paymentAmounts': paymentAmounts,
|
||||
'totalPayments': totalPayments,
|
||||
'passagesWithPaymentCount': passagesWithPaymentCount,
|
||||
'paymentDataList': paymentDataList,
|
||||
};
|
||||
}
|
||||
|
||||
// Construction d'une carte combinée pour les règlements (liste + graphique)
|
||||
Widget _buildCombinedPaymentsCard(bool isDesktop) {
|
||||
return FutureBuilder<Map<String, dynamic>>(
|
||||
// Utiliser un Future pour s'assurer que les données sont chargées
|
||||
future: _loadPaymentData(),
|
||||
builder: (context, snapshot) {
|
||||
// Afficher un spinner pendant le chargement
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return Card(
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: const SizedBox(
|
||||
height: 300,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('Chargement des données de règlements...'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// En cas d'erreur
|
||||
if (snapshot.hasError) {
|
||||
return Card(
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: SizedBox(
|
||||
height: 300,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Erreur lors du chargement des données: ${snapshot.error}'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Si les données sont disponibles
|
||||
if (snapshot.hasData) {
|
||||
final data = snapshot.data!;
|
||||
final paymentAmounts =
|
||||
Map<int, double>.from(data['paymentAmounts'] as Map);
|
||||
final totalPayments = data['totalPayments'] as double;
|
||||
final passagesWithPaymentCount =
|
||||
data['passagesWithPaymentCount'] as int;
|
||||
final paymentDataList = data['paymentDataList'] as List<PaymentData>;
|
||||
|
||||
return Card(
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
// Symbole euro en arrière-plan
|
||||
Positioned.fill(
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.euro_symbol,
|
||||
size: 180,
|
||||
color: Colors.blue.withOpacity(0.07), // Bleuté et estompé
|
||||
),
|
||||
),
|
||||
),
|
||||
// Contenu principal
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.payments,
|
||||
color: AppTheme.accentColor,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Mes règlements sur $passagesWithPaymentCount passages',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${totalPayments.toStringAsFixed(2)} €',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.accentColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(height: 24),
|
||||
SizedBox(
|
||||
height: 250,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Liste des règlements (côté gauche)
|
||||
Expanded(
|
||||
flex: isDesktop ? 1 : 2,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
...AppKeys.typesReglements.entries
|
||||
.map((entry) {
|
||||
final int typeId = entry.key;
|
||||
final Map<String, dynamic> typeData =
|
||||
entry.value;
|
||||
final double amount =
|
||||
paymentAmounts[typeId] ?? 0.0;
|
||||
final Color color =
|
||||
Color(typeData['couleur'] as int);
|
||||
|
||||
return Padding(
|
||||
padding:
|
||||
const EdgeInsets.only(bottom: 8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
typeData['icon_data'] as IconData,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
typeData['titre'] as String,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${amount.toStringAsFixed(2)} €',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Séparateur vertical
|
||||
if (isDesktop) const VerticalDivider(width: 24),
|
||||
|
||||
// Graphique en camembert (côté droit)
|
||||
Expanded(
|
||||
flex: isDesktop ? 1 : 2,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4.0),
|
||||
// Réduire légèrement la taille pour éviter la troncature
|
||||
child: FittedBox(
|
||||
fit: BoxFit.contain,
|
||||
child: SizedBox(
|
||||
width:
|
||||
200, // Taille réduite pour éviter la troncature
|
||||
height: 200,
|
||||
child: PaymentPieChart(
|
||||
payments: paymentDataList,
|
||||
size:
|
||||
200, // Taille fixe au lieu de double.infinity
|
||||
labelSize:
|
||||
10, // Réduire davantage la taille des étiquettes
|
||||
showPercentage: true,
|
||||
showIcons: false, // Désactiver les icônes
|
||||
showLegend: false,
|
||||
isDonut: true,
|
||||
innerRadius:
|
||||
'55%', // Augmenter légèrement le rayon interne
|
||||
enable3DEffect:
|
||||
false, // Désactiver l'effet 3D pour préserver les couleurs originales
|
||||
effect3DIntensity:
|
||||
0.0, // Pas d'intensité 3D
|
||||
enableEnhancedExplode:
|
||||
false, // Désactiver l'effet d'explosion amélioré
|
||||
useGradient:
|
||||
false, // Ne pas utiliser de dégradés
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Par défaut, retourner un widget vide
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Variable pour suivre si les données sont complètement chargées
|
||||
bool _dataFullyLoaded = false;
|
||||
|
||||
// Méthode pour charger les données de passages de manière asynchrone
|
||||
Future<Map<String, dynamic>> _loadPassageData() async {
|
||||
// Utiliser un délai plus long pour s'assurer que les données sont chargées
|
||||
await Future.delayed(const Duration(milliseconds: 1500));
|
||||
|
||||
// Utiliser les instances globales définies dans app.dart
|
||||
final currentUser = userRepository.getCurrentUser();
|
||||
final int? currentUserId = currentUser?.id;
|
||||
|
||||
// Récupérer tous les passages
|
||||
final passages = passageRepository.getAllPassages();
|
||||
|
||||
// Vérifier si les données sont complètement chargées
|
||||
final int totalPassages = passages.length;
|
||||
debugPrint('Nombre total de passages chargés: $totalPassages');
|
||||
|
||||
// Si le nombre de passages est trop faible, on considère que les données ne sont pas complètement chargées
|
||||
if (totalPassages < 100 && !_dataFullyLoaded) {
|
||||
// Attendre un peu plus et réessayer
|
||||
await Future.delayed(const Duration(milliseconds: 1000));
|
||||
// Récupérer à nouveau les passages
|
||||
final newPassages = passageRepository.getAllPassages();
|
||||
final newTotalPassages = newPassages.length;
|
||||
debugPrint('Nouveau nombre total de passages chargés: $newTotalPassages');
|
||||
|
||||
// Si le nombre a augmenté, utiliser les nouvelles données
|
||||
if (newTotalPassages > totalPassages) {
|
||||
passages.clear();
|
||||
passages.addAll(newPassages);
|
||||
debugPrint('Utilisation des nouvelles données de passages');
|
||||
}
|
||||
}
|
||||
|
||||
// Marquer les données comme complètement chargées pour éviter de refaire cette vérification
|
||||
_dataFullyLoaded = true;
|
||||
|
||||
// Compter les passages par type
|
||||
final Map<int, int> passagesCounts = {
|
||||
1: 0, // Effectués
|
||||
2: 0, // À finaliser
|
||||
3: 0, // Refusés
|
||||
4: 0, // Dons
|
||||
5: 0, // Lots
|
||||
6: 0, // Maisons vides
|
||||
};
|
||||
|
||||
// Créer un map pour compter les types de passages
|
||||
final Map<int, int> typesCount = {};
|
||||
final Map<int, int> userTypesCount = {};
|
||||
|
||||
// Parcourir les passages et les compter par type
|
||||
for (final passage in passages) {
|
||||
final typeId = passage.fkType;
|
||||
final int passageUserId = passage.fkUser;
|
||||
|
||||
// Compter les occurrences de chaque type pour le débogage
|
||||
typesCount[typeId] = (typesCount[typeId] ?? 0) + 1;
|
||||
|
||||
// Vérifier si le passage appartient à l'utilisateur actuel ou est de type 2
|
||||
bool shouldCount = typeId == 2 ||
|
||||
(currentUserId != null && passageUserId == currentUserId);
|
||||
|
||||
if (shouldCount) {
|
||||
// Compter pour les statistiques de l'utilisateur
|
||||
userTypesCount[typeId] = (userTypesCount[typeId] ?? 0) + 1;
|
||||
|
||||
// Ajouter au compteur des passages par type
|
||||
if (passagesCounts.containsKey(typeId)) {
|
||||
passagesCounts[typeId] = passagesCounts[typeId]! + 1;
|
||||
} else {
|
||||
// Si le type n'est pas dans notre map, l'ajouter à la catégorie par défaut (2: À finaliser)
|
||||
passagesCounts[2] = passagesCounts[2]! + 1;
|
||||
// Type de passage inconnu ajouté à 'A finaliser'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculer le total des passages pour l'utilisateur (somme des valeurs dans userTypesCount)
|
||||
final int totalUserPassages =
|
||||
userTypesCount.values.fold(0, (sum, count) => sum + count);
|
||||
|
||||
// Retourner les données calculées
|
||||
return {
|
||||
'passagesCounts': passagesCounts,
|
||||
'totalUserPassages': totalUserPassages,
|
||||
};
|
||||
}
|
||||
|
||||
// Construction d'une carte combinée pour les passages (liste + graphique)
|
||||
Widget _buildCombinedPassagesCard(BuildContext context, bool isDesktop) {
|
||||
return FutureBuilder<Map<String, dynamic>>(
|
||||
// Utiliser un Future pour s'assurer que les données sont chargées
|
||||
future: _loadPassageData(),
|
||||
builder: (context, snapshot) {
|
||||
// Afficher un spinner pendant le chargement
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return Card(
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: const SizedBox(
|
||||
height: 300,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('Chargement des données de passages...'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// En cas d'erreur
|
||||
if (snapshot.hasError) {
|
||||
return Card(
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: SizedBox(
|
||||
height: 300,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Erreur lors du chargement des données: ${snapshot.error}'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Si les données sont disponibles
|
||||
if (snapshot.hasData) {
|
||||
final data = snapshot.data!;
|
||||
final passagesCounts =
|
||||
Map<int, int>.from(data['passagesCounts'] as Map);
|
||||
final totalUserPassages = data['totalUserPassages'] as int;
|
||||
|
||||
return Card(
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.route,
|
||||
color: AppTheme.primaryColor,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Builder(builder: (context) {
|
||||
return Text(
|
||||
'Mes passages',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
Text(
|
||||
totalUserPassages.toString(),
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(height: 24),
|
||||
SizedBox(
|
||||
height: 250,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Liste des passages (côté gauche)
|
||||
Expanded(
|
||||
flex: isDesktop ? 1 : 2,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
...AppKeys.typesPassages.entries.map((entry) {
|
||||
final int typeId = entry.key;
|
||||
final Map<String, dynamic> typeData =
|
||||
entry.value;
|
||||
final int count = passagesCounts[typeId] ?? 0;
|
||||
final Color color =
|
||||
Color(typeData['couleur2'] as int);
|
||||
final IconData iconData =
|
||||
typeData['icon_data'] as IconData;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
iconData,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
typeData['titres'] as String,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
count.toString(),
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Séparateur vertical
|
||||
if (isDesktop) const VerticalDivider(width: 24),
|
||||
|
||||
// Graphique en camembert (côté droit)
|
||||
Expanded(
|
||||
flex: isDesktop ? 1 : 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: PassagePieChart(
|
||||
passagesByType: passagesCounts,
|
||||
size: double.infinity,
|
||||
labelSize: 12,
|
||||
showPercentage: true,
|
||||
showIcons: false,
|
||||
showLegend: false,
|
||||
isDonut: true,
|
||||
innerRadius: '50%',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Par défaut, retourner un widget vide
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Construction du graphique des passages
|
||||
Widget _buildPassagesChart(BuildContext context, ThemeData theme) {
|
||||
// Définir les types de passages à exclure
|
||||
// Selon la mémoire, le type 2 correspond aux passages "A finaliser"
|
||||
// et nous voulons les exclure du comptage pour l'utilisateur actuel
|
||||
final List<int> excludePassageTypes = [2];
|
||||
|
||||
// Utiliser le même mécanisme de chargement asynchrone que pour les autres graphiques
|
||||
return FutureBuilder<Map<String, dynamic>>(
|
||||
// Utiliser le même Future que pour le graphique des passages
|
||||
future: _loadPassageData(),
|
||||
builder: (context, snapshot) {
|
||||
// Afficher un spinner pendant le chargement
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return Card(
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: const SizedBox(
|
||||
height: 350,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('Chargement des données d\'activité...'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// En cas d'erreur
|
||||
if (snapshot.hasError) {
|
||||
return Card(
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: SizedBox(
|
||||
height: 350,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Erreur lors du chargement des données: ${snapshot.error}'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Si les données sont disponibles, afficher le graphique
|
||||
return Card(
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Titre supprimé car déjà présent dans le widget ActivityChart
|
||||
SizedBox(
|
||||
height:
|
||||
350, // Augmentation de la hauteur à 350px pour résoudre le problème de l'axe Y
|
||||
child: ActivityChart(
|
||||
// Utiliser le chargement depuis Hive directement dans le widget
|
||||
loadFromHive: true,
|
||||
// Ne pas filtrer par utilisateur (afficher tous les passages)
|
||||
showAllPassages: true,
|
||||
// Exclure les passages de type 2 (A finaliser)
|
||||
excludePassageTypes: excludePassageTypes,
|
||||
// Afficher les 15 derniers jours
|
||||
daysToShow: 15,
|
||||
periodType: 'Jour',
|
||||
height:
|
||||
350, // Augmentation de la hauteur à 350px aussi dans le widget
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Construction de la liste des derniers passages
|
||||
Widget _buildRecentPassages(BuildContext context, ThemeData theme) {
|
||||
// Utiliser le même mécanisme de chargement asynchrone que pour les autres widgets
|
||||
return FutureBuilder<Map<String, dynamic>>(
|
||||
// Utiliser le même Future que pour les autres widgets
|
||||
future: _loadPassageData(),
|
||||
builder: (context, snapshot) {
|
||||
// Afficher un spinner pendant le chargement
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return Card(
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: const SizedBox(
|
||||
height: 300,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('Chargement des derniers passages...'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// En cas d'erreur
|
||||
if (snapshot.hasError) {
|
||||
return Card(
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: SizedBox(
|
||||
height: 300,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Erreur lors du chargement des données: ${snapshot.error}'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Si les données sont disponibles, afficher la liste des passages récents
|
||||
// Utiliser les instances globales définies dans app.dart
|
||||
final allPassages = passageRepository.getAllPassages();
|
||||
allPassages.sort((a, b) => b.passedAt.compareTo(a.passedAt));
|
||||
|
||||
// Limiter aux 10 passages les plus récents
|
||||
final recentPassagesModels = allPassages.take(10).toList();
|
||||
|
||||
// Convertir les modèles de passage au format attendu par le widget PassagesListWidget
|
||||
final List<Map<String, dynamic>> recentPassages =
|
||||
recentPassagesModels.map((passage) {
|
||||
// Construire l'adresse complète à partir des champs disponibles
|
||||
final String address =
|
||||
'${passage.numero} ${passage.rue}${passage.rueBis.isNotEmpty ? ' ${passage.rueBis}' : ''}, ${passage.ville}';
|
||||
|
||||
// Convertir le montant en double
|
||||
final double amount = double.tryParse(passage.montant) ?? 0.0;
|
||||
|
||||
return {
|
||||
'id': passage.id.toString(),
|
||||
'address': address,
|
||||
'amount': amount,
|
||||
'date': passage.passedAt,
|
||||
'type': passage.fkType,
|
||||
'payment': passage.fkTypeReglement,
|
||||
'name': passage.name,
|
||||
'notes': passage.remarque,
|
||||
'hasReceipt': passage.nomRecu.isNotEmpty,
|
||||
'hasError': passage.emailErreur.isNotEmpty,
|
||||
'fkUser': passage.fkUser, // Ajouter l'ID de l'utilisateur
|
||||
};
|
||||
}).toList();
|
||||
|
||||
return Card(
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16.0, 16.0, 16.0, 0.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Derniers passages',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
// Naviguer vers la page d'historique
|
||||
},
|
||||
child: const Text('Voir tout'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Utilisation du widget commun PassagesListWidget
|
||||
PassagesListWidget(
|
||||
passages: recentPassages,
|
||||
showFilters: false,
|
||||
showSearch: false,
|
||||
showActions: true, // Activer l'affichage des boutons d'action
|
||||
maxPassages: 10,
|
||||
// Exclure les passages de type 2 (À finaliser)
|
||||
excludePassageTypes: [2],
|
||||
// Filtrer par utilisateur courant
|
||||
filterByUserId: userRepository.getCurrentUser()?.id,
|
||||
// Période par défaut (derniers 15 jours)
|
||||
periodFilter: 'last15',
|
||||
onPassageSelected: (passage) {
|
||||
// Action lors de la sélection d'un passage
|
||||
debugPrint('Passage sélectionné: ${passage['id']}');
|
||||
},
|
||||
onDetailsView: (passage) {
|
||||
// Action lors de l'affichage des détails
|
||||
debugPrint('Affichage des détails: ${passage['id']}');
|
||||
},
|
||||
// Callback pour le bouton de modification
|
||||
onPassageEdit: (passage) {
|
||||
// Action lors de la modification d'un passage
|
||||
debugPrint('Modification du passage: ${passage['id']}');
|
||||
// Ici, vous pourriez ouvrir un formulaire d'édition
|
||||
},
|
||||
// Callback pour le bouton de reçu (uniquement pour les passages de type 1)
|
||||
onReceiptView: (passage) {
|
||||
// Action lors de la demande d'affichage du reçu
|
||||
debugPrint(
|
||||
'Affichage du reçu pour le passage: ${passage['id']}');
|
||||
// Ici, vous pourriez générer et afficher un PDF
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
378
app/lib/presentation/user/user_dashboard_page.dart
Normal file
378
app/lib/presentation/user/user_dashboard_page.dart
Normal file
@@ -0,0 +1,378 @@
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:geosector_app/core/repositories/user_repository.dart';
|
||||
import 'package:geosector_app/core/theme/app_theme.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/presentation/widgets/dashboard_layout.dart';
|
||||
|
||||
// Import des pages utilisateur
|
||||
import 'user_dashboard_home_page.dart';
|
||||
import 'user_statistics_page.dart';
|
||||
import 'user_history_page.dart';
|
||||
import 'user_communication_page.dart';
|
||||
import 'user_map_page.dart';
|
||||
|
||||
class UserDashboardPage extends StatefulWidget {
|
||||
const UserDashboardPage({super.key});
|
||||
|
||||
@override
|
||||
State<UserDashboardPage> createState() => _UserDashboardPageState();
|
||||
}
|
||||
|
||||
class _UserDashboardPageState extends State<UserDashboardPage> {
|
||||
int _selectedIndex = 0;
|
||||
|
||||
// Liste des pages à afficher
|
||||
late final List<Widget> _pages;
|
||||
|
||||
// Référence à la boîte Hive pour les paramètres
|
||||
late Box _settingsBox;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_pages = [
|
||||
const UserDashboardHomePage(),
|
||||
const UserStatisticsPage(),
|
||||
const UserHistoryPage(),
|
||||
const UserCommunicationPage(),
|
||||
const UserMapPage(),
|
||||
];
|
||||
|
||||
// Initialiser et charger les paramètres
|
||||
_initSettings();
|
||||
}
|
||||
|
||||
// Initialiser la boîte de paramètres et charger les préférences
|
||||
Future<void> _initSettings() async {
|
||||
try {
|
||||
// Ouvrir la boîte de paramètres si elle n'est pas déjà ouverte
|
||||
if (!Hive.isBoxOpen(AppKeys.settingsBoxName)) {
|
||||
_settingsBox = await Hive.openBox(AppKeys.settingsBoxName);
|
||||
} else {
|
||||
_settingsBox = Hive.box(AppKeys.settingsBoxName);
|
||||
}
|
||||
|
||||
// Charger l'index de page sélectionné
|
||||
final savedIndex = _settingsBox.get('selectedPageIndex');
|
||||
if (savedIndex != null &&
|
||||
savedIndex is int &&
|
||||
savedIndex >= 0 &&
|
||||
savedIndex < _pages.length) {
|
||||
setState(() {
|
||||
_selectedIndex = savedIndex;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du chargement des paramètres: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Sauvegarder les paramètres utilisateur
|
||||
void _saveSettings() {
|
||||
try {
|
||||
// Sauvegarder l'index de page sélectionné
|
||||
_settingsBox.put('selectedPageIndex', _selectedIndex);
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la sauvegarde des paramètres: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Utiliser l'instance globale définie dans app.dart
|
||||
final hasOperation = userRepository.getCurrentOperation() != null;
|
||||
final hasSectors = userRepository.getUserSectors().isNotEmpty;
|
||||
final isStandardUser = userRepository.currentUser != null &&
|
||||
userRepository.currentUser!.role ==
|
||||
'1'; // Rôle 1 = utilisateur standard
|
||||
|
||||
// Si l'utilisateur est standard et n'a pas d'opération assignée ou n'a pas de secteur, afficher un message spécial
|
||||
final bool shouldShowNoOperationMessage = isStandardUser && !hasOperation;
|
||||
final bool shouldShowNoSectorMessage = isStandardUser && !hasSectors;
|
||||
|
||||
// Définir les actions supplémentaires pour l'AppBar
|
||||
List<Widget>? additionalActions;
|
||||
if (shouldShowNoOperationMessage || shouldShowNoSectorMessage) {
|
||||
additionalActions = [
|
||||
// Bouton de déconnexion uniquement si l'utilisateur n'a pas d'opération
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.logout, color: Colors.white),
|
||||
label: const Text('Se déconnecter',
|
||||
style: TextStyle(color: Colors.white)),
|
||||
onPressed: () async {
|
||||
// Utiliser directement userRepository pour la déconnexion
|
||||
await userRepository.logoutWithUI(context);
|
||||
// La redirection est gérée dans logoutWithUI
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
backgroundColor: AppTheme.accentColor,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16), // Espacement à droite
|
||||
];
|
||||
}
|
||||
|
||||
return shouldShowNoOperationMessage
|
||||
? _buildNoOperationMessage(context)
|
||||
: (shouldShowNoSectorMessage
|
||||
? _buildNoSectorMessage(context)
|
||||
: DashboardLayout(
|
||||
title: 'GEOSECTOR',
|
||||
selectedIndex: _selectedIndex,
|
||||
onDestinationSelected: (index) {
|
||||
setState(() {
|
||||
_selectedIndex = index;
|
||||
_saveSettings(); // Sauvegarder l'index de page sélectionné
|
||||
});
|
||||
},
|
||||
destinations: const [
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.dashboard_outlined),
|
||||
selectedIcon: Icon(Icons.dashboard),
|
||||
label: 'Tableau de bord',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.bar_chart_outlined),
|
||||
selectedIcon: Icon(Icons.bar_chart),
|
||||
label: 'Stats',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.history_outlined),
|
||||
selectedIcon: Icon(Icons.history),
|
||||
label: 'Historique',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.chat_outlined),
|
||||
selectedIcon: Icon(Icons.chat),
|
||||
label: 'Messages',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.map_outlined),
|
||||
selectedIcon: Icon(Icons.map),
|
||||
label: 'Carte',
|
||||
),
|
||||
],
|
||||
additionalActions: additionalActions,
|
||||
onNewPassagePressed: () => _showPassageForm(context),
|
||||
body: _pages[_selectedIndex],
|
||||
));
|
||||
}
|
||||
|
||||
// Message pour les utilisateurs sans opération assignée
|
||||
Widget _buildNoOperationMessage(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
constraints: const BoxConstraints(maxWidth: 500),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: theme.shadowColor.withOpacity(0.1),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.warning_amber_rounded,
|
||||
size: 80,
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Aucune opération assignée',
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Vous n\'avez pas encore été affecté à une opération. Veuillez contacter votre administrateur pour obtenir un accès.',
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Message pour les utilisateurs sans secteur assigné
|
||||
Widget _buildNoSectorMessage(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
constraints: const BoxConstraints(maxWidth: 500),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: theme.shadowColor.withOpacity(0.1),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.map_outlined,
|
||||
size: 80,
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Aucun secteur assigné',
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Vous n\'êtes affecté sur aucun secteur. Contactez votre administrateur pour qu\'il vous en affecte au moins un.',
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Affiche le formulaire de passage
|
||||
void _showPassageForm(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(
|
||||
'Nouveau passage',
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Adresse',
|
||||
prefixIcon: const Icon(Icons.location_on),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<int>(
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Type de passage',
|
||||
prefixIcon: const Icon(Icons.category),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
items: const [
|
||||
DropdownMenuItem(
|
||||
value: 1,
|
||||
child: Text('Effectué'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 2,
|
||||
child: Text('À finaliser'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 3,
|
||||
child: Text('Refusé'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 4,
|
||||
child: Text('Don'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 5,
|
||||
child: Text('Lot'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 6,
|
||||
child: Text('Maison vide'),
|
||||
),
|
||||
],
|
||||
onChanged: (value) {},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Commentaire',
|
||||
prefixIcon: const Icon(Icons.comment),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
maxLines: 3,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text(
|
||||
'Annuler',
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
// Enregistrer le passage
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('Passage enregistré avec succès'),
|
||||
backgroundColor: theme.colorScheme.primary,
|
||||
),
|
||||
);
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: theme.colorScheme.primary,
|
||||
foregroundColor: theme.colorScheme.onPrimary,
|
||||
),
|
||||
child: const Text('Enregistrer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
572
app/lib/presentation/user/user_history_page.dart
Normal file
572
app/lib/presentation/user/user_history_page.dart
Normal file
@@ -0,0 +1,572 @@
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:geosector_app/presentation/widgets/passages/passages_list_widget.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/core/repositories/passage_repository.dart';
|
||||
import 'package:geosector_app/core/repositories/user_repository.dart';
|
||||
import 'package:geosector_app/core/data/models/passage_model.dart';
|
||||
|
||||
class UserHistoryPage extends StatefulWidget {
|
||||
const UserHistoryPage({super.key});
|
||||
|
||||
@override
|
||||
State<UserHistoryPage> createState() => _UserHistoryPageState();
|
||||
}
|
||||
|
||||
class _UserHistoryPageState extends State<UserHistoryPage> {
|
||||
// Liste qui contiendra les passages convertis
|
||||
List<Map<String, dynamic>> _convertedPassages = [];
|
||||
|
||||
// Variables pour indiquer l'état de chargement
|
||||
bool _isLoading = true;
|
||||
String _errorMessage = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Charger les passages depuis la box Hive au démarrage
|
||||
_loadPassages();
|
||||
}
|
||||
|
||||
// Méthode pour charger les passages depuis le repository
|
||||
Future<void> _loadPassages() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = '';
|
||||
});
|
||||
|
||||
try {
|
||||
// Utiliser l'instance globale définie dans app.dart
|
||||
|
||||
// Utiliser la propriété passages qui gère déjà l'ouverture de la box
|
||||
final List<PassageModel> allPassages = passageRepository.passages;
|
||||
|
||||
debugPrint('Nombre total de passages dans la box: ${allPassages.length}');
|
||||
|
||||
// Filtrer pour exclure les passages de type 2
|
||||
List<PassageModel> filtered = [];
|
||||
for (var passage in allPassages) {
|
||||
try {
|
||||
if (passage.fkType != 2) {
|
||||
filtered.add(passage);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du filtrage du passage: $e');
|
||||
// Si nous ne pouvons pas accéder à fkType, ne pas ajouter ce passage
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
'Nombre de passages après filtrage (fkType != 2): ${filtered.length}');
|
||||
|
||||
// Afficher la distribution des types de passages pour le débogage
|
||||
final Map<int, int> typeCount = {};
|
||||
for (var passage in filtered) {
|
||||
typeCount[passage.fkType] = (typeCount[passage.fkType] ?? 0) + 1;
|
||||
}
|
||||
typeCount.forEach((type, count) {
|
||||
debugPrint('Type de passage $type: $count passages');
|
||||
});
|
||||
|
||||
// Afficher la plage de dates pour le débogage
|
||||
if (filtered.isNotEmpty) {
|
||||
// Trier par date pour trouver min et max
|
||||
final sortedByDate = List<PassageModel>.from(filtered);
|
||||
sortedByDate.sort((a, b) => a.passedAt.compareTo(b.passedAt));
|
||||
|
||||
final DateTime minDate = sortedByDate.first.passedAt;
|
||||
final DateTime maxDate = sortedByDate.last.passedAt;
|
||||
|
||||
// Log détaillé pour débogage
|
||||
debugPrint(
|
||||
'Plage de dates des passages: ${minDate.toString()} à ${maxDate.toString()}');
|
||||
|
||||
// Afficher les 5 passages les plus anciens et les 5 plus récents pour débogage
|
||||
debugPrint('\n--- 5 PASSAGES LES PLUS ANCIENS ---');
|
||||
for (int i = 0; i < sortedByDate.length && i < 5; i++) {
|
||||
final p = sortedByDate[i];
|
||||
debugPrint(
|
||||
'ID: ${p.id}, Type: ${p.fkType}, Date: ${p.passedAt}, Adresse: ${p.rue}');
|
||||
}
|
||||
|
||||
debugPrint('\n--- 5 PASSAGES LES PLUS RÉCENTS ---');
|
||||
for (int i = sortedByDate.length - 1;
|
||||
i >= 0 && i >= sortedByDate.length - 5;
|
||||
i--) {
|
||||
final p = sortedByDate[i];
|
||||
debugPrint(
|
||||
'ID: ${p.id}, Type: ${p.fkType}, Date: ${p.passedAt}, Adresse: ${p.rue}');
|
||||
}
|
||||
|
||||
// Vérifier la distribution des passages par mois
|
||||
final Map<String, int> monthCount = {};
|
||||
for (var passage in filtered) {
|
||||
final String monthKey =
|
||||
'${passage.passedAt.year}-${passage.passedAt.month.toString().padLeft(2, '0')}';
|
||||
monthCount[monthKey] = (monthCount[monthKey] ?? 0) + 1;
|
||||
}
|
||||
|
||||
debugPrint('\n--- DISTRIBUTION PAR MOIS ---');
|
||||
final sortedMonths = monthCount.keys.toList()..sort();
|
||||
for (var month in sortedMonths) {
|
||||
debugPrint('$month: ${monthCount[month]} passages');
|
||||
}
|
||||
}
|
||||
|
||||
// Convertir les modèles en Maps pour l'affichage avec gestion d'erreurs
|
||||
List<Map<String, dynamic>> passagesMap = [];
|
||||
for (var passage in filtered) {
|
||||
try {
|
||||
final Map<String, dynamic> passageMap =
|
||||
_convertPassageModelToMap(passage);
|
||||
if (passageMap != null) {
|
||||
passagesMap.add(passageMap);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la conversion du passage en map: $e');
|
||||
// Ignorer ce passage et continuer
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint('Nombre de passages après conversion: ${passagesMap.length}');
|
||||
|
||||
// Trier par date (plus récent en premier) avec gestion d'erreurs
|
||||
try {
|
||||
passagesMap.sort((a, b) {
|
||||
try {
|
||||
return (b['date'] as DateTime).compareTo(a['date'] as DateTime);
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la comparaison des dates: $e');
|
||||
return 0; // Garder l'ordre actuel en cas d'erreur
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du tri des passages: $e');
|
||||
// Continuer sans tri en cas d'erreur
|
||||
}
|
||||
|
||||
// Debug: vérifier la plage de dates après conversion et tri
|
||||
if (passagesMap.isNotEmpty) {
|
||||
debugPrint('\n--- PLAGE DE DATES APRÈS CONVERSION ET TRI ---');
|
||||
final firstDate = passagesMap.last['date'] as DateTime;
|
||||
final lastDate = passagesMap.first['date'] as DateTime;
|
||||
debugPrint('Premier passage: ${firstDate.toString()}');
|
||||
debugPrint('Dernier passage: ${lastDate.toString()}');
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_convertedPassages = passagesMap;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_errorMessage = 'Erreur lors du chargement des passages: $e';
|
||||
_isLoading = false;
|
||||
});
|
||||
debugPrint(_errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
// Convertir un modèle de passage en Map pour l'affichage avec gestion renforcée des erreurs
|
||||
Map<String, dynamic> _convertPassageModelToMap(PassageModel passage) {
|
||||
try {
|
||||
// Le passage ne peut pas être null en Dart non-nullable,
|
||||
// mais nous gardons cette structure pour faciliter la gestion des erreurs
|
||||
|
||||
// Construire l'adresse complète avec gestion des erreurs
|
||||
String address = 'Adresse non disponible';
|
||||
try {
|
||||
address = _buildFullAddress(passage);
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la construction de l\'adresse: $e');
|
||||
}
|
||||
|
||||
// Convertir le montant en double avec sécurité
|
||||
double amount = 0.0;
|
||||
try {
|
||||
if (passage.montant.isNotEmpty) {
|
||||
amount = double.parse(passage.montant);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur de conversion du montant: ${passage.montant}: $e');
|
||||
}
|
||||
|
||||
// Récupérer la date avec gestion d'erreur
|
||||
DateTime date;
|
||||
try {
|
||||
date = passage.passedAt;
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la récupération de la date: $e');
|
||||
date = DateTime.now();
|
||||
}
|
||||
|
||||
// Récupérer le type avec gestion d'erreur
|
||||
int type;
|
||||
try {
|
||||
type = passage.fkType;
|
||||
// Si le type n'est pas dans les types connus, utiliser 0 comme valeur par défaut
|
||||
if (!AppKeys.typesPassages.containsKey(type)) {
|
||||
type = 0; // Type inconnu
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la récupération du type: $e');
|
||||
type = 0;
|
||||
}
|
||||
|
||||
// Récupérer le type de règlement avec gestion d'erreur
|
||||
int payment;
|
||||
try {
|
||||
payment = passage.fkTypeReglement;
|
||||
// Si le type de règlement n'est pas dans les types connus, utiliser 0 comme valeur par défaut
|
||||
if (!AppKeys.typesReglements.containsKey(payment)) {
|
||||
payment = 0; // Type de règlement inconnu
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la récupération du type de règlement: $e');
|
||||
payment = 0;
|
||||
}
|
||||
|
||||
// Gérer les champs optionnels
|
||||
String name = '';
|
||||
try {
|
||||
name = passage.name;
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la récupération du nom: $e');
|
||||
}
|
||||
|
||||
String notes = '';
|
||||
try {
|
||||
notes = passage.remarque;
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la récupération des remarques: $e');
|
||||
}
|
||||
|
||||
// Vérifier si un reçu est disponible avec gestion d'erreur
|
||||
bool hasReceipt = false;
|
||||
try {
|
||||
hasReceipt = amount > 0 && type == 1 && passage.nomRecu.isNotEmpty;
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la vérification du reçu: $e');
|
||||
}
|
||||
|
||||
// Vérifier s'il y a une erreur avec gestion d'erreur
|
||||
bool hasError = false;
|
||||
try {
|
||||
hasError = passage.emailErreur.isNotEmpty;
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la vérification des erreurs: $e');
|
||||
}
|
||||
|
||||
// Log pour débogage
|
||||
debugPrint(
|
||||
'Conversion passage ID: ${passage.id}, Type: $type, Date: $date');
|
||||
|
||||
return {
|
||||
'id': passage.id.toString(),
|
||||
'address': address,
|
||||
'amount': amount,
|
||||
'date': date,
|
||||
'type': type,
|
||||
'payment': payment,
|
||||
'name': name,
|
||||
'notes': notes,
|
||||
'hasReceipt': hasReceipt,
|
||||
'hasError': hasError,
|
||||
'fkUser': passage.fkUser, // Ajouter l'ID de l'utilisateur
|
||||
};
|
||||
} catch (e) {
|
||||
debugPrint('ERREUR CRITIQUE lors de la conversion du passage: $e');
|
||||
// Retourner un objet valide par défaut pour éviter les erreurs
|
||||
// Récupérer l'ID de l'utilisateur courant pour l'objet par défaut
|
||||
// Utiliser l'instance globale définie dans app.dart
|
||||
final currentUserId = userRepository.getCurrentUser()?.id;
|
||||
|
||||
return {
|
||||
'id': 'error',
|
||||
'address': 'Adresse non disponible',
|
||||
'amount': 0.0,
|
||||
'date': DateTime.now(),
|
||||
'type': 0,
|
||||
'payment': 0,
|
||||
'name': 'Nom non disponible',
|
||||
'notes': '',
|
||||
'hasReceipt': false,
|
||||
'hasError': true,
|
||||
'fkUser': currentUserId, // Ajouter l'ID de l'utilisateur courant
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Construire l'adresse complète à partir des composants
|
||||
String _buildFullAddress(PassageModel passage) {
|
||||
final List<String> addressParts = [];
|
||||
|
||||
// Numéro et rue
|
||||
if (passage.numero.isNotEmpty) {
|
||||
addressParts.add('${passage.numero} ${passage.rue}');
|
||||
} else {
|
||||
addressParts.add(passage.rue);
|
||||
}
|
||||
|
||||
// Complément rue bis
|
||||
if (passage.rueBis.isNotEmpty) {
|
||||
addressParts.add(passage.rueBis);
|
||||
}
|
||||
|
||||
// Résidence/Bâtiment
|
||||
if (passage.residence.isNotEmpty) {
|
||||
addressParts.add(passage.residence);
|
||||
}
|
||||
|
||||
// Appartement
|
||||
if (passage.appt.isNotEmpty) {
|
||||
addressParts.add('Appt ${passage.appt}');
|
||||
}
|
||||
|
||||
// Niveau
|
||||
if (passage.niveau.isNotEmpty) {
|
||||
addressParts.add('Niveau ${passage.niveau}');
|
||||
}
|
||||
|
||||
// Ville
|
||||
if (passage.ville.isNotEmpty) {
|
||||
addressParts.add(passage.ville);
|
||||
}
|
||||
|
||||
return addressParts.join(', ');
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// Méthode pour afficher les détails d'un passage
|
||||
void _showPassageDetails(Map<String, dynamic> passage) {
|
||||
// Récupérer les informations du type de passage et du type de règlement
|
||||
final typePassage =
|
||||
AppKeys.typesPassages[passage['type']] as Map<String, dynamic>;
|
||||
final typeReglement =
|
||||
AppKeys.typesReglements[passage['payment']] as Map<String, dynamic>;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text('Détails du passage'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildDetailRow('Adresse', passage['address']),
|
||||
_buildDetailRow('Nom', passage['name']),
|
||||
_buildDetailRow('Date',
|
||||
'${passage['date'].day}/${passage['date'].month}/${passage['date'].year}'),
|
||||
_buildDetailRow('Type', typePassage['titre']),
|
||||
_buildDetailRow('Règlement', typeReglement['titre']),
|
||||
_buildDetailRow('Montant', '${passage['amount']}€'),
|
||||
if (passage['notes'] != null &&
|
||||
passage['notes'].toString().isNotEmpty)
|
||||
_buildDetailRow('Notes', passage['notes']),
|
||||
if (passage['hasReceipt'] == true)
|
||||
_buildDetailRow('Reçu', 'Disponible'),
|
||||
if (passage['hasError'] == true)
|
||||
_buildDetailRow('Erreur', 'Détectée', isError: true),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text('Fermer'),
|
||||
),
|
||||
if (passage['hasReceipt'] == true)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
_showReceipt(passage);
|
||||
},
|
||||
child: Text('Voir le reçu'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
_editPassage(passage);
|
||||
},
|
||||
child: Text('Modifier'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Méthode pour éditer un passage
|
||||
void _editPassage(Map<String, dynamic> passage) {
|
||||
// Implémenter l'ouverture d'un formulaire d'édition
|
||||
// Cette méthode pourrait naviguer vers une page d'édition
|
||||
debugPrint('Édition du passage ${passage['id']}');
|
||||
// Exemple: Navigator.of(context).push(MaterialPageRoute(builder: (_) => EditPassagePage(passage: passage)));
|
||||
}
|
||||
|
||||
// Méthode pour afficher un reçu
|
||||
void _showReceipt(Map<String, dynamic> passage) {
|
||||
// Implémenter l'affichage ou la génération d'un reçu
|
||||
// Cette méthode pourrait générer un PDF et l'afficher
|
||||
debugPrint('Affichage du reçu pour le passage ${passage['id']}');
|
||||
// Exemple: Navigator.of(context).push(MaterialPageRoute(builder: (_) => ReceiptPage(passage: passage)));
|
||||
}
|
||||
|
||||
// Helper pour construire une ligne de détails
|
||||
Widget _buildDetailRow(String label, String value, {bool isError = false}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 100,
|
||||
child: Text('$label:',
|
||||
style: TextStyle(fontWeight: FontWeight.bold))),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: isError ? TextStyle(color: Colors.red) : null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Variable pour gérer la recherche
|
||||
String _searchQuery = '';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// En-tête avec bouton de rafraîchissement
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Historique des passages',
|
||||
style: theme.textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: _loadPassages,
|
||||
tooltip: 'Rafraîchir',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Affichage du chargement ou des erreurs
|
||||
if (_isLoading)
|
||||
const Expanded(
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
)
|
||||
else if (_errorMessage.isNotEmpty)
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.error_outline,
|
||||
size: 48, color: Colors.red),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Erreur de chargement',
|
||||
style: theme.textTheme.titleLarge
|
||||
?.copyWith(color: Colors.red),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(_errorMessage),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _loadPassages,
|
||||
child: const Text('Réessayer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
// Utilisation du widget PassagesListWidget pour afficher la liste des passages
|
||||
else
|
||||
Column(
|
||||
children: [
|
||||
// Stat rapide pour l'utilisateur
|
||||
if (_convertedPassages.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(
|
||||
'${_convertedPassages.length} passages au total (${_convertedPassages.where((p) => (p['date'] as DateTime).isAfter(DateTime(2024, 12, 13))).length} de décembre 2024)',
|
||||
style: TextStyle(
|
||||
fontStyle: FontStyle.italic,
|
||||
color: theme.colorScheme.primary),
|
||||
),
|
||||
),
|
||||
// Widget de liste des passages
|
||||
Expanded(
|
||||
child: PassagesListWidget(
|
||||
passages: _convertedPassages,
|
||||
showFilters: true,
|
||||
showSearch: true,
|
||||
showActions: true,
|
||||
initialSearchQuery: _searchQuery,
|
||||
initialTypeFilter:
|
||||
'Tous', // Toujours commencer avec 'Tous' pour voir tous les types
|
||||
initialPaymentFilter: 'Tous',
|
||||
// Exclure les passages de type 2 (À finaliser)
|
||||
excludePassageTypes: [2],
|
||||
// Filtrer par utilisateur courant
|
||||
filterByUserId: userRepository.getCurrentUser()?.id,
|
||||
// Désactiver les filtres de date implicites
|
||||
key: ValueKey(
|
||||
'passages_list_${DateTime.now().millisecondsSinceEpoch}'),
|
||||
onPassageSelected: (passage) {
|
||||
// Action lors de la sélection d'un passage
|
||||
debugPrint('Passage sélectionné: ${passage['id']}');
|
||||
_showPassageDetails(passage);
|
||||
},
|
||||
onDetailsView: (passage) {
|
||||
// Action lors de l'affichage des détails
|
||||
debugPrint('Affichage des détails: ${passage['id']}');
|
||||
_showPassageDetails(passage);
|
||||
},
|
||||
onPassageEdit: (passage) {
|
||||
// Action lors de la modification d'un passage
|
||||
debugPrint('Modification du passage: ${passage['id']}');
|
||||
_editPassage(passage);
|
||||
},
|
||||
onReceiptView: (passage) {
|
||||
// Action lors de la demande d'affichage du reçu
|
||||
debugPrint(
|
||||
'Affichage du reçu pour le passage: ${passage['id']}');
|
||||
_showReceipt(passage);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1084
app/lib/presentation/user/user_map_page.dart
Normal file
1084
app/lib/presentation/user/user_map_page.dart
Normal file
File diff suppressed because it is too large
Load Diff
581
app/lib/presentation/user/user_statistics_page.dart
Normal file
581
app/lib/presentation/user/user_statistics_page.dart
Normal file
@@ -0,0 +1,581 @@
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:geosector_app/core/theme/app_theme.dart';
|
||||
import 'package:geosector_app/presentation/widgets/charts/charts.dart';
|
||||
import 'package:geosector_app/core/repositories/user_repository.dart';
|
||||
|
||||
class UserStatisticsPage extends StatefulWidget {
|
||||
const UserStatisticsPage({super.key});
|
||||
|
||||
@override
|
||||
State<UserStatisticsPage> createState() => _UserStatisticsPageState();
|
||||
}
|
||||
|
||||
class _UserStatisticsPageState extends State<UserStatisticsPage> {
|
||||
// Période sélectionnée
|
||||
String _selectedPeriod = 'Semaine';
|
||||
|
||||
// Secteur sélectionné (0 = tous les secteurs)
|
||||
int _selectedSectorId = 0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final size = MediaQuery.of(context).size;
|
||||
final isDesktop = size.width > 900;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Statistiques',
|
||||
style: theme.textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Filtres
|
||||
_buildFilters(theme, isDesktop),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Graphiques
|
||||
_buildCharts(theme),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Résumé par type de passage
|
||||
_buildPassageTypeSummary(theme, isDesktop),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Résumé par type de règlement
|
||||
_buildPaymentTypeSummary(theme, isDesktop),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Construction des filtres
|
||||
Widget _buildFilters(ThemeData theme, bool isDesktop) {
|
||||
return Card(
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Filtres',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
spacing: 16,
|
||||
runSpacing: 16,
|
||||
children: [
|
||||
// Sélection de la période
|
||||
_buildFilterSection(
|
||||
'Période',
|
||||
['Jour', 'Semaine', 'Mois', 'Année'],
|
||||
_selectedPeriod,
|
||||
(value) {
|
||||
setState(() {
|
||||
_selectedPeriod = value;
|
||||
});
|
||||
},
|
||||
theme,
|
||||
),
|
||||
|
||||
// Sélection du secteur (si l'utilisateur a plusieurs secteurs)
|
||||
_buildSectorSelector(context, theme),
|
||||
|
||||
// Bouton d'application des filtres
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
// Actualiser les statistiques avec les filtres sélectionnés
|
||||
setState(() {
|
||||
// Dans une implémentation réelle, on chargerait ici les données
|
||||
// filtrées par période et secteur
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.filter_list),
|
||||
label: const Text('Appliquer'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.accentColor,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24, vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Construction du sélecteur de secteur
|
||||
Widget _buildSectorSelector(BuildContext context, ThemeData theme) {
|
||||
// Utiliser l'instance globale définie dans app.dart
|
||||
|
||||
// Récupérer les secteurs de l'utilisateur
|
||||
final sectors = userRepository.getUserSectors();
|
||||
|
||||
// Si l'utilisateur n'a qu'un seul secteur, ne pas afficher le sélecteur
|
||||
if (sectors.length <= 1) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
// Créer la liste des options avec "Tous" comme première option
|
||||
final List<DropdownMenuItem<int>> items = [
|
||||
const DropdownMenuItem<int>(
|
||||
value: 0,
|
||||
child: Text('Tous les secteurs'),
|
||||
),
|
||||
];
|
||||
|
||||
// Ajouter les secteurs de l'utilisateur
|
||||
for (final sector in sectors) {
|
||||
items.add(
|
||||
DropdownMenuItem<int>(
|
||||
value: sector.id,
|
||||
child: Text(sector.libelle),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Secteur',
|
||||
style: theme.textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
constraints: const BoxConstraints(maxWidth: 250),
|
||||
child: DropdownButton<int>(
|
||||
value: _selectedSectorId,
|
||||
isExpanded: true,
|
||||
items: items,
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
setState(() {
|
||||
_selectedSectorId = value;
|
||||
});
|
||||
}
|
||||
},
|
||||
hint: const Text('Sélectionner un secteur'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Construction d'une section de filtre
|
||||
Widget _buildFilterSection(
|
||||
String title,
|
||||
List<String> options,
|
||||
String selectedValue,
|
||||
Function(String) onChanged,
|
||||
ThemeData theme,
|
||||
) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SegmentedButton<String>(
|
||||
segments: options.map((option) {
|
||||
return ButtonSegment<String>(
|
||||
value: option,
|
||||
label: Text(option),
|
||||
);
|
||||
}).toList(),
|
||||
selected: {selectedValue},
|
||||
onSelectionChanged: (Set<String> selection) {
|
||||
onChanged(selection.first);
|
||||
},
|
||||
style: ButtonStyle(
|
||||
backgroundColor: MaterialStateProperty.resolveWith<Color>(
|
||||
(Set<MaterialState> states) {
|
||||
if (states.contains(MaterialState.selected)) {
|
||||
return AppTheme.secondaryColor;
|
||||
}
|
||||
return theme.colorScheme.surface;
|
||||
},
|
||||
),
|
||||
foregroundColor: MaterialStateProperty.resolveWith<Color>(
|
||||
(Set<MaterialState> states) {
|
||||
if (states.contains(MaterialState.selected)) {
|
||||
return Colors.white;
|
||||
}
|
||||
return theme.colorScheme.onSurface;
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Construction des graphiques
|
||||
Widget _buildCharts(ThemeData theme) {
|
||||
return Card(
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Passages et règlements par $_selectedPeriod',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
height: 300,
|
||||
child: _buildActivityChart(theme),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Construction du graphique d'activité
|
||||
Widget _buildActivityChart(ThemeData theme) {
|
||||
// Générer des données fictives pour les passages
|
||||
final now = DateTime.now();
|
||||
final List<Map<String, dynamic>> passageData = [];
|
||||
|
||||
// Récupérer le secteur sélectionné (si applicable)
|
||||
final String sectorLabel = _selectedSectorId == 0
|
||||
? 'Tous les secteurs'
|
||||
: userRepository.getSectorById(_selectedSectorId)?.libelle ??
|
||||
'Secteur inconnu';
|
||||
|
||||
// Déterminer la plage de dates en fonction de la période sélectionnée
|
||||
DateTime startDate;
|
||||
int daysToGenerate;
|
||||
|
||||
switch (_selectedPeriod) {
|
||||
case 'Jour':
|
||||
startDate = DateTime(now.year, now.month, now.day);
|
||||
daysToGenerate = 1;
|
||||
break;
|
||||
case 'Semaine':
|
||||
// Début de la semaine (lundi)
|
||||
final weekday = now.weekday;
|
||||
startDate = now.subtract(Duration(days: weekday - 1));
|
||||
daysToGenerate = 7;
|
||||
break;
|
||||
case 'Mois':
|
||||
// Début du mois
|
||||
startDate = DateTime(now.year, now.month, 1);
|
||||
// Calculer le nombre de jours dans le mois
|
||||
final lastDayOfMonth = DateTime(now.year, now.month + 1, 0).day;
|
||||
daysToGenerate = lastDayOfMonth;
|
||||
break;
|
||||
case 'Année':
|
||||
// Début de l'année
|
||||
startDate = DateTime(now.year, 1, 1);
|
||||
daysToGenerate = 365;
|
||||
break;
|
||||
default:
|
||||
startDate = DateTime(now.year, now.month, now.day);
|
||||
daysToGenerate = 7;
|
||||
}
|
||||
|
||||
// Générer des données pour la période sélectionnée
|
||||
for (int i = 0; i < daysToGenerate; i++) {
|
||||
final date = startDate.add(Duration(days: i));
|
||||
|
||||
// Générer des données pour chaque type de passage
|
||||
for (int typeId = 1; typeId <= 6; typeId++) {
|
||||
// Générer un nombre de passages basé sur le jour et le type
|
||||
final count = (typeId == 1 || typeId == 2)
|
||||
? (2 + (date.day % 6)) // Plus de passages pour les types 1 et 2
|
||||
: (date.day % 4); // Moins pour les autres types
|
||||
|
||||
if (count > 0) {
|
||||
passageData.add({
|
||||
'date': date.toIso8601String(),
|
||||
'type_passage': typeId,
|
||||
'nb': count,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Afficher le secteur sélectionné si ce n'est pas "Tous"
|
||||
if (_selectedSectorId != 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16.0),
|
||||
child: Text(
|
||||
'Secteur: $sectorLabel',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
ActivityChart(
|
||||
passageData: passageData,
|
||||
periodType: _selectedPeriod,
|
||||
height: 300,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Construction du résumé par type de passage
|
||||
Widget _buildPassageTypeSummary(ThemeData theme, bool isDesktop) {
|
||||
// Dans une implémentation réelle, ces données seraient filtrées par secteur
|
||||
// en fonction de _selectedSectorId
|
||||
return Card(
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Répartition par type de passage',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
// Graphique circulaire
|
||||
Expanded(
|
||||
flex: isDesktop ? 1 : 2,
|
||||
child: SizedBox(
|
||||
height: 200,
|
||||
child: PassagePieChart(
|
||||
passagesByType: {
|
||||
1: 60, // Effectués
|
||||
2: 15, // À finaliser
|
||||
3: 10, // Refusés
|
||||
4: 8, // Dons
|
||||
5: 5, // Lots
|
||||
6: 2, // Maisons vides
|
||||
},
|
||||
size: 140,
|
||||
labelSize: 12,
|
||||
showPercentage: true,
|
||||
showIcons: false, // Désactiver les icônes
|
||||
isDonut: true, // Activer le format donut
|
||||
innerRadius: '50%' // Rayon interne du donut
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Légende
|
||||
if (isDesktop)
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildLegendItem(
|
||||
'Effectués', '60%', const Color(0xFF4CAF50)),
|
||||
_buildLegendItem(
|
||||
'À finaliser', '15%', const Color(0xFFFF9800)),
|
||||
_buildLegendItem(
|
||||
'Refusés', '10%', const Color(0xFFF44336)),
|
||||
_buildLegendItem('Dons', '8%', const Color(0xFF03A9F4)),
|
||||
_buildLegendItem('Lots', '5%', const Color(0xFF0D47A1)),
|
||||
_buildLegendItem(
|
||||
'Maisons vides', '2%', const Color(0xFF9E9E9E)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (!isDesktop)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
_buildLegendItem('Effectués', '60%', const Color(0xFF4CAF50)),
|
||||
_buildLegendItem(
|
||||
'À finaliser', '15%', const Color(0xFFFF9800)),
|
||||
_buildLegendItem('Refusés', '10%', const Color(0xFFF44336)),
|
||||
_buildLegendItem('Dons', '8%', const Color(0xFF03A9F4)),
|
||||
_buildLegendItem('Lots', '5%', const Color(0xFF0D47A1)),
|
||||
_buildLegendItem(
|
||||
'Maisons vides', '2%', const Color(0xFF9E9E9E)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Construction du résumé par type de règlement
|
||||
Widget _buildPaymentTypeSummary(ThemeData theme, bool isDesktop) {
|
||||
// Dans une implémentation réelle, ces données seraient filtrées par secteur
|
||||
// en fonction de _selectedSectorId
|
||||
return Card(
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Répartition par type de règlement',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
// Graphique circulaire
|
||||
Expanded(
|
||||
flex: isDesktop ? 1 : 2,
|
||||
child: SizedBox(
|
||||
height: 200,
|
||||
child: PieChart(
|
||||
PieChartData(
|
||||
sectionsSpace: 2,
|
||||
centerSpaceRadius: 40,
|
||||
sections: [
|
||||
_buildPieChartSection(
|
||||
'Espèces', 30, const Color(0xFF4CAF50), 0),
|
||||
_buildPieChartSection(
|
||||
'Chèques', 45, const Color(0xFF2196F3), 1),
|
||||
_buildPieChartSection(
|
||||
'CB', 25, const Color(0xFFF44336), 2),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Légende
|
||||
if (isDesktop)
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildLegendItem(
|
||||
'Espèces', '30%', const Color(0xFF4CAF50)),
|
||||
_buildLegendItem(
|
||||
'Chèques', '45%', const Color(0xFF2196F3)),
|
||||
_buildLegendItem('CB', '25%', const Color(0xFFF44336)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (!isDesktop)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
_buildLegendItem('Espèces', '30%', const Color(0xFF4CAF50)),
|
||||
_buildLegendItem('Chèques', '45%', const Color(0xFF2196F3)),
|
||||
_buildLegendItem('CB', '25%', const Color(0xFFF44336)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Construction d'une section de graphique circulaire
|
||||
PieChartSectionData _buildPieChartSection(
|
||||
String title, double value, Color color, int index) {
|
||||
return PieChartSectionData(
|
||||
color: color,
|
||||
value: value,
|
||||
title: '$value%',
|
||||
radius: 60,
|
||||
titleStyle: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Construction d'un élément de légende
|
||||
Widget _buildLegendItem(String title, String value, Color color) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 16,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user