- Ajout système complet de gestion des secteurs avec contours géographiques - Import des contours départementaux depuis GeoJSON - API REST pour la gestion des secteurs (/api/sectors) - Service de géolocalisation pour déterminer les secteurs - Migration base de données avec tables x_departements_contours et sectors_adresses - Interface Flutter pour visualisation et gestion des secteurs - Ajout thème sombre dans l'application - Corrections diverses et optimisations 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
393 lines
12 KiB
Dart
Executable File
393 lines
12 KiB
Dart
Executable File
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
|
import 'package:flutter/material.dart';
|
|
import 'package:geosector_app/core/theme/app_theme.dart';
|
|
import 'package:geosector_app/presentation/widgets/charts/charts.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: WidgetStateProperty.resolveWith<Color>(
|
|
(Set<WidgetState> states) {
|
|
if (states.contains(WidgetState.selected)) {
|
|
return AppTheme.secondaryColor;
|
|
}
|
|
return theme.colorScheme.surface;
|
|
},
|
|
),
|
|
foregroundColor: WidgetStateProperty.resolveWith<Color>(
|
|
(Set<WidgetState> states) {
|
|
if (states.contains(WidgetState.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) {
|
|
return PassageSummaryCard(
|
|
title: 'Répartition par type de passage',
|
|
titleColor: theme.colorScheme.primary,
|
|
titleIcon: Icons.pie_chart,
|
|
height: 300,
|
|
useValueListenable: true,
|
|
userId: userRepository.getCurrentUser()?.id,
|
|
showAllPassages: false,
|
|
excludePassageTypes: const [2], // Exclure "À finaliser"
|
|
isDesktop: isDesktop,
|
|
);
|
|
}
|
|
|
|
// Construction du résumé par type de règlement
|
|
Widget _buildPaymentTypeSummary(ThemeData theme, bool isDesktop) {
|
|
return PaymentSummaryCard(
|
|
title: 'Répartition par type de règlement',
|
|
titleColor: AppTheme.accentColor,
|
|
titleIcon: Icons.pie_chart,
|
|
height: 300,
|
|
useValueListenable: true,
|
|
userId: userRepository.getCurrentUser()?.id,
|
|
showAllPayments: false,
|
|
isDesktop: isDesktop,
|
|
backgroundIcon: Icons.euro_symbol,
|
|
backgroundIconColor: Colors.blue,
|
|
backgroundIconOpacity: 0.05,
|
|
);
|
|
}
|
|
}
|