582 lines
18 KiB
Dart
582 lines
18 KiB
Dart
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|