- Amélioration des interfaces utilisateur sur mobile - Optimisation de la responsivité des composants Flutter - Mise à jour des widgets de chat et communication - Amélioration des formulaires et tableaux - Ajout de nouveaux composants pour l'administration - Optimisation des thèmes et styles visuels 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
590 lines
21 KiB
Dart
Executable File
590 lines
21 KiB
Dart
Executable File
import 'package:flutter/material.dart';
|
|
import 'package:hive_flutter/hive_flutter.dart';
|
|
import 'package:geosector_app/core/theme/app_theme.dart';
|
|
import 'package:geosector_app/core/constants/app_keys.dart';
|
|
import 'package:geosector_app/core/data/models/sector_model.dart';
|
|
import 'package:geosector_app/core/data/models/membre_model.dart';
|
|
import 'package:geosector_app/core/data/models/user_sector_model.dart';
|
|
import 'package:geosector_app/presentation/widgets/charts/charts.dart';
|
|
import 'dart:math' as math;
|
|
|
|
/// Class pour dessiner les petits points blancs sur le fond
|
|
class DotsPainter extends CustomPainter {
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
final paint = Paint()
|
|
..color = Colors.white.withValues(alpha: 0.5)
|
|
..style = PaintingStyle.fill;
|
|
|
|
final random = math.Random(42); // Seed fixe pour consistance
|
|
final numberOfDots = (size.width * size.height) ~/ 1500;
|
|
|
|
for (int i = 0; i < numberOfDots; i++) {
|
|
final x = random.nextDouble() * size.width;
|
|
final y = random.nextDouble() * size.height;
|
|
final radius = 1.0 + random.nextDouble() * 2.0;
|
|
canvas.drawCircle(Offset(x, y), radius, paint);
|
|
}
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
|
}
|
|
|
|
class AdminStatisticsPage extends StatefulWidget {
|
|
const AdminStatisticsPage({super.key});
|
|
|
|
@override
|
|
State<AdminStatisticsPage> createState() => _AdminStatisticsPageState();
|
|
}
|
|
|
|
class _AdminStatisticsPageState extends State<AdminStatisticsPage> {
|
|
// Filtres
|
|
String _selectedPeriod = 'Jour';
|
|
String _selectedSector = 'Tous';
|
|
String _selectedMember = 'Tous';
|
|
int _daysToShow = 15;
|
|
|
|
// Liste des périodes
|
|
final List<String> _periods = ['Jour', 'Semaine', 'Mois', 'Année'];
|
|
|
|
// Listes dynamiques pour les secteurs et membres
|
|
List<String> _sectors = ['Tous'];
|
|
List<String> _members = ['Tous'];
|
|
|
|
// Listes complètes (non filtrées) pour réinitialisation
|
|
List<SectorModel> _allSectors = [];
|
|
List<MembreModel> _allMembers = [];
|
|
List<UserSectorModel> _userSectors = [];
|
|
|
|
// Map pour stocker les IDs correspondants
|
|
final Map<String, int> _sectorIds = {};
|
|
final Map<String, int> _memberIds = {};
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_loadData();
|
|
}
|
|
|
|
void _loadData() {
|
|
// Charger les secteurs depuis Hive
|
|
if (Hive.isBoxOpen(AppKeys.sectorsBoxName)) {
|
|
final sectorsBox = Hive.box<SectorModel>(AppKeys.sectorsBoxName);
|
|
_allSectors = sectorsBox.values.toList();
|
|
}
|
|
|
|
// Charger les membres depuis Hive
|
|
if (Hive.isBoxOpen(AppKeys.membresBoxName)) {
|
|
final membresBox = Hive.box<MembreModel>(AppKeys.membresBoxName);
|
|
_allMembers = membresBox.values.toList();
|
|
}
|
|
|
|
// Charger les associations user-sector depuis Hive
|
|
if (Hive.isBoxOpen(AppKeys.userSectorBoxName)) {
|
|
final userSectorBox = Hive.box<UserSectorModel>(AppKeys.userSectorBoxName);
|
|
_userSectors = userSectorBox.values.toList();
|
|
}
|
|
|
|
// Initialiser les listes avec toutes les données
|
|
_updateSectorsList();
|
|
_updateMembersList();
|
|
}
|
|
|
|
// Mettre à jour la liste des secteurs (filtrée ou complète)
|
|
void _updateSectorsList({int? forMemberId}) {
|
|
setState(() {
|
|
_sectors = ['Tous'];
|
|
_sectorIds.clear();
|
|
|
|
List<SectorModel> sectorsToShow = _allSectors;
|
|
|
|
// Si un membre est sélectionné, filtrer les secteurs
|
|
if (forMemberId != null) {
|
|
final memberSectorIds = _userSectors
|
|
.where((us) => us.id == forMemberId)
|
|
.map((us) => us.fkSector)
|
|
.toSet();
|
|
|
|
sectorsToShow = _allSectors
|
|
.where((sector) => memberSectorIds.contains(sector.id))
|
|
.toList();
|
|
}
|
|
|
|
// Ajouter les secteurs à la liste
|
|
for (final sector in sectorsToShow) {
|
|
_sectors.add(sector.libelle);
|
|
_sectorIds[sector.libelle] = sector.id;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Mettre à jour la liste des membres (filtrée ou complète)
|
|
void _updateMembersList({int? forSectorId}) {
|
|
setState(() {
|
|
_members = ['Tous'];
|
|
_memberIds.clear();
|
|
|
|
List<MembreModel> membersToShow = _allMembers;
|
|
|
|
// Si un secteur est sélectionné, filtrer les membres
|
|
if (forSectorId != null) {
|
|
final sectorMemberIds = _userSectors
|
|
.where((us) => us.fkSector == forSectorId)
|
|
.map((us) => us.id)
|
|
.toSet();
|
|
|
|
membersToShow = _allMembers
|
|
.where((member) => sectorMemberIds.contains(member.id))
|
|
.toList();
|
|
}
|
|
|
|
// Ajouter les membres à la liste
|
|
for (final membre in membersToShow) {
|
|
final fullName = '${membre.firstName} ${membre.name}'.trim();
|
|
_members.add(fullName);
|
|
_memberIds[fullName] = membre.id;
|
|
}
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final screenWidth = MediaQuery.of(context).size.width;
|
|
final isDesktop = screenWidth > 800;
|
|
|
|
// Utiliser un Builder simple avec listeners pour les boxes
|
|
// On écoute les changements et on reconstruit le widget
|
|
return Stack(
|
|
children: [
|
|
// Fond dégradé avec petits points blancs
|
|
Container(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: [Colors.white, Colors.red.shade300],
|
|
),
|
|
),
|
|
child: CustomPaint(
|
|
painter: DotsPainter(),
|
|
child:
|
|
const SizedBox(width: double.infinity, height: double.infinity),
|
|
),
|
|
),
|
|
// Contenu de la page
|
|
SingleChildScrollView(
|
|
padding: const EdgeInsets.all(AppTheme.spacingL),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Filtres
|
|
Card(
|
|
elevation: 2,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius:
|
|
BorderRadius.circular(AppTheme.borderRadiusMedium),
|
|
),
|
|
color: Colors.white, // Fond opaque
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(AppTheme.spacingM),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Filtres',
|
|
style:
|
|
Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
const SizedBox(height: AppTheme.spacingM),
|
|
isDesktop
|
|
? Column(
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(child: _buildPeriodDropdown()),
|
|
const SizedBox(width: AppTheme.spacingM),
|
|
Expanded(child: _buildDaysDropdown()),
|
|
],
|
|
),
|
|
const SizedBox(height: AppTheme.spacingM),
|
|
Row(
|
|
children: [
|
|
Expanded(child: _buildSectorDropdown()),
|
|
const SizedBox(width: AppTheme.spacingM),
|
|
Expanded(child: _buildMemberDropdown()),
|
|
],
|
|
),
|
|
],
|
|
)
|
|
: Column(
|
|
children: [
|
|
_buildPeriodDropdown(),
|
|
const SizedBox(height: AppTheme.spacingM),
|
|
_buildDaysDropdown(),
|
|
const SizedBox(height: AppTheme.spacingM),
|
|
_buildSectorDropdown(),
|
|
const SizedBox(height: AppTheme.spacingM),
|
|
_buildMemberDropdown(),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: AppTheme.spacingL),
|
|
|
|
// Graphique d'activité principal
|
|
Card(
|
|
elevation: 2,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius:
|
|
BorderRadius.circular(AppTheme.borderRadiusMedium),
|
|
),
|
|
color: Colors.white, // Fond opaque
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(AppTheme.spacingM),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Évolution des passages',
|
|
style:
|
|
Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
const SizedBox(height: AppTheme.spacingM),
|
|
ActivityChart(
|
|
height: 350,
|
|
showAllPassages: _selectedMember == 'Tous', // Afficher tous les passages seulement si "Tous" est sélectionné
|
|
title: '',
|
|
daysToShow: _daysToShow,
|
|
periodType: _selectedPeriod,
|
|
userId: _selectedMember != 'Tous'
|
|
? _getMemberIdFromName(_selectedMember)
|
|
: null,
|
|
// Note: Le filtre par secteur nécessite une modification du widget ActivityChart
|
|
// Pour filtrer par secteur, il faudrait ajouter un paramètre sectorId au widget
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: AppTheme.spacingL),
|
|
|
|
// Graphiques de répartition
|
|
isDesktop
|
|
? Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Expanded(
|
|
child: _buildChartCard(
|
|
'Répartition par type de passage',
|
|
PassageSummaryCard(
|
|
title: '',
|
|
titleColor: AppTheme.primaryColor,
|
|
titleIcon: Icons.pie_chart,
|
|
height: 300,
|
|
useValueListenable: true,
|
|
showAllPassages: _selectedMember == 'Tous',
|
|
excludePassageTypes: const [
|
|
2
|
|
], // Exclure "À finaliser"
|
|
userId: _selectedMember != 'Tous'
|
|
? _getMemberIdFromName(_selectedMember)
|
|
: null,
|
|
// Note: Le filtre par secteur nécessite une modification du widget PassageSummaryCard
|
|
isDesktop:
|
|
MediaQuery.of(context).size.width > 800,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: AppTheme.spacingM),
|
|
Expanded(
|
|
child: _buildChartCard(
|
|
'Répartition par mode de paiement',
|
|
PaymentPieChart(
|
|
useValueListenable: true,
|
|
showAllPassages: _selectedMember == 'Tous',
|
|
userId: _selectedMember != 'Tous'
|
|
? _getMemberIdFromName(_selectedMember)
|
|
: null,
|
|
size: 300,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
)
|
|
: Column(
|
|
children: [
|
|
_buildChartCard(
|
|
'Répartition par type de passage',
|
|
PassageSummaryCard(
|
|
title: '',
|
|
titleColor: AppTheme.primaryColor,
|
|
titleIcon: Icons.pie_chart,
|
|
height: 300,
|
|
useValueListenable: true,
|
|
showAllPassages: _selectedMember == 'Tous',
|
|
excludePassageTypes: const [
|
|
2
|
|
], // Exclure "À finaliser"
|
|
userId: _selectedMember != 'Tous'
|
|
? _getMemberIdFromName(_selectedMember)
|
|
: null,
|
|
// Note: Le filtre par secteur nécessite une modification du widget PassageSummaryCard
|
|
isDesktop: MediaQuery.of(context).size.width > 800,
|
|
),
|
|
),
|
|
const SizedBox(height: AppTheme.spacingM),
|
|
_buildChartCard(
|
|
'Répartition par mode de paiement',
|
|
PaymentPieChart(
|
|
useValueListenable: true,
|
|
showAllPassages: _selectedMember == 'Tous',
|
|
userId: _selectedMember != 'Tous'
|
|
? _getMemberIdFromName(_selectedMember)
|
|
: null,
|
|
size: 300,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
// Dropdown pour la période
|
|
Widget _buildPeriodDropdown() {
|
|
return InputDecorator(
|
|
decoration: InputDecoration(
|
|
labelText: 'Période',
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
|
|
),
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: AppTheme.spacingM,
|
|
vertical: AppTheme.spacingS,
|
|
),
|
|
),
|
|
child: DropdownButtonHideUnderline(
|
|
child: DropdownButton<String>(
|
|
value: _selectedPeriod,
|
|
isDense: true,
|
|
isExpanded: true,
|
|
items: _periods.map((String period) {
|
|
return DropdownMenuItem<String>(
|
|
value: period,
|
|
child: Text(period),
|
|
);
|
|
}).toList(),
|
|
onChanged: (String? newValue) {
|
|
if (newValue != null) {
|
|
setState(() {
|
|
_selectedPeriod = newValue;
|
|
});
|
|
}
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// Dropdown pour le nombre de jours
|
|
Widget _buildDaysDropdown() {
|
|
return InputDecorator(
|
|
decoration: InputDecoration(
|
|
labelText: 'Nombre de jours',
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
|
|
),
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: AppTheme.spacingM,
|
|
vertical: AppTheme.spacingS,
|
|
),
|
|
),
|
|
child: DropdownButtonHideUnderline(
|
|
child: DropdownButton<int>(
|
|
value: _daysToShow,
|
|
isDense: true,
|
|
isExpanded: true,
|
|
items: [7, 15, 30, 60, 90, 180, 365].map((int days) {
|
|
return DropdownMenuItem<int>(
|
|
value: days,
|
|
child: Text('$days jours'),
|
|
);
|
|
}).toList(),
|
|
onChanged: (int? newValue) {
|
|
if (newValue != null) {
|
|
setState(() {
|
|
_daysToShow = newValue;
|
|
});
|
|
}
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// Dropdown pour les secteurs
|
|
Widget _buildSectorDropdown() {
|
|
return InputDecorator(
|
|
decoration: InputDecoration(
|
|
labelText: 'Secteur',
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
|
|
),
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: AppTheme.spacingM,
|
|
vertical: AppTheme.spacingS,
|
|
),
|
|
),
|
|
child: DropdownButtonHideUnderline(
|
|
child: DropdownButton<String>(
|
|
value: _selectedSector,
|
|
isDense: true,
|
|
isExpanded: true,
|
|
items: _sectors.map((String sector) {
|
|
return DropdownMenuItem<String>(
|
|
value: sector,
|
|
child: Text(sector),
|
|
);
|
|
}).toList(),
|
|
onChanged: (String? newValue) {
|
|
if (newValue != null) {
|
|
setState(() {
|
|
_selectedSector = newValue;
|
|
|
|
// Si "Tous" est sélectionné, réinitialiser la liste des membres
|
|
if (newValue == 'Tous') {
|
|
_updateMembersList();
|
|
// Garder le membre sélectionné s'il existe
|
|
} else {
|
|
// Sinon, filtrer les membres pour ce secteur
|
|
final sectorId = _getSectorIdFromName(newValue);
|
|
_updateMembersList(forSectorId: sectorId);
|
|
|
|
// Si le membre actuellement sélectionné n'est pas dans la liste filtrée
|
|
if (_selectedMember == 'Tous' || !_members.contains(_selectedMember)) {
|
|
// Auto-sélectionner le premier membre du secteur (après "Tous")
|
|
// Puisque chaque secteur a au moins un membre, il y aura toujours un membre à sélectionner
|
|
if (_members.length > 1) {
|
|
_selectedMember = _members[1]; // Index 1 car 0 est "Tous"
|
|
}
|
|
}
|
|
// Si le membre sélectionné est dans la liste, on le garde
|
|
// Les graphiques afficheront ses données
|
|
}
|
|
});
|
|
}
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// Dropdown pour les membres
|
|
Widget _buildMemberDropdown() {
|
|
return InputDecorator(
|
|
decoration: InputDecoration(
|
|
labelText: 'Membre',
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
|
|
),
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: AppTheme.spacingM,
|
|
vertical: AppTheme.spacingS,
|
|
),
|
|
),
|
|
child: DropdownButtonHideUnderline(
|
|
child: DropdownButton<String>(
|
|
value: _selectedMember,
|
|
isDense: true,
|
|
isExpanded: true,
|
|
items: _members.map((String member) {
|
|
return DropdownMenuItem<String>(
|
|
value: member,
|
|
child: Text(member),
|
|
);
|
|
}).toList(),
|
|
onChanged: (String? newValue) {
|
|
if (newValue != null) {
|
|
setState(() {
|
|
_selectedMember = newValue;
|
|
|
|
// Si "Tous" est sélectionné, réinitialiser la liste des secteurs
|
|
if (newValue == 'Tous') {
|
|
_updateSectorsList();
|
|
// On peut réinitialiser le secteur car "Tous" les membres = pas de filtre secteur pertinent
|
|
_selectedSector = 'Tous';
|
|
} else {
|
|
// Sinon, filtrer les secteurs pour ce membre
|
|
final memberId = _getMemberIdFromName(newValue);
|
|
_updateSectorsList(forMemberId: memberId);
|
|
|
|
// Si le secteur actuellement sélectionné n'est plus dans la liste, réinitialiser
|
|
if (_selectedSector != 'Tous' && !_sectors.contains(_selectedSector)) {
|
|
_selectedSector = 'Tous';
|
|
}
|
|
// Si le secteur est toujours dans la liste, on le garde sélectionné
|
|
}
|
|
});
|
|
}
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// Widget pour envelopper un graphique dans une carte
|
|
Widget _buildChartCard(String title, Widget chart) {
|
|
return Card(
|
|
elevation: 2,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
|
|
),
|
|
color: Colors.white, // Fond opaque
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(AppTheme.spacingM),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
title,
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
const SizedBox(height: AppTheme.spacingM),
|
|
chart,
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// Méthode utilitaire pour obtenir l'ID membre à partir de son nom
|
|
int? _getMemberIdFromName(String name) {
|
|
if (name == 'Tous') return null;
|
|
return _memberIds[name];
|
|
}
|
|
|
|
// Méthode utilitaire pour obtenir l'ID du secteur à partir de son nom
|
|
int? _getSectorIdFromName(String name) {
|
|
if (name == 'Tous') return null;
|
|
return _sectorIds[name];
|
|
}
|
|
|
|
// Méthode pour obtenir tous les IDs des membres d'un secteur
|
|
|
|
// Méthode pour déterminer quel userId utiliser pour les graphiques
|
|
|
|
// Méthode pour déterminer si on doit afficher tous les passages
|
|
}
|