Initialisation du projet geosector complet (web + flutter)

This commit is contained in:
d6soft
2025-05-01 18:59:27 +02:00
commit b5aafc424b
244 changed files with 37296 additions and 0 deletions

View File

@@ -0,0 +1,476 @@
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:flutter/foundation.dart' show listEquals;
import 'package:intl/intl.dart';
import 'package:syncfusion_flutter_charts/charts.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/services/passage_data_service.dart';
/// Widget de graphique d'activité affichant les passages
class ActivityChart extends StatefulWidget {
/// Liste des données de passage par date et type (si fournie directement)
/// Format attendu: [{"date": String, "type_passage": int, "nb": int}, ...]
final List<Map<String, dynamic>>? passageData;
/// Type de période (Jour, Semaine, Mois, Année)
final String periodType;
/// Hauteur du graphique
final double height;
/// Nombre de jours à afficher (par défaut 15)
final int daysToShow;
/// ID de l'utilisateur pour filtrer les passages (null = tous les utilisateurs)
final int? userId;
/// Types de passages à exclure (par défaut [2] = "À finaliser")
final List<int> excludePassageTypes;
/// Indique si les données doivent être chargées depuis la Hive box
final bool loadFromHive;
/// Callback appelé lorsque la période change
final Function(int days)? onPeriodChanged;
/// Titre du graphique
final String title;
/// Afficher les étiquettes de valeur
final bool showDataLabels;
/// Largeur des colonnes (en pourcentage)
final double columnWidth;
/// Espacement entre les colonnes (en pourcentage)
final double columnSpacing;
/// Si vrai, n'applique aucun filtrage par utilisateur (affiche tous les passages)
final bool showAllPassages;
const ActivityChart({
super.key,
this.passageData,
this.periodType = 'Jour',
this.height = 350,
this.daysToShow = 15,
this.userId,
this.excludePassageTypes = const [2],
this.loadFromHive = false,
this.onPeriodChanged,
this.title = 'Dernière activité enregistrée sur 15 jours',
this.showDataLabels = true,
this.columnWidth = 0.8,
this.columnSpacing = 0.2,
this.showAllPassages = false,
}) : assert(loadFromHive || passageData != null,
'Soit loadFromHive doit être true, soit passageData doit être fourni');
@override
State<ActivityChart> createState() => _ActivityChartState();
}
/// Classe pour stocker les données d'activité par date
class ActivityData {
final DateTime date;
final String dateStr;
final Map<int, int> passagesByType;
final int totalPassages;
ActivityData({
required this.date,
required this.dateStr,
required this.passagesByType,
}) : totalPassages =
passagesByType.values.fold(0, (sum, count) => sum + count);
}
class _ActivityChartState extends State<ActivityChart>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
// Données pour les graphiques
List<Map<String, dynamic>> _passageData = [];
List<ActivityData> _chartData = [];
bool _isLoading = true;
bool _hasData = false;
bool _dataLoaded = false;
// Période sélectionnée en jours
int _selectedDays = 15;
// Contrôleur de zoom pour le graphique
late ZoomPanBehavior _zoomPanBehavior;
@override
void initState() {
super.initState();
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
);
// Initialiser la période sélectionnée avec la valeur par défaut du widget
_selectedDays = widget.daysToShow;
// Initialiser le contrôleur de zoom
_zoomPanBehavior = ZoomPanBehavior(
enablePinching: true,
enableDoubleTapZooming: true,
enablePanning: true,
zoomMode: ZoomMode.x,
);
_loadData();
_animationController.forward();
}
/// Trouve la date du passage le plus récent
DateTime _getMostRecentDate() {
final allDates = [
..._passageData.map((data) => DateTime.parse(data['date'] as String)),
];
if (allDates.isEmpty) {
return DateTime.now();
}
return allDates.reduce((a, b) => a.isAfter(b) ? a : b);
}
void _loadData() {
// Si les données ont déjà été chargées, ne pas les recharger
if (_dataLoaded) return;
// Marquer comme chargé immédiatement pour éviter les appels multiples
_dataLoaded = true;
setState(() {
_isLoading = true;
});
if (widget.loadFromHive) {
// Charger les données depuis Hive
WidgetsBinding.instance.addPostFrameCallback((_) {
// Éviter de recharger si le widget a été démonté entre-temps
if (!mounted) return;
try {
// Utiliser les instances globales définies dans app.dart
// Créer une instance du service de données
final passageDataService = PassageDataService(
passageRepository: passageRepository,
userRepository: userRepository,
);
// Utiliser le service pour charger les données
_passageData = passageDataService.loadPassageData(
daysToShow: _selectedDays,
excludePassageTypes: widget.excludePassageTypes,
userId: widget.userId,
showAllPassages: widget.showAllPassages,
);
_prepareChartData();
// Mettre à jour l'état une seule fois après avoir préparé les données
if (mounted) {
setState(() {
_isLoading = false;
_hasData = _chartData.isNotEmpty;
});
}
} catch (e) {
// En cas d'erreur, réinitialiser l'état pour permettre une future tentative
if (mounted) {
setState(() {
_isLoading = false;
_hasData = false;
});
}
}
});
} else {
// Utiliser les données fournies directement
_passageData = widget.passageData ?? [];
_prepareChartData();
setState(() {
_isLoading = false;
_hasData = _chartData.isNotEmpty;
});
}
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
void didUpdateWidget(ActivityChart oldWidget) {
super.didUpdateWidget(oldWidget);
// Vérifier si les propriétés importantes ont changé
final bool periodChanged = oldWidget.periodType != widget.periodType ||
oldWidget.daysToShow != widget.daysToShow;
final bool dataSourceChanged = widget.loadFromHive
? false
: oldWidget.passageData != widget.passageData;
final bool filteringChanged = oldWidget.userId != widget.userId ||
!listEquals(
oldWidget.excludePassageTypes, widget.excludePassageTypes) ||
oldWidget.showAllPassages != widget.showAllPassages;
// Si des paramètres importants ont changé, recharger les données
if (periodChanged || dataSourceChanged || filteringChanged) {
_selectedDays = widget.daysToShow;
_dataLoaded = false; // Réinitialiser l'état pour forcer le rechargement
_loadData();
}
}
// La méthode _loadPassageDataFromHive a été intégrée directement dans _loadData
// pour éviter les appels multiples et les problèmes de cycle de vie
/// Prépare les données pour le graphique
void _prepareChartData() {
try {
// Vérifier que les données sont disponibles
if (_passageData.isEmpty) {
_chartData = [];
return;
}
// Obtenir toutes les dates uniques
final Set<String> uniqueDatesSet = {};
for (final data in _passageData) {
if (data.containsKey('date') && data['date'] != null) {
uniqueDatesSet.add(data['date'] as String);
}
}
// Trier les dates
final List<String> uniqueDates = uniqueDatesSet.toList();
uniqueDates.sort();
// Créer les données pour chaque date
_chartData = [];
for (final dateStr in uniqueDates) {
final passagesByType = <int, int>{};
// Initialiser tous les types de passage possibles
for (final typeId in AppKeys.typesPassages.keys) {
if (!widget.excludePassageTypes.contains(typeId)) {
passagesByType[typeId] = 0;
}
}
// Remplir les données de passage
for (final data in _passageData) {
if (data.containsKey('date') &&
data['date'] == dateStr &&
data.containsKey('type_passage') &&
data.containsKey('nb')) {
final typeId = data['type_passage'] as int;
if (!widget.excludePassageTypes.contains(typeId)) {
passagesByType[typeId] = data['nb'] as int;
}
}
}
try {
// Convertir la date en objet DateTime
final dateParts = dateStr.split('-');
if (dateParts.length == 3) {
final year = int.parse(dateParts[0]);
final month = int.parse(dateParts[1]);
final day = int.parse(dateParts[2]);
final date = DateTime(year, month, day);
// Ajouter les données à la liste
_chartData.add(ActivityData(
date: date,
dateStr: dateStr,
passagesByType: passagesByType,
));
}
} catch (e) {
// Silencieux lors des erreurs de conversion de date pour éviter les logs excessifs
}
}
// Trier les données par date
_chartData.sort((a, b) => a.date.compareTo(b.date));
} catch (e) {
// Erreur silencieuse pour éviter les logs excessifs
_chartData = [];
}
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return SizedBox(
height: widget.height,
child: const Center(
child: CircularProgressIndicator(),
),
);
}
if (!_hasData || _chartData.isEmpty) {
return SizedBox(
height: widget.height,
child: const Center(
child: Text('Aucune donnée disponible'),
),
);
}
// Préparer les données si nécessaire
if (_chartData.isEmpty) {
_prepareChartData();
}
return SizedBox(
height: widget.height,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Titre (conservé)
if (widget.title.isNotEmpty)
Padding(
padding: const EdgeInsets.only(
left: 16.0, right: 16.0, top: 16.0, bottom: 8.0),
child: Text(
widget.title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
// Graphique (occupe maintenant plus d'espace)
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(16.0, 8.0, 16.0, 16.0),
child: SfCartesianChart(
plotAreaBorderWidth: 0,
legend: Legend(
isVisible: true,
position: LegendPosition.bottom,
overflowMode: LegendItemOverflowMode.wrap,
),
primaryXAxis: DateTimeAxis(
dateFormat: DateFormat('dd/MM'),
intervalType: DateTimeIntervalType.days,
majorGridLines: const MajorGridLines(width: 0),
labelStyle: const TextStyle(fontSize: 10),
// Définir explicitement la plage de dates à afficher
minimum: _chartData.isNotEmpty ? _chartData.first.date : null,
maximum: _chartData.isNotEmpty ? _chartData.last.date : null,
// Assurer que tous les jours sont affichés
interval: 1,
axisLabelFormatter: (AxisLabelRenderDetails details) {
return ChartAxisLabel(details.text, details.textStyle);
},
),
primaryYAxis: NumericAxis(
labelStyle: const TextStyle(fontSize: 10),
axisLine: const AxisLine(width: 0),
majorTickLines: const MajorTickLines(size: 0),
majorGridLines: const MajorGridLines(
width: 0.5,
color: Colors.grey,
dashArray: <double>[5, 5], // Motif de pointillés
),
title: const AxisTitle(
text: 'Passages',
textStyle: TextStyle(fontSize: 10, color: Colors.grey),
),
),
series: _buildSeries(),
tooltipBehavior: TooltipBehavior(enable: true),
zoomPanBehavior: _zoomPanBehavior,
),
),
),
],
),
);
}
/// Construit les séries de données pour le graphique
List<CartesianSeries<ActivityData, DateTime>> _buildSeries() {
final List<CartesianSeries<ActivityData, DateTime>> series = [];
// Vérifier que les données sont disponibles
if (_chartData.isEmpty) {
return series;
}
// Obtenir tous les types de passage (sauf ceux exclus)
final passageTypes = AppKeys.typesPassages.keys
.where((typeId) => !widget.excludePassageTypes.contains(typeId))
.toList();
// Créer les séries pour les passages (colonnes empilées)
for (final typeId in passageTypes) {
// Vérifier que le type existe dans AppKeys
if (!AppKeys.typesPassages.containsKey(typeId)) {
continue;
}
final typeInfo = AppKeys.typesPassages[typeId]!;
// Vérifier que les clés nécessaires existent
if (!typeInfo.containsKey('couleur1') || !typeInfo.containsKey('titre')) {
continue;
}
final typeColor = Color(typeInfo['couleur1'] as int);
final typeName = typeInfo['titre'] as String;
// Calculer le total pour ce type pour déterminer s'il faut l'afficher
int totalForType = 0;
for (final data in _chartData) {
totalForType += data.passagesByType[typeId] ?? 0;
}
// On peut décider de ne pas afficher les types sans données
final addZeroValueTypes = false;
// Ajouter la série pour ce type
if (totalForType > 0 || addZeroValueTypes) {
series.add(
StackedColumnSeries<ActivityData, DateTime>(
name: typeName,
dataSource: _chartData,
xValueMapper: (ActivityData data, _) => data.date,
yValueMapper: (ActivityData data, _) {
final value = data.passagesByType.containsKey(typeId)
? data.passagesByType[typeId]!
: 0;
return value;
},
color: typeColor,
width: widget.columnWidth,
spacing: widget.columnSpacing,
dataLabelSettings: DataLabelSettings(
isVisible: widget.showDataLabels,
labelAlignment: ChartDataLabelAlignment.middle,
textStyle: const TextStyle(fontSize: 8, color: Colors.white),
),
markerSettings: const MarkerSettings(isVisible: false),
animationDuration: 1500,
),
);
}
}
return series;
}
}

View File

@@ -0,0 +1,11 @@
/// Bibliothèque de widgets de graphiques pour l'application GeoSector
library geosector_charts;
export 'payment_data.dart';
export 'payment_pie_chart.dart';
export 'payment_utils.dart';
export 'passage_data.dart';
export 'passage_utils.dart';
export 'passage_pie_chart.dart';
export 'activity_chart.dart';
export 'combined_chart.dart';

View File

@@ -0,0 +1,313 @@
import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:geosector_app/presentation/widgets/charts/passage_data.dart';
import 'package:geosector_app/presentation/widgets/charts/passage_utils.dart';
import 'package:intl/intl.dart';
/// Widget de graphique combiné pour afficher les passages et règlements
class CombinedChart extends StatelessWidget {
/// Liste des données de passage par type
final List<Map<String, dynamic>> passageData;
/// Liste des données de règlement par type
final List<Map<String, dynamic>> paymentData;
/// Type de période (Jour, Semaine, Mois, Année)
final String periodType;
/// Hauteur du graphique
final double height;
/// Largeur des barres
final double barWidth;
/// Rayon des points sur les lignes
final double dotRadius;
/// Épaisseur des lignes
final double lineWidth;
/// Montant maximum pour l'axe Y des règlements
final double? maxYAmount;
/// Nombre maximum pour l'axe Y des passages
final int? maxYCount;
const CombinedChart({
super.key,
required this.passageData,
required this.paymentData,
this.periodType = 'Jour',
this.height = 300,
this.barWidth = 16,
this.dotRadius = 4,
this.lineWidth = 3,
this.maxYAmount,
this.maxYCount,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
// Convertir les données brutes en modèles structurés
final passagesByType = PassageUtils.getPassageDataByType(passageData);
final paymentsByType = PassageUtils.getPaymentDataByType(paymentData);
// Extraire les dates uniques pour l'axe X
final List<DateTime> allDates = [];
for (final data in passageData) {
final DateTime date = data['date'] is DateTime
? data['date']
: DateTime.parse(data['date']);
if (!allDates.any((d) =>
d.year == date.year && d.month == date.month && d.day == date.day)) {
allDates.add(date);
}
}
// Trier les dates
allDates.sort((a, b) => a.compareTo(b));
// Calculer le maximum pour les axes Y
double maxAmount = 0;
for (final typeData in paymentsByType) {
for (final data in typeData) {
if (data.amount > maxAmount) {
maxAmount = data.amount;
}
}
}
int maxCount = 0;
for (final typeData in passagesByType) {
for (final data in typeData) {
if (data.count > maxCount) {
maxCount = data.count;
}
}
}
// Utiliser les maximums fournis ou calculés
final effectiveMaxYAmount = maxYAmount ?? (maxAmount * 1.2).ceilToDouble();
final effectiveMaxYCount = maxYCount ?? (maxCount * 1.2).ceil();
return SizedBox(
height: height,
child: BarChart(
BarChartData(
alignment: BarChartAlignment.spaceAround,
maxY: effectiveMaxYCount.toDouble(),
barTouchData: BarTouchData(
touchTooltipData: BarTouchTooltipData(
tooltipPadding: const EdgeInsets.all(8),
tooltipMargin: 8,
getTooltipItem: (group, groupIndex, rod, rodIndex) {
final date = allDates[group.x.toInt()];
final formattedDate = DateFormat('dd/MM').format(date);
// Calculer le total des passages pour cette date
int totalPassages = 0;
for (final typeData in passagesByType) {
for (final data in typeData) {
if (data.date.year == date.year &&
data.date.month == date.month &&
data.date.day == date.day) {
totalPassages += data.count;
}
}
}
return BarTooltipItem(
'$formattedDate: $totalPassages passages',
TextStyle(
color: theme.colorScheme.onSurface,
fontWeight: FontWeight.bold,
),
);
},
),
),
titlesData: FlTitlesData(
show: true,
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 30,
getTitlesWidget: (value, meta) {
if (value >= 0 && value < allDates.length) {
final date = allDates[value.toInt()];
final formattedDate =
PassageUtils.formatDateForChart(date, periodType);
return SideTitleWidget(
meta: meta,
space: 8,
child: Text(
formattedDate,
style: TextStyle(
color: theme.colorScheme.onSurface.withOpacity(0.6),
fontSize: 10,
),
),
);
}
return const SizedBox();
},
),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
return SideTitleWidget(
meta: meta,
space: 8,
child: Text(
value.toInt().toString(),
style: TextStyle(
color: theme.colorScheme.onSurface.withOpacity(0.6),
fontSize: 10,
),
),
);
},
reservedSize: 30,
),
),
rightTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
// Convertir la valeur de l'axe Y des passages à l'échelle des montants
final amountValue =
(value / effectiveMaxYCount) * effectiveMaxYAmount;
return SideTitleWidget(
meta: meta,
space: 8,
child: Text(
'${amountValue.toInt()}',
style: TextStyle(
color: theme.colorScheme.onSurface.withOpacity(0.6),
fontSize: 10,
),
),
);
},
reservedSize: 40,
),
),
topTitles: AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
),
gridData: FlGridData(
show: true,
getDrawingHorizontalLine: (value) {
return FlLine(
color: theme.dividerColor.withOpacity(0.2),
strokeWidth: 1,
);
},
drawVerticalLine: false,
),
borderData: FlBorderData(show: false),
barGroups: _createBarGroups(allDates, passagesByType),
extraLinesData: ExtraLinesData(
horizontalLines: [],
verticalLines: [],
extraLinesOnTop: true,
),
),
swapAnimationDuration: const Duration(milliseconds: 250),
),
);
}
/// Créer les groupes de barres pour les passages
List<BarChartGroupData> _createBarGroups(
List<DateTime> allDates,
List<List<PassageData>> passagesByType,
) {
final List<BarChartGroupData> groups = [];
for (int i = 0; i < allDates.length; i++) {
final date = allDates[i];
// Calculer le total des passages pour cette date
int totalPassages = 0;
for (final typeData in passagesByType) {
for (final data in typeData) {
if (data.date.year == date.year &&
data.date.month == date.month &&
data.date.day == date.day) {
totalPassages += data.count;
}
}
}
// Créer un groupe de barres pour cette date
groups.add(
BarChartGroupData(
x: i,
barRods: [
BarChartRodData(
toY: totalPassages.toDouble(),
color: Colors.blue.shade700,
width: barWidth,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(6),
topRight: Radius.circular(6),
),
),
],
),
);
}
return groups;
}
}
/// Widget de légende pour le graphique combiné
class CombinedChartLegend extends StatelessWidget {
const CombinedChartLegend({super.key});
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 16,
runSpacing: 8,
children: [
_buildLegendItem('Passages', Colors.blue.shade700, isBar: true),
_buildLegendItem('Espèces', const Color(0xFF4CAF50)),
_buildLegendItem('Chèques', const Color(0xFF2196F3)),
_buildLegendItem('CB', const Color(0xFFF44336)),
],
);
}
/// Créer un élément de légende
Widget _buildLegendItem(String label, Color color, {bool isBar = false}) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: color,
shape: isBar ? BoxShape.rectangle : BoxShape.circle,
borderRadius: isBar ? BorderRadius.circular(3) : null,
),
),
const SizedBox(width: 4),
Text(
label,
style: const TextStyle(fontSize: 12),
),
],
);
}
}

View File

@@ -0,0 +1,99 @@
import 'package:flutter/material.dart';
/// Modèle de données pour représenter un passage avec sa date, son type et son nombre
class PassageData {
/// Date du passage
final DateTime date;
/// Identifiant du type de passage (1: Effectué, 2: À finaliser, 3: Refusé, etc.)
final int typeId;
/// Nombre de passages
final int count;
/// Couleur associée au type de passage
final Color color;
/// Icône associée au type de passage (chemin vers le fichier d'icône)
final String iconPath;
/// Titre du type de passage
final String title;
const PassageData({
required this.date,
required this.typeId,
required this.count,
required this.color,
required this.iconPath,
required this.title,
});
/// Crée une instance de PassageData à partir d'une date au format ISO 8601
factory PassageData.fromIsoDate({
required String isoDate,
required int typeId,
required int count,
required Color color,
required String iconPath,
required String title,
}) {
return PassageData(
date: DateTime.parse(isoDate),
typeId: typeId,
count: count,
color: color,
iconPath: iconPath,
title: title,
);
}
}
/// Modèle de données pour représenter un règlement avec sa date, son type et son montant
class PaymentAmountData {
/// Date du règlement
final DateTime date;
/// Identifiant du type de règlement (1: Espèces, 2: Chèques, 3: CB)
final int typeId;
/// Montant du règlement
final double amount;
/// Couleur associée au type de règlement
final Color color;
/// Icône associée au type de règlement (chemin vers le fichier d'icône ou IconData)
final dynamic iconData;
/// Titre du type de règlement
final String title;
/// Crée une instance de PaymentAmountData à partir d'une date au format ISO 8601
factory PaymentAmountData.fromIsoDate({
required String isoDate,
required int typeId,
required double amount,
required Color color,
required dynamic iconData,
required String title,
}) {
return PaymentAmountData(
date: DateTime.parse(isoDate),
typeId: typeId,
amount: amount,
color: color,
iconData: iconData,
title: title,
);
}
const PaymentAmountData({
required this.date,
required this.typeId,
required this.amount,
required this.color,
required this.iconData,
required this.title,
});
}

View File

@@ -0,0 +1,459 @@
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:flutter/foundation.dart' show listEquals, mapEquals;
import 'package:intl/intl.dart';
import 'package:syncfusion_flutter_charts/charts.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/services/passage_data_service.dart';
/// Modèle de données pour le graphique en camembert des passages
class PassageChartData {
/// Identifiant du type de passage
final int typeId;
/// Nombre de passages de ce type
final int count;
/// Titre du type de passage
final String title;
/// Couleur associée au type de passage
final Color color;
/// Icône associée au type de passage
final IconData icon;
PassageChartData({
required this.typeId,
required this.count,
required this.title,
required this.color,
required this.icon,
});
}
/// Widget de graphique en camembert pour représenter la répartition des passages par type
class PassagePieChart extends StatefulWidget {
/// Liste des données de passages par type sous forme de Map avec typeId et count
/// Si loadFromHive est true, ce paramètre est ignoré
final Map<int, int> passagesByType;
/// Taille du graphique
final double size;
/// Taille des étiquettes
final double labelSize;
/// Afficher les pourcentages
final bool showPercentage;
/// Afficher les icônes
final bool showIcons;
/// Afficher la légende
final bool showLegend;
/// Format donut (anneau)
final bool isDonut;
/// Rayon central pour le format donut (en pourcentage)
final String innerRadius;
/// Charger les données depuis Hive
final bool loadFromHive;
/// ID de l'utilisateur pour filtrer les passages (utilisé seulement si loadFromHive est true)
final int? userId;
/// Types de passages à exclure (utilisé seulement si loadFromHive est true)
final List<int> excludePassageTypes;
/// Afficher tous les passages sans filtrer par utilisateur (utilisé seulement si loadFromHive est true)
final bool showAllPassages;
const PassagePieChart({
super.key,
this.passagesByType = const {},
this.size = 300,
this.labelSize = 12,
this.showPercentage = true,
this.showIcons = true,
this.showLegend = true,
this.isDonut = false,
this.innerRadius = '40%',
this.loadFromHive = false,
this.userId,
this.excludePassageTypes = const [2],
this.showAllPassages = false,
});
@override
State<PassagePieChart> createState() => _PassagePieChartState();
}
class _PassagePieChartState extends State<PassagePieChart>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
/// Données de passages par type
late Map<int, int> _passagesByType;
/// Variables pour la mise en cache et l'optimisation
bool _dataLoaded = false;
bool _isLoading = false;
List<PassageChartData>? _cachedChartData;
List<CircularChartAnnotation>? _cachedAnnotations;
@override
void initState() {
super.initState();
_passagesByType = widget.passagesByType;
// Initialiser le contrôleur d'animation
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 2000),
);
_animationController.forward();
// Si nous n'utilisons pas Hive, préparer les données immédiatement
if (!widget.loadFromHive) {
_prepareChartData();
}
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (widget.loadFromHive && !_dataLoaded && !_isLoading) {
_isLoading = true; // Prévenir les chargements multiples
_loadPassageDataFromHive(context);
}
}
@override
void didUpdateWidget(PassagePieChart oldWidget) {
super.didUpdateWidget(oldWidget);
// Vérifier si les propriétés importantes ont changé
final bool dataSourceChanged = widget.loadFromHive
? false
: !mapEquals(oldWidget.passagesByType, widget.passagesByType);
final bool filteringChanged = oldWidget.userId != widget.userId ||
!listEquals(
oldWidget.excludePassageTypes, widget.excludePassageTypes) ||
oldWidget.showAllPassages != widget.showAllPassages;
final bool visualChanged = oldWidget.size != widget.size ||
oldWidget.labelSize != widget.labelSize ||
oldWidget.showPercentage != widget.showPercentage ||
oldWidget.showIcons != widget.showIcons ||
oldWidget.showLegend != widget.showLegend ||
oldWidget.isDonut != widget.isDonut ||
oldWidget.innerRadius != widget.innerRadius;
// Si les paramètres de filtrage ou de source de données ont changé, recharger les données
if (dataSourceChanged || filteringChanged) {
_cachedChartData = null;
_cachedAnnotations = null;
// Relancer l'animation si les données ont changé
_animationController.reset();
_animationController.forward();
if (!widget.loadFromHive) {
_passagesByType = widget.passagesByType;
_prepareChartData();
} else if (!_isLoading) {
_dataLoaded = false;
_isLoading = true;
_loadPassageDataFromHive(context);
}
}
// Si seuls les paramètres visuels ont changé, recalculer les annotations sans recharger les données
else if (visualChanged) {
_cachedAnnotations = null;
}
}
/// Charge les données de passage depuis Hive en utilisant le service PassageDataService
void _loadPassageDataFromHive(BuildContext context) {
// Éviter les appels multiples
if (_isLoading && _dataLoaded) return;
// Charger les données dans un addPostFrameCallback pour éviter les problèmes de cycle de vie
WidgetsBinding.instance.addPostFrameCallback((_) {
// Vérifier si le widget est toujours monté
if (!mounted) return;
try {
// Utiliser les instances globales définies dans app.dart
// Créer une instance du service de données
final passageDataService = PassageDataService(
passageRepository: passageRepository,
userRepository: userRepository,
);
// Utiliser le service pour charger les données
final data = passageDataService.loadPassageDataForPieChart(
excludePassageTypes: widget.excludePassageTypes,
userId: widget.userId,
showAllPassages: widget.showAllPassages,
);
// Mettre à jour les données et les états
if (mounted) {
setState(() {
_passagesByType = data;
_dataLoaded = true;
_isLoading = false;
_cachedChartData =
null; // Forcer la régénération des données du graphique
_cachedAnnotations = null;
});
// Préparer les données du graphique
_prepareChartData();
}
} catch (e) {
// Gérer les erreurs et réinitialiser l'état pour permettre une future tentative
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
});
}
/// Prépare les données pour le graphique en camembert avec mise en cache
List<PassageChartData> _prepareChartData() {
// Utiliser les données en cache si disponibles
if (_cachedChartData != null) {
return _cachedChartData!;
}
final List<PassageChartData> chartData = [];
// Créer les données du graphique
_passagesByType.forEach((typeId, count) {
// Vérifier que le type existe et que le compteur est positif
if (count > 0 && AppKeys.typesPassages.containsKey(typeId)) {
final typeInfo = AppKeys.typesPassages[typeId]!;
chartData.add(PassageChartData(
typeId: typeId,
count: count,
title: typeInfo['titre'] as String,
color: Color(typeInfo['couleur2'] as int),
icon: typeInfo['icon_data'] as IconData,
));
}
});
// Mettre en cache les données générées
_cachedChartData = chartData;
return chartData;
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
// Si les données doivent être chargées depuis Hive mais ne sont pas encore prêtes
if (widget.loadFromHive && !_dataLoaded) {
return SizedBox(
width: widget.size,
height: widget.size,
child: const Center(
child: CircularProgressIndicator(),
),
);
}
final chartData = _prepareChartData();
// Si aucune donnée, afficher un message
if (chartData.isEmpty) {
return SizedBox(
width: widget.size,
height: widget.size,
child: const Center(
child: Text('Aucune donnée disponible'),
),
);
}
// Créer des animations pour différents aspects du graphique
final progressAnimation = CurvedAnimation(
parent: _animationController,
curve: Curves.easeOutCubic,
);
final explodeAnimation = CurvedAnimation(
parent: _animationController,
curve: Interval(0.7, 1.0, curve: Curves.elasticOut),
);
final opacityAnimation = CurvedAnimation(
parent: _animationController,
curve: Interval(0.1, 0.5, curve: Curves.easeIn),
);
return AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return SizedBox(
width: widget.size,
height: widget.size,
child: SfCircularChart(
margin: EdgeInsets.zero,
legend: Legend(
isVisible: widget.showLegend,
position: LegendPosition.bottom,
overflowMode: LegendItemOverflowMode.wrap,
textStyle: TextStyle(fontSize: widget.labelSize),
),
tooltipBehavior: TooltipBehavior(enable: true),
series: <CircularSeries>[
widget.isDonut
? DoughnutSeries<PassageChartData, String>(
dataSource: chartData,
xValueMapper: (PassageChartData data, _) => data.title,
yValueMapper: (PassageChartData data, _) => data.count,
pointColorMapper: (PassageChartData data, _) =>
data.color,
enableTooltip: true,
dataLabelMapper: (PassageChartData data, _) {
if (widget.showPercentage) {
// Calculer le pourcentage avec une décimale
final total = chartData.fold(
0, (sum, item) => sum + item.count);
final percentage = (data.count / total * 100);
return '${percentage.toStringAsFixed(1)}%';
} else {
return data.title;
}
},
dataLabelSettings: DataLabelSettings(
isVisible: true,
labelPosition: ChartDataLabelPosition.outside,
textStyle: TextStyle(fontSize: widget.labelSize),
connectorLineSettings: const ConnectorLineSettings(
type: ConnectorType.curve,
length: '15%',
),
),
innerRadius: widget.innerRadius,
explode: true,
explodeIndex: 0,
explodeOffset:
'${(5 * explodeAnimation.value).toStringAsFixed(1)}%',
opacity: opacityAnimation.value,
animationDuration:
0, // On désactive l'animation intégrée car nous utilisons notre propre animation
startAngle: 270,
endAngle: 270 + (360 * progressAnimation.value).toInt(),
)
: PieSeries<PassageChartData, String>(
dataSource: chartData,
xValueMapper: (PassageChartData data, _) => data.title,
yValueMapper: (PassageChartData data, _) => data.count,
pointColorMapper: (PassageChartData data, _) =>
data.color,
enableTooltip: true,
dataLabelMapper: (PassageChartData data, _) {
if (widget.showPercentage) {
// Calculer le pourcentage avec une décimale
final total = chartData.fold(
0, (sum, item) => sum + item.count);
final percentage = (data.count / total * 100);
return '${percentage.toStringAsFixed(1)}%';
} else {
return data.title;
}
},
dataLabelSettings: DataLabelSettings(
isVisible: true,
labelPosition: ChartDataLabelPosition.outside,
textStyle: TextStyle(fontSize: widget.labelSize),
connectorLineSettings: const ConnectorLineSettings(
type: ConnectorType.curve,
length: '15%',
),
),
explode: true,
explodeIndex: 0,
explodeOffset:
'${(5 * explodeAnimation.value).toStringAsFixed(1)}%',
opacity: opacityAnimation.value,
animationDuration:
0, // On désactive l'animation intégrée car nous utilisons notre propre animation
startAngle: 270,
endAngle: 270 + (360 * progressAnimation.value).toInt(),
),
],
annotations:
widget.showIcons ? _buildIconAnnotations(chartData) : null,
),
);
},
);
}
/// Crée les annotations d'icônes pour le graphique avec mise en cache
List<CircularChartAnnotation> _buildIconAnnotations(
List<PassageChartData> chartData) {
// Utiliser les annotations en cache si disponibles
if (_cachedAnnotations != null) {
return _cachedAnnotations!;
}
final List<CircularChartAnnotation> annotations = [];
// Calculer le total pour les pourcentages
int total = chartData.fold(0, (sum, item) => sum + item.count);
if (total == 0) return []; // Éviter la division par zéro
// Position angulaire actuelle (en radians)
double currentAngle = 0;
for (int i = 0; i < chartData.length; i++) {
final data = chartData[i];
final percentage = data.count / total;
// Calculer l'angle central de ce segment
final segmentAngle = percentage * 2 * 3.14159;
final midAngle = currentAngle + (segmentAngle / 2);
// Ajouter une annotation pour l'icône
annotations.add(
CircularChartAnnotation(
widget: Icon(
data.icon,
color: Colors.white,
size: 16,
),
radius: '50%',
angle: (midAngle * (180 / 3.14159)).toInt(), // Convertir en degrés
),
);
// Mettre à jour l'angle actuel
currentAngle += segmentAngle;
}
// Mettre en cache les annotations générées
_cachedAnnotations = annotations;
return annotations;
}
}

View File

@@ -0,0 +1,214 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/presentation/widgets/charts/passage_data.dart';
import 'package:intl/intl.dart';
/// Utilitaires pour les passages et règlements
class PassageUtils {
/// Convertit les données de passage brutes en liste de PassageData
///
/// [passageData] est une liste d'objets contenant date, type_passage et nb
static List<List<PassageData>> getPassageDataByType(
List<Map<String, dynamic>> passageData) {
// Créer un Map pour stocker les données par type de passage
final Map<int, List<PassageData>> passagesByType = {};
// Initialiser les listes pour chaque type de passage
for (final entry in AppKeys.typesPassages.entries) {
passagesByType[entry.key] = [];
}
// Grouper les passages par type
for (final data in passageData) {
final int typeId = data['type_passage'];
final int count = data['nb'];
if (AppKeys.typesPassages.containsKey(typeId)) {
final typeData = AppKeys.typesPassages[typeId]!;
final Color color = Color(typeData['couleur1'] as int);
final String iconPath = typeData['icone'] as String;
final String title = typeData['titre'] as String;
// Utiliser la méthode factory qui gère les dates au format ISO 8601
if (data['date'] is String) {
passagesByType[typeId]!.add(
PassageData.fromIsoDate(
isoDate: data['date'],
typeId: typeId,
count: count,
color: color,
iconPath: iconPath,
title: title,
),
);
} else {
// Fallback pour les objets DateTime (pour compatibilité)
final DateTime date = data['date'] as DateTime;
passagesByType[typeId]!.add(
PassageData(
date: date,
typeId: typeId,
count: count,
color: color,
iconPath: iconPath,
title: title,
),
);
}
}
}
// Convertir le Map en liste de listes
return passagesByType.values.toList();
}
/// Convertit les données de règlement brutes en liste de PaymentAmountData
///
/// [paymentData] est une liste d'objets contenant date, type_reglement et montant
static List<List<PaymentAmountData>> getPaymentDataByType(
List<Map<String, dynamic>> paymentData) {
// Créer un Map pour stocker les données par type de règlement
final Map<int, List<PaymentAmountData>> paymentsByType = {};
// Initialiser les listes pour chaque type de règlement (sauf 0 qui est "Pas de règlement")
for (final entry in AppKeys.typesReglements.entries) {
if (entry.key > 0) {
// Ignorer le type 0 (Pas de règlement)
paymentsByType[entry.key] = [];
}
}
// Grouper les règlements par type
for (final data in paymentData) {
final int typeId = data['type_reglement'];
final double amount = data['montant'] is double
? data['montant']
: double.parse(data['montant'].toString());
if (typeId > 0 && AppKeys.typesReglements.containsKey(typeId)) {
final typeData = AppKeys.typesReglements[typeId]!;
final Color color = Color(typeData['couleur'] as int);
final dynamic iconData = _getIconForPaymentType(typeId);
final String title = typeData['titre'] as String;
// Utiliser la méthode factory qui gère les dates au format ISO 8601
if (data['date'] is String) {
paymentsByType[typeId]!.add(
PaymentAmountData.fromIsoDate(
isoDate: data['date'],
typeId: typeId,
amount: amount,
color: color,
iconData: iconData,
title: title,
),
);
} else {
// Fallback pour les objets DateTime (pour compatibilité)
final DateTime date = data['date'] as DateTime;
paymentsByType[typeId]!.add(
PaymentAmountData(
date: date,
typeId: typeId,
amount: amount,
color: color,
iconData: iconData,
title: title,
),
);
}
}
}
// Convertir le Map en liste de listes
return paymentsByType.values.toList();
}
/// Génère des données de passage fictives pour les 14 derniers jours
static List<Map<String, dynamic>> generateMockPassageData() {
final List<Map<String, dynamic>> mockData = [];
final now = DateTime.now();
for (int i = 13; i >= 0; i--) {
final date = now.subtract(Duration(days: i));
// Ajouter des données pour chaque type de passage
for (int typeId = 1; typeId <= 6; typeId++) {
// Générer un nombre aléatoire de passages entre 0 et 5
final count = (typeId == 1 || typeId == 2)
? (1 + (date.day % 5)) // Plus de passages pour les types 1 et 2
: (date.day % 3); // Moins pour les autres types
if (count > 0) {
mockData.add({
'date': date,
'type_passage': typeId,
'nb': count,
});
}
}
}
return mockData;
}
/// Génère des données de règlement fictives pour les 14 derniers jours
static List<Map<String, dynamic>> generateMockPaymentData() {
final List<Map<String, dynamic>> mockData = [];
final now = DateTime.now();
for (int i = 13; i >= 0; i--) {
final date = now.subtract(Duration(days: i));
// Ajouter des données pour chaque type de règlement
for (int typeId = 1; typeId <= 3; typeId++) {
// Générer un montant aléatoire
final amount = (typeId * 100.0) + (date.day * 10.0);
mockData.add({
'date': date,
'type_reglement': typeId,
'montant': amount,
});
}
}
return mockData;
}
/// Obtenir l'icône correspondant au type de règlement
/// Retourne un IconData pour les règlements car ils n'ont pas de chemin d'icône défini dans AppKeys
static IconData _getIconForPaymentType(int typeId) {
switch (typeId) {
case 1: // Espèces
return Icons.payments;
case 2: // Chèque
return Icons.money;
case 3: // CB
return Icons.credit_card;
default:
return Icons.euro;
}
}
/// Formater une date pour l'affichage dans les graphiques
static String formatDateForChart(DateTime date, String periodType) {
switch (periodType.toLowerCase()) {
case 'jour':
return DateFormat('dd/MM').format(date);
case 'semaine':
// Calculer le numéro de la semaine dans l'année
final firstDayOfYear = DateTime(date.year, 1, 1);
final dayOfYear = date.difference(firstDayOfYear).inDays;
final weekNumber =
((dayOfYear + firstDayOfYear.weekday - 1) / 7).ceil();
return 'S$weekNumber';
case 'mois':
return DateFormat('MMM').format(date);
case 'année':
return DateFormat('yyyy').format(date);
default:
return DateFormat('dd/MM').format(date);
}
}
}

View File

@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
/// Modèle de données pour représenter un type de règlement avec son montant
class PaymentData {
/// Identifiant du type de règlement (1: Espèces, 2: Chèques, 3: CB)
final int typeId;
/// Montant du règlement
final double amount;
/// Couleur associée au type de règlement
final Color color;
/// Icône associée au type de règlement
final IconData icon;
/// Titre du type de règlement
final String title;
const PaymentData({
required this.typeId,
required this.amount,
required this.color,
required this.icon,
required this.title,
});
}

View File

@@ -0,0 +1,404 @@
import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_charts/charts.dart';
import 'package:geosector_app/presentation/widgets/charts/payment_data.dart';
import 'dart:math' as math;
/// Widget de graphique en camembert pour représenter la répartition des règlements
class PaymentPieChart extends StatefulWidget {
/// Liste des données de règlement à afficher dans le graphique
final List<PaymentData> payments;
/// Taille du graphique
final double size;
/// Taille des étiquettes
final double labelSize;
/// Afficher les pourcentages
final bool showPercentage;
/// Afficher les icônes
final bool showIcons;
/// Afficher la légende
final bool showLegend;
/// Format donut (anneau)
final bool isDonut;
/// Rayon central pour le format donut (en pourcentage)
final String innerRadius;
/// Activer l'effet 3D
final bool enable3DEffect;
/// Intensité de l'effet 3D (1.0 = normal, 2.0 = fort)
final double effect3DIntensity;
/// Activer l'effet d'explosion plus prononcé
final bool enableEnhancedExplode;
/// Utiliser un dégradé pour simuler l'effet 3D
final bool useGradient;
const PaymentPieChart({
super.key,
required this.payments,
this.size = 300,
this.labelSize = 12,
this.showPercentage = true,
this.showIcons = true,
this.showLegend = true,
this.isDonut = false,
this.innerRadius = '40%',
this.enable3DEffect = false,
this.effect3DIntensity = 1.0,
this.enableEnhancedExplode = false,
this.useGradient = false,
});
@override
State<PaymentPieChart> createState() => _PaymentPieChartState();
}
class _PaymentPieChartState extends State<PaymentPieChart>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
@override
void initState() {
super.initState();
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 2000),
);
_animationController.forward();
}
@override
void didUpdateWidget(PaymentPieChart oldWidget) {
super.didUpdateWidget(oldWidget);
// Relancer l'animation si les données ont changé
// Utiliser une comparaison plus stricte pour éviter des animations inutiles
bool shouldResetAnimation = false;
if (oldWidget.payments.length != widget.payments.length) {
shouldResetAnimation = true;
} else {
// Comparer les éléments importants uniquement
for (int i = 0; i < oldWidget.payments.length; i++) {
if (i >= widget.payments.length) break;
if (oldWidget.payments[i].amount != widget.payments[i].amount ||
oldWidget.payments[i].title != widget.payments[i].title) {
shouldResetAnimation = true;
break;
}
}
}
if (shouldResetAnimation) {
_animationController.reset();
_animationController.forward();
}
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
/// Prépare les données pour le graphique en camembert
List<PaymentData> _prepareChartData() {
// Filtrer les règlements avec un montant > 0
return widget.payments.where((payment) => payment.amount > 0).toList();
}
@override
Widget build(BuildContext context) {
final chartData = _prepareChartData();
// Si aucune donnée, afficher un message
if (chartData.isEmpty) {
return SizedBox(
width: widget.size,
height: widget.size,
child: const Center(
child: Text('Aucune donnée disponible'),
),
);
}
// Créer des animations pour différents aspects du graphique
final progressAnimation = CurvedAnimation(
parent: _animationController,
curve: Curves.easeOutCubic,
);
final explodeAnimation = CurvedAnimation(
parent: _animationController,
curve: Interval(0.7, 1.0, curve: Curves.elasticOut),
);
final opacityAnimation = CurvedAnimation(
parent: _animationController,
curve: Interval(0.1, 0.5, curve: Curves.easeIn),
);
return AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return SizedBox(
width: widget.size,
height: widget.size,
child: SfCircularChart(
margin: EdgeInsets.zero,
legend: Legend(
isVisible: widget.showLegend,
position: LegendPosition.bottom,
overflowMode: LegendItemOverflowMode.wrap,
textStyle: TextStyle(fontSize: widget.labelSize),
),
tooltipBehavior: TooltipBehavior(enable: true),
series: <CircularSeries>[
widget.isDonut
? DoughnutSeries<PaymentData, String>(
dataSource: chartData,
xValueMapper: (PaymentData data, _) => data.title,
yValueMapper: (PaymentData data, _) => data.amount,
pointColorMapper: (PaymentData data, _) {
if (widget.enable3DEffect) {
// Utiliser un angle différent pour chaque segment pour simuler un effet 3D
final index = chartData.indexOf(data);
final angle =
(index / chartData.length) * 2 * math.pi;
return widget.useGradient
? _createEnhanced3DColor(data.color, angle)
: _create3DColor(
data.color, widget.effect3DIntensity);
}
return data.color;
},
// Note: Le gradient n'est pas directement pris en charge dans cette version de Syncfusion
enableTooltip: true,
dataLabelMapper: (PaymentData data, _) {
if (widget.showPercentage) {
// Calculer le pourcentage avec une décimale
final total = chartData.fold(
0.0, (sum, item) => sum + item.amount);
final percentage = (data.amount / total * 100);
return '${percentage.toStringAsFixed(1)}%';
} else {
return data.title;
}
},
dataLabelSettings: DataLabelSettings(
isVisible: true,
labelPosition: ChartDataLabelPosition.outside,
textStyle: TextStyle(fontSize: widget.labelSize),
connectorLineSettings: const ConnectorLineSettings(
type: ConnectorType.curve,
length: '15%',
),
),
innerRadius: widget.innerRadius,
// Effet d'explosion plus prononcé pour donner du relief avec animation
explode: true,
explodeAll: widget.enableEnhancedExplode,
explodeIndex: widget.enableEnhancedExplode ? null : 0,
explodeOffset: widget.enableEnhancedExplode
? widget.enable3DEffect
? '${(12 * explodeAnimation.value).toStringAsFixed(1)}%'
: '${(8 * explodeAnimation.value).toStringAsFixed(1)}%'
: '${(5 * explodeAnimation.value).toStringAsFixed(1)}%',
// Effet 3D via l'opacité et les couleurs avec animation
opacity: widget.enable3DEffect
? 0.95 * opacityAnimation.value
: opacityAnimation.value,
// Animation progressive du graphique
animationDuration:
0, // On désactive l'animation intégrée car nous utilisons notre propre animation
startAngle: 270,
endAngle: 270 + (360 * progressAnimation.value).toInt(),
)
: PieSeries<PaymentData, String>(
dataSource: chartData,
xValueMapper: (PaymentData data, _) => data.title,
yValueMapper: (PaymentData data, _) => data.amount,
pointColorMapper: (PaymentData data, _) {
if (widget.enable3DEffect) {
// Utiliser un angle différent pour chaque segment pour simuler un effet 3D
final index = chartData.indexOf(data);
final angle =
(index / chartData.length) * 2 * math.pi;
return widget.useGradient
? _createEnhanced3DColor(data.color, angle)
: _create3DColor(
data.color, widget.effect3DIntensity);
}
return data.color;
},
// Note: Le gradient n'est pas directement pris en charge dans cette version de Syncfusion
enableTooltip: true,
dataLabelMapper: (PaymentData data, _) {
if (widget.showPercentage) {
// Calculer le pourcentage avec une décimale
final total = chartData.fold(
0.0, (sum, item) => sum + item.amount);
final percentage = (data.amount / total * 100);
return '${percentage.toStringAsFixed(1)}%';
} else {
return data.title;
}
},
dataLabelSettings: DataLabelSettings(
isVisible: true,
labelPosition: ChartDataLabelPosition.outside,
textStyle: TextStyle(fontSize: widget.labelSize),
connectorLineSettings: const ConnectorLineSettings(
type: ConnectorType.curve,
length: '15%',
),
),
// Effet d'explosion plus prononcé pour donner du relief avec animation
explode: true,
explodeAll: widget.enableEnhancedExplode,
explodeIndex: widget.enableEnhancedExplode ? null : 0,
explodeOffset: widget.enableEnhancedExplode
? widget.enable3DEffect
? '${(12 * explodeAnimation.value).toStringAsFixed(1)}%'
: '${(8 * explodeAnimation.value).toStringAsFixed(1)}%'
: '${(5 * explodeAnimation.value).toStringAsFixed(1)}%',
// Effet 3D via l'opacité et les couleurs avec animation
opacity: widget.enable3DEffect
? 0.95 * opacityAnimation.value
: opacityAnimation.value,
// Animation progressive du graphique
animationDuration:
0, // On désactive l'animation intégrée car nous utilisons notre propre animation
startAngle: 270,
endAngle: 270 + (360 * progressAnimation.value).toInt(),
),
],
annotations:
widget.showIcons ? _buildIconAnnotations(chartData) : null,
// Paramètres pour améliorer l'effet 3D
palette: widget.enable3DEffect ? _create3DPalette(chartData) : null,
// Ajouter un effet de bordure pour renforcer l'effet 3D
borderWidth: widget.enable3DEffect ? 0.5 : 0,
// Note: La rotation n'est pas directement prise en charge dans cette version de Syncfusion
),
);
},
);
}
/// Crée une couleur avec effet 3D en ajoutant des nuances
Color _create3DColor(Color baseColor, double intensity) {
// Ajuster la luminosité et la saturation pour créer un effet 3D plus prononcé
final hslColor = HSLColor.fromColor(baseColor);
// Augmenter la luminosité pour simuler un éclairage
final adjustedLightness =
(hslColor.lightness + 0.15 * intensity).clamp(0.0, 1.0);
// Augmenter légèrement la saturation pour des couleurs plus vives
final adjustedSaturation =
(hslColor.saturation + 0.05 * intensity).clamp(0.0, 1.0);
return hslColor
.withLightness(adjustedLightness)
.withSaturation(adjustedSaturation)
.toColor();
}
/// Crée une palette de couleurs pour l'effet 3D
List<Color> _create3DPalette(List<PaymentData> chartData) {
List<Color> palette = [];
// Créer des variations de couleurs pour chaque segment
for (var i = 0; i < chartData.length; i++) {
var data = chartData[i];
// Calculer un angle pour chaque segment pour simuler un éclairage directionnel
final angle = (i / chartData.length) * 2 * math.pi;
// Créer un effet d'ombre et de lumière en fonction de l'angle
final hslColor = HSLColor.fromColor(data.color);
// Ajuster la luminosité en fonction de l'angle
final lightAdjustment = 0.15 * widget.effect3DIntensity * math.sin(angle);
final adjustedLightness = (hslColor.lightness -
0.1 * widget.effect3DIntensity +
lightAdjustment)
.clamp(0.0, 1.0);
// Ajuster la saturation pour plus de profondeur
final adjustedSaturation =
(hslColor.saturation + 0.1 * widget.effect3DIntensity)
.clamp(0.0, 1.0);
final enhancedColor = hslColor
.withLightness(adjustedLightness)
.withSaturation(adjustedSaturation)
.toColor();
palette.add(enhancedColor);
}
return palette;
}
/// Crée une couleur avec effet 3D plus avancé
Color _createEnhanced3DColor(Color baseColor, double angle) {
// Simuler un effet de lumière directionnel
final hslColor = HSLColor.fromColor(baseColor);
// Ajuster la luminosité en fonction de l'angle pour simuler un éclairage
final adjustedLightness = hslColor.lightness +
(0.2 * widget.effect3DIntensity * math.sin(angle)).clamp(-0.3, 0.3);
return hslColor.withLightness(adjustedLightness.clamp(0.0, 1.0)).toColor();
}
/// Crée les annotations d'icônes pour le graphique
List<CircularChartAnnotation> _buildIconAnnotations(
List<PaymentData> chartData) {
final List<CircularChartAnnotation> annotations = [];
// Calculer le total pour les pourcentages
double total = chartData.fold(0.0, (sum, item) => sum + item.amount);
// Position angulaire actuelle (en radians)
double currentAngle = 0;
for (int i = 0; i < chartData.length; i++) {
final data = chartData[i];
final percentage = data.amount / total;
// Calculer l'angle central de ce segment
final segmentAngle = percentage * 2 * 3.14159;
final midAngle = currentAngle + (segmentAngle / 2);
// Ajouter une annotation pour l'icône
annotations.add(
CircularChartAnnotation(
widget: Icon(
data.icon,
color: Colors.white,
size: 16,
),
radius: '50%',
angle: (midAngle * (180 / 3.14159)).toInt(), // Convertir en degrés
),
);
// Mettre à jour l'angle actuel
currentAngle += segmentAngle;
}
return annotations;
}
}

View File

@@ -0,0 +1,33 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/presentation/widgets/charts/payment_data.dart';
/// Utilitaires pour les paiements et règlements
class PaymentUtils {
/// Convertit les données de règlement depuis les constantes AppKeys
///
/// [paymentAmounts] est une Map associant l'ID du type de règlement à son montant
static List<PaymentData> getPaymentDataFromAmounts(
Map<int, double> paymentAmounts) {
final List<PaymentData> paymentDataList = [];
// Parcourir tous les types de règlements définis dans AppKeys
AppKeys.typesReglements.forEach((typeId, typeData) {
// Vérifier si nous avons un montant pour ce type de règlement
final double amount = paymentAmounts[typeId] ?? 0.0;
// Créer un objet PaymentData pour ce type de règlement
final PaymentData paymentData = PaymentData(
typeId: typeId,
amount: amount,
color: Color(typeData['couleur'] as int),
icon: typeData['icon_data'] as IconData,
title: typeData['titre'] as String,
);
paymentDataList.add(paymentData);
});
return paymentDataList;
}
}

View File

@@ -0,0 +1,219 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/shared/app_theme.dart';
/// Widget pour la zone de saisie des messages
class ChatInput extends StatefulWidget {
final Function(String) onMessageSent;
const ChatInput({
Key? key,
required this.onMessageSent,
}) : super(key: key);
@override
State<ChatInput> createState() => _ChatInputState();
}
class _ChatInputState extends State<ChatInput> {
final TextEditingController _controller = TextEditingController();
bool _isComposing = false;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: AppTheme.spacingM,
vertical: AppTheme.spacingS,
),
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 5,
offset: const Offset(0, -2),
),
],
),
child: Row(
children: [
// Bouton pour ajouter des pièces jointes
IconButton(
icon: const Icon(Icons.attach_file),
color: AppTheme.primaryColor,
onPressed: () {
// Afficher les options de pièces jointes
_showAttachmentOptions(context);
},
),
// Champ de saisie du message
Expanded(
child: TextField(
controller: _controller,
decoration: InputDecoration(
hintText: 'Écrivez votre message...',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
borderSide: BorderSide.none,
),
filled: true,
fillColor: Colors.grey[100],
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
),
textCapitalization: TextCapitalization.sentences,
maxLines: null,
keyboardType: TextInputType.multiline,
textInputAction: TextInputAction.newline,
onChanged: (text) {
setState(() {
_isComposing = text.isNotEmpty;
});
},
),
),
// Bouton d'envoi
IconButton(
icon: Icon(
_isComposing ? Icons.send : Icons.mic,
color: _isComposing ? AppTheme.primaryColor : Colors.grey[600],
),
onPressed: _isComposing
? () {
final text = _controller.text.trim();
if (text.isNotEmpty) {
widget.onMessageSent(text);
_controller.clear();
setState(() {
_isComposing = false;
});
}
}
: () {
// Activer la reconnaissance vocale
},
),
],
),
);
}
// Afficher les options de pièces jointes
void _showAttachmentOptions(BuildContext context) {
showModalBottomSheet(
context: context,
builder: (context) => Container(
padding: const EdgeInsets.symmetric(
vertical: AppTheme.spacingL,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Ajouter une pièce jointe',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
const SizedBox(height: AppTheme.spacingL),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildAttachmentOption(
context,
Icons.photo,
'Photo',
Colors.green,
() {
Navigator.pop(context);
// Sélectionner une photo
},
),
_buildAttachmentOption(
context,
Icons.camera_alt,
'Caméra',
Colors.blue,
() {
Navigator.pop(context);
// Prendre une photo
},
),
_buildAttachmentOption(
context,
Icons.insert_drive_file,
'Document',
Colors.orange,
() {
Navigator.pop(context);
// Sélectionner un document
},
),
_buildAttachmentOption(
context,
Icons.location_on,
'Position',
Colors.red,
() {
Navigator.pop(context);
// Partager la position
},
),
],
),
const SizedBox(height: AppTheme.spacingL),
],
),
),
);
}
// Construire une option de pièce jointe
Widget _buildAttachmentOption(
BuildContext context,
IconData icon,
String label,
Color color,
VoidCallback onTap,
) {
return InkWell(
onTap: onTap,
child: Column(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: color.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Icon(
icon,
color: color,
size: 28,
),
),
const SizedBox(height: 8),
Text(
label,
style: TextStyle(
color: Colors.grey[800],
fontSize: 12,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,245 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/shared/app_theme.dart';
/// Widget pour afficher les messages d'une conversation
class ChatMessages extends StatelessWidget {
final List<Map<String, dynamic>> messages;
final int currentUserId;
final Function(Map<String, dynamic>) onReply;
const ChatMessages({
Key? key,
required this.messages,
required this.currentUserId,
required this.onReply,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return messages.isEmpty
? const Center(
child: Text('Aucun message dans cette conversation'),
)
: ListView.builder(
padding: const EdgeInsets.all(AppTheme.spacingM),
itemCount: messages.length,
reverse:
false, // Afficher les messages du plus ancien au plus récent
itemBuilder: (context, index) {
final message = messages[index];
final isCurrentUser = message['senderId'] == currentUserId;
final hasReply = message['replyTo'] != null;
return Padding(
padding: const EdgeInsets.only(bottom: AppTheme.spacingM),
child: Column(
crossAxisAlignment: isCurrentUser
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
children: [
// Afficher le message auquel on répond
if (hasReply) ...[
Container(
margin: EdgeInsets.only(
left: isCurrentUser ? 0 : 40,
right: isCurrentUser ? 40 : 0,
bottom: 4,
),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Réponse à ${message['replyTo']['senderName']}',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
color: AppTheme.primaryColor,
),
),
Text(
message['replyTo']['message'],
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
// Message principal
Row(
mainAxisAlignment: isCurrentUser
? MainAxisAlignment.end
: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Avatar (seulement pour les messages des autres)
if (!isCurrentUser)
CircleAvatar(
radius: 16,
backgroundColor:
AppTheme.primaryColor.withOpacity(0.2),
backgroundImage: message['avatar'] != null
? AssetImage(message['avatar'] as String)
: null,
child: message['avatar'] == null
? Text(
message['senderName'].isNotEmpty
? message['senderName'][0].toUpperCase()
: '',
style: const TextStyle(
color: AppTheme.primaryColor,
fontWeight: FontWeight.bold,
fontSize: 12,
),
)
: null,
),
const SizedBox(width: 8),
// Contenu du message
Flexible(
child: Column(
crossAxisAlignment: isCurrentUser
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
children: [
// Nom de l'expéditeur (seulement pour les messages des autres)
if (!isCurrentUser)
Padding(
padding:
const EdgeInsets.only(left: 4, bottom: 2),
child: Text(
message['senderName'],
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
// Bulle de message
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: isCurrentUser
? AppTheme.primaryColor
: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 3,
offset: const Offset(0, 1),
),
],
),
child: Text(
message['message'],
style: TextStyle(
color: isCurrentUser
? Colors.white
: Colors.black87,
),
),
),
// Heure et statut
Padding(
padding: const EdgeInsets.only(top: 4, left: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_formatTime(message['time']),
style: TextStyle(
fontSize: 10,
color: Colors.grey[600],
),
),
const SizedBox(width: 4),
if (isCurrentUser)
Icon(
message['isRead']
? Icons.done_all
: Icons.done,
size: 12,
color: message['isRead']
? Colors.blue
: Colors.grey[600],
),
],
),
),
],
),
),
const SizedBox(width: 8),
// Menu d'actions (seulement pour les messages des autres)
if (!isCurrentUser)
PopupMenuButton<String>(
icon: Icon(
Icons.more_vert,
size: 16,
color: Colors.grey[600],
),
padding: EdgeInsets.zero,
itemBuilder: (context) => [
const PopupMenuItem<String>(
value: 'reply',
child: Row(
children: [
Icon(Icons.reply, size: 16),
SizedBox(width: 8),
Text('Répondre'),
],
),
),
const PopupMenuItem<String>(
value: 'copy',
child: Row(
children: [
Icon(Icons.content_copy, size: 16),
SizedBox(width: 8),
Text('Copier'),
],
),
),
],
onSelected: (value) {
if (value == 'reply') {
onReply(message);
} else if (value == 'copy') {
// Copier le message
}
},
),
],
),
],
),
);
},
);
}
// Formater l'heure du message
String _formatTime(DateTime time) {
return '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}';
}
}

View File

@@ -0,0 +1,219 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/shared/app_theme.dart';
/// Widget pour afficher la barre latérale des contacts
class ChatSidebar extends StatelessWidget {
final List<Map<String, dynamic>> teamContacts;
final List<Map<String, dynamic>> clientContacts;
final bool isTeamChat;
final int selectedContactId;
final Function(int, String, bool) onContactSelected;
final Function(bool) onToggleGroup;
const ChatSidebar({
Key? key,
required this.teamContacts,
required this.clientContacts,
required this.isTeamChat,
required this.selectedContactId,
required this.onContactSelected,
required this.onToggleGroup,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Column(
children: [
// En-tête avec les onglets
Container(
padding: const EdgeInsets.all(AppTheme.spacingM),
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 5,
offset: const Offset(0, 2),
),
],
),
child: Row(
children: [
Expanded(
child: _buildTabButton(
context,
'Équipe',
isTeamChat,
() => onToggleGroup(true),
),
),
const SizedBox(width: AppTheme.spacingS),
Expanded(
child: _buildTabButton(
context,
'Clients',
!isTeamChat,
() => onToggleGroup(false),
),
),
],
),
),
// Liste des contacts
Expanded(
child: Container(
color: Colors.grey[100],
child: ListView(
padding: EdgeInsets.zero,
children: [
// Afficher les contacts appropriés en fonction de l'onglet sélectionné
...isTeamChat
? teamContacts.map(
(contact) => _buildContactItem(context, contact, true))
: clientContacts.map((contact) =>
_buildContactItem(context, contact, false)),
],
),
),
),
],
);
}
// Construire un bouton d'onglet
Widget _buildTabButton(
BuildContext context,
String label,
bool isSelected,
VoidCallback onPressed,
) {
return ElevatedButton(
onPressed: onPressed,
style: ElevatedButton.styleFrom(
backgroundColor: isSelected ? AppTheme.primaryColor : Colors.grey[200],
foregroundColor: isSelected ? Colors.white : Colors.black,
elevation: isSelected ? 2 : 0,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppTheme.borderRadiusSmall),
),
),
child: Text(label),
);
}
// Construire un élément de contact
Widget _buildContactItem(
BuildContext context,
Map<String, dynamic> contact,
bool isTeam,
) {
final bool isSelected = contact['id'] == selectedContactId;
final bool hasUnread = (contact['unread'] as int) > 0;
return ListTile(
selected: isSelected,
selectedTileColor: Colors.blue.withOpacity(0.1),
leading: CircleAvatar(
backgroundColor: AppTheme.primaryColor.withOpacity(0.2),
backgroundImage: contact['avatar'] != null
? AssetImage(contact['avatar'] as String)
: null,
child: contact['avatar'] == null
? Text(
(contact['name'] as String).isNotEmpty
? (contact['name'] as String)[0].toUpperCase()
: '',
style: const TextStyle(
color: AppTheme.primaryColor,
fontWeight: FontWeight.bold,
),
)
: null,
),
title: Row(
children: [
Expanded(
child: Text(
contact['name'] as String,
style: TextStyle(
fontWeight: hasUnread ? FontWeight.bold : FontWeight.normal,
),
overflow: TextOverflow.ellipsis,
),
),
if (contact['online'] == true)
Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: Colors.green,
shape: BoxShape.circle,
),
),
],
),
subtitle: Text(
contact['lastMessage'] as String,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: hasUnread ? FontWeight.bold : FontWeight.normal,
color: hasUnread ? Colors.black87 : Colors.grey[600],
),
),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
_formatTime(contact['time'] as DateTime),
style: TextStyle(
fontSize: 12,
color: hasUnread ? AppTheme.primaryColor : Colors.grey[500],
),
),
const SizedBox(height: 4),
if (hasUnread)
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: AppTheme.primaryColor,
shape: BoxShape.circle,
),
child: Text(
(contact['unread'] as int).toString(),
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
],
),
onTap: () => onContactSelected(
contact['id'] as int,
contact['name'] as String,
isTeam,
),
);
}
// Formater l'heure du dernier message
String _formatTime(DateTime time) {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final yesterday = today.subtract(const Duration(days: 1));
final messageDate = DateTime(time.year, time.month, time.day);
if (messageDate == today) {
return '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}';
} else if (messageDate == yesterday) {
return 'Hier';
} else {
return '${time.day}/${time.month}';
}
}
}

View File

@@ -0,0 +1,156 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/core/services/connectivity_service.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
/// Widget qui affiche l'état de la connexion Internet
class ConnectivityIndicator extends StatelessWidget {
/// Si true, affiche un message d'erreur lorsque l'appareil est déconnecté
final bool showErrorMessage;
/// Si true, affiche un badge avec le type de connexion (WiFi, données mobiles)
final bool showConnectionType;
/// Callback appelé lorsque l'état de la connexion change
final Function(bool isConnected)? onConnectivityChanged;
const ConnectivityIndicator({
super.key,
this.showErrorMessage = true,
this.showConnectionType = true,
this.onConnectivityChanged,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
// Utiliser l'instance globale de connectivityService définie dans app.dart
final isConnected = connectivityService.isConnected;
final connectionType = connectivityService.connectionType;
final connectionStatus = connectivityService.connectionStatus;
// Appeler le callback si fourni, mais pas directement dans le build
// pour éviter les problèmes de rendu
WidgetsBinding.instance.addPostFrameCallback((_) {
if (onConnectivityChanged != null) {
onConnectivityChanged!(isConnected);
}
});
if (!isConnected && showErrorMessage) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
margin: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration(
color: theme.colorScheme.error.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: theme.colorScheme.error.withOpacity(0.3),
),
),
child: Row(
children: [
Icon(
Icons.wifi_off,
color: theme.colorScheme.error,
size: 18,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Aucune connexion Internet. Certaines fonctionnalités peuvent être limitées.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
),
),
],
),
);
} else if (isConnected && showConnectionType) {
// Obtenir la couleur et l'icône en fonction du type de connexion
final color = _getConnectionColor(connectionStatus, theme);
final icon = _getConnectionIcon(connectionStatus);
return Container(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: color.withOpacity(0.3),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
color: color,
size: 14,
),
const SizedBox(width: 4),
Text(
connectionType,
style: theme.textTheme.bodySmall?.copyWith(
color: color,
fontWeight: FontWeight.bold,
),
),
],
),
);
}
// Si aucune condition n'est remplie ou si showErrorMessage et showConnectionType sont false
return const SizedBox.shrink();
}
/// Retourne l'icône correspondant au type de connexion
IconData _getConnectionIcon(List<ConnectivityResult> statusList) {
// Utiliser le premier type de connexion qui n'est pas 'none'
ConnectivityResult status = statusList.firstWhere(
(result) => result != ConnectivityResult.none,
orElse: () => ConnectivityResult.none);
switch (status) {
case ConnectivityResult.wifi:
return Icons.wifi;
case ConnectivityResult.mobile:
return Icons.signal_cellular_alt;
case ConnectivityResult.ethernet:
return Icons.lan;
case ConnectivityResult.bluetooth:
return Icons.bluetooth;
case ConnectivityResult.vpn:
return Icons.vpn_key;
default:
return Icons.wifi_off;
}
}
/// Retourne la couleur correspondant au type de connexion
Color _getConnectionColor(
List<ConnectivityResult> statusList, ThemeData theme) {
// Utiliser le premier type de connexion qui n'est pas 'none'
ConnectivityResult status = statusList.firstWhere(
(result) => result != ConnectivityResult.none,
orElse: () => ConnectivityResult.none);
switch (status) {
case ConnectivityResult.wifi:
return Colors.green;
case ConnectivityResult.mobile:
return Colors.blue;
case ConnectivityResult.ethernet:
return Colors.purple;
case ConnectivityResult.bluetooth:
return Colors.indigo;
case ConnectivityResult.vpn:
return Colors.orange;
default:
return theme.colorScheme.error;
}
}
}

View File

@@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
class CustomButton extends StatelessWidget {
final VoidCallback? onPressed;
final String text;
final IconData? icon;
final bool isLoading;
final double? width;
final Color? backgroundColor;
final Color? textColor;
const CustomButton({
super.key,
required this.onPressed,
required this.text,
this.icon,
this.isLoading = false,
this.width,
this.backgroundColor,
this.textColor,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SizedBox(
width: width,
child: ElevatedButton(
onPressed: isLoading ? null : onPressed,
style: ElevatedButton.styleFrom(
backgroundColor: backgroundColor ?? theme.colorScheme.primary,
foregroundColor: textColor ?? Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 2,
),
child: isLoading
? SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
textColor ?? Colors.white,
),
),
)
: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (icon != null) ...[
Icon(icon),
const SizedBox(width: 8),
],
Text(
text,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,134 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class CustomTextField extends StatelessWidget {
final TextEditingController controller;
final String label;
final String? hintText;
final IconData? prefixIcon;
final Widget? suffixIcon;
final bool obscureText;
final TextInputType keyboardType;
final String? Function(String?)? validator;
final List<TextInputFormatter>? inputFormatters;
final int? maxLines;
final int? minLines;
final bool readOnly;
final VoidCallback? onTap;
final Function(String)? onChanged;
final bool autofocus;
final FocusNode? focusNode;
final String? errorText;
final Color? fillColor;
final String? helperText;
final Function(String)? onFieldSubmitted;
const CustomTextField({
super.key,
required this.controller,
required this.label,
this.hintText,
this.prefixIcon,
this.suffixIcon,
this.obscureText = false,
this.keyboardType = TextInputType.text,
this.validator,
this.inputFormatters,
this.maxLines = 1,
this.minLines,
this.readOnly = false,
this.onTap,
this.onChanged,
this.autofocus = false,
this.focusNode,
this.errorText,
this.fillColor,
this.helperText,
this.onFieldSubmitted,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (label.isNotEmpty) ...[
Text(
label,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w500,
color: theme.colorScheme.onBackground,
),
),
const SizedBox(height: 8),
],
TextFormField(
controller: controller,
obscureText: obscureText,
keyboardType: keyboardType,
validator: validator,
inputFormatters: inputFormatters,
maxLines: maxLines,
minLines: minLines,
readOnly: readOnly,
onTap: onTap,
onChanged: onChanged,
onFieldSubmitted: onFieldSubmitted,
autofocus: autofocus,
focusNode: focusNode,
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onBackground,
),
decoration: InputDecoration(
hintText: hintText,
hintStyle: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onBackground.withOpacity(0.5),
),
errorText: errorText,
helperText: helperText,
helperStyle: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onBackground.withOpacity(0.6),
),
prefixIcon: prefixIcon != null
? Icon(prefixIcon, color: theme.colorScheme.primary)
: null,
suffixIcon: suffixIcon,
fillColor: fillColor ?? theme.inputDecorationTheme.fillColor,
filled: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: theme.colorScheme.primary,
width: 2,
),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: theme.colorScheme.error,
width: 2,
),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: theme.colorScheme.error,
width: 2,
),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 16,
),
),
),
],
);
}
}

View File

@@ -0,0 +1,169 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/app.dart';
import 'package:geosector_app/presentation/widgets/connectivity_indicator.dart';
import 'package:go_router/go_router.dart';
/// AppBar personnalisée pour les tableaux de bord
class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget {
/// Le titre principal de l'AppBar (généralement le nom de l'application)
final String title;
/// Le titre de la page actuelle (optionnel)
final String? pageTitle;
/// Actions supplémentaires à afficher dans l'AppBar
final List<Widget>? additionalActions;
/// Indique si le bouton "Nouveau passage" doit être affiché
final bool showNewPassageButton;
/// Callback appelé lorsque le bouton "Nouveau passage" est pressé
final VoidCallback? onNewPassagePressed;
/// Indique si l'utilisateur est un administrateur
final bool isAdmin;
/// Callback appelé lorsque le bouton de déconnexion est pressé
final VoidCallback? onLogoutPressed;
const DashboardAppBar({
Key? key,
required this.title,
this.pageTitle,
this.additionalActions,
this.showNewPassageButton = true,
this.onNewPassagePressed,
this.isAdmin = false,
this.onLogoutPressed,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AppBar(
title: _buildTitle(context),
backgroundColor: theme.colorScheme.primary,
foregroundColor: theme.colorScheme.onPrimary,
elevation: 4,
leading: _buildLogo(),
actions: _buildActions(context),
);
}
/// Construction du logo dans l'AppBar
Widget _buildLogo() {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Image.asset(
'assets/images/geosector-logo-80.png',
width: 40,
height: 40,
),
);
}
/// Construction des actions de l'AppBar
List<Widget> _buildActions(BuildContext context) {
final theme = Theme.of(context);
final List<Widget> actions = [];
// Ajouter l'indicateur de connectivité
actions.add(
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 12.0),
child: const ConnectivityIndicator(
showErrorMessage: false,
showConnectionType: true,
),
),
);
// Ajouter les actions supplémentaires si elles existent
if (additionalActions != null && additionalActions!.isNotEmpty) {
actions.addAll(additionalActions!);
} else if (showNewPassageButton) {
// Ajouter le bouton "Nouveau passage" en haut à droite
actions.add(
TextButton.icon(
icon: const Icon(Icons.add_location_alt, color: Colors.white),
label: const Text('Nouveau passage',
style: TextStyle(color: Colors.white)),
onPressed: onNewPassagePressed,
style: TextButton.styleFrom(
backgroundColor: theme.colorScheme.secondary,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
),
);
}
// Ajouter le bouton de déconnexion
actions.add(
IconButton(
icon: const Icon(Icons.logout),
tooltip: 'Déconnexion',
onPressed: onLogoutPressed ??
() {
// Si aucun callback n'est fourni, utiliser le userRepository global
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Déconnexion'),
content:
const Text('Voulez-vous vraiment vous déconnecter ?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
TextButton(
onPressed: () async {
Navigator.of(context).pop();
// Appeler la méthode logout du userRepository
// qui nettoie les hive boxes, lance la requête API logout
// et supprime user.sessionId
await userRepository.logout();
// Rediriger vers la landing page
if (context.mounted) {
// Utiliser go_router pour la navigation
context.go('/public');
}
},
child: const Text('Déconnexion'),
),
],
),
);
},
),
);
actions.add(const SizedBox(width: 8)); // Espacement à droite
return actions;
}
/// Construction du titre de l'AppBar
Widget _buildTitle(BuildContext context) {
// Si aucun titre de page n'est fourni, afficher simplement le titre principal
if (pageTitle == null) {
return Text(title);
}
// Construire un titre composé en fonction du rôle de l'utilisateur
final String prefix = isAdmin ? 'Administration' : title;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(prefix),
const Text(' - '),
Text(pageTitle!),
],
);
}
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
}

View File

@@ -0,0 +1,142 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/presentation/widgets/dashboard_app_bar.dart';
import 'package:geosector_app/presentation/widgets/responsive_navigation.dart';
/// Layout commun pour les tableaux de bord utilisateur et administrateur
/// Combine DashboardAppBar et ResponsiveNavigation
class DashboardLayout extends StatelessWidget {
/// Le contenu principal à afficher
final Widget body;
/// Le titre de la page
final String title;
/// L'index de la page sélectionnée
final int selectedIndex;
/// Callback appelé lorsqu'un élément de navigation est sélectionné
final Function(int) onDestinationSelected;
/// Liste des destinations de navigation
final List<NavigationDestination> destinations;
/// Actions supplémentaires à afficher dans l'AppBar
final List<Widget>? additionalActions;
/// Indique si le bouton "Nouveau passage" doit être affiché
final bool showNewPassageButton;
/// Callback appelé lorsque le bouton "Nouveau passage" est pressé
final VoidCallback? onNewPassagePressed;
/// Widgets à afficher en bas de la sidebar
final List<Widget>? sidebarBottomItems;
/// Indique si l'utilisateur est un administrateur
final bool isAdmin;
/// Callback appelé lorsque le bouton de déconnexion est pressé
final VoidCallback? onLogoutPressed;
const DashboardLayout({
Key? key,
required this.body,
required this.title,
required this.selectedIndex,
required this.onDestinationSelected,
required this.destinations,
this.additionalActions,
this.showNewPassageButton = true,
this.onNewPassagePressed,
this.sidebarBottomItems,
this.isAdmin = false,
this.onLogoutPressed,
}) : super(key: key);
@override
Widget build(BuildContext context) {
try {
debugPrint('Building DashboardLayout');
// Vérifier que les destinations ne sont pas vides
if (destinations.isEmpty) {
debugPrint('ERREUR: destinations est vide dans DashboardLayout');
return const Scaffold(
body: Center(
child: Text('Erreur: Aucune destination de navigation disponible'),
),
);
}
// Vérifier que selectedIndex est valide
if (selectedIndex < 0 || selectedIndex >= destinations.length) {
debugPrint('ERREUR: selectedIndex invalide dans DashboardLayout');
return Scaffold(
body: Center(
child:
Text('Erreur: Index de navigation invalide ($selectedIndex)'),
),
);
}
return Scaffold(
appBar: DashboardAppBar(
title: title,
pageTitle: destinations[selectedIndex].label,
additionalActions: additionalActions,
showNewPassageButton: showNewPassageButton,
onNewPassagePressed: onNewPassagePressed,
isAdmin: isAdmin,
onLogoutPressed: onLogoutPressed,
),
body: ResponsiveNavigation(
title:
title, // Même si le titre n'est pas affiché dans la navigation, il est utilisé pour la cohérence
body: body,
selectedIndex: selectedIndex,
onDestinationSelected: onDestinationSelected,
destinations: destinations,
// Ne pas afficher le bouton "Nouveau passage" dans la navigation car il est déjà dans l'AppBar
showNewPassageButton: false,
onNewPassagePressed: onNewPassagePressed,
sidebarBottomItems: sidebarBottomItems,
isAdmin: isAdmin,
// Ne pas afficher l'AppBar dans la navigation car nous utilisons DashboardAppBar
showAppBar: false,
),
);
} catch (e) {
debugPrint('ERREUR CRITIQUE dans DashboardLayout.build: $e');
// Afficher une interface de secours en cas d'erreur
return Scaffold(
appBar: AppBar(
title: Text('Erreur - $title'),
backgroundColor: Colors.red,
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, color: Colors.red, size: 64),
const SizedBox(height: 16),
const Text(
'Une erreur est survenue',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text('Détails: $e'),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () {
Navigator.of(context)
.pushNamedAndRemoveUntil('/', (route) => false);
},
child: const Text('Retour à l\'accueil'),
),
],
),
),
);
}
}
}

View File

@@ -0,0 +1,110 @@
import 'package:flutter/material.dart';
/// Widget d'aide commun pour toute l'application
/// Affiche une boîte de dialogue modale avec une aide contextuelle
/// basée sur la page courante
class HelpDialog extends StatelessWidget {
/// Nom de la page courante pour laquelle l'aide est demandée
final String currentPage;
const HelpDialog({
Key? key,
required this.currentPage,
}) : super(key: key);
/// Affiche la boîte de dialogue d'aide
static void show(BuildContext context, String currentPage) {
showDialog(
context: context,
builder: (context) => HelpDialog(currentPage: currentPage),
);
}
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
final theme = Theme.of(context);
// Déterminer si nous sommes sur un appareil mobile ou un ordinateur de bureau
final isDesktop = size.width > 900;
// Calculer la largeur de la boîte de dialogue
// 90% de la largeur de l'écran pour les mobiles
// 50% de la largeur de l'écran pour les ordinateurs de bureau (max 600px)
final dialogWidth = isDesktop
? size.width * 0.5 > 600
? 600.0
: size.width * 0.5
: size.width * 0.9;
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
// Définir la largeur de la boîte de dialogue
child: Container(
width: dialogWidth,
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Titre de l'aide avec le nom de la page courante
Row(
children: [
Icon(
Icons.help_outline,
color: theme.colorScheme.primary,
size: 28,
),
const SizedBox(width: 12),
Expanded(
child: Text(
'Aide - Page $currentPage',
style: theme.textTheme.titleLarge?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
tooltip: 'Fermer',
),
],
),
const Divider(height: 32),
// Contenu de l'aide (à personnaliser selon la page)
Text(
'Contenu d\'aide pour la page "$currentPage".',
style: theme.textTheme.bodyLarge,
),
const SizedBox(height: 16),
Text(
'Cette section sera personnalisée avec des instructions spécifiques pour chaque page de l\'application.',
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 24),
// Bouton pour fermer la boîte de dialogue
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () => Navigator.of(context).pop(),
style: TextButton.styleFrom(
backgroundColor: theme.colorScheme.primary,
foregroundColor: theme.colorScheme.onPrimary,
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
),
child: const Text('Fermer'),
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,88 @@
import 'package:flutter/material.dart';
/// Widget d'overlay de chargement qui affiche un spinner centré avec un message optionnel
/// Utilisé pour les opérations longues comme la connexion, déconnexion et synchronisation
class LoadingOverlay extends StatelessWidget {
final String? message;
final Color backgroundColor;
final Color spinnerColor;
final Color textColor;
final double spinnerSize;
final double strokeWidth;
const LoadingOverlay({
Key? key,
this.message,
this.backgroundColor = Colors.black54,
this.spinnerColor = Colors.white,
this.textColor = Colors.white,
this.spinnerSize = 60.0,
this.strokeWidth = 5.0,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
color: backgroundColor,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: spinnerSize,
height: spinnerSize,
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(spinnerColor),
strokeWidth: strokeWidth,
),
),
if (message != null) ...[ // Afficher le texte seulement si message n'est pas null
const SizedBox(height: 24),
Text(
message!,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: textColor,
),
textAlign: TextAlign.center,
),
],
],
),
),
);
}
/// Méthode statique pour afficher l'overlay de chargement
static Future<T> show<T>({
required BuildContext context,
required Future<T> future,
String? message,
double spinnerSize = 60.0,
double strokeWidth = 5.0,
}) async {
// Afficher l'overlay
final overlayEntry = OverlayEntry(
builder: (context) => LoadingOverlay(
message: message,
spinnerSize: spinnerSize,
strokeWidth: strokeWidth,
),
);
Overlay.of(context).insert(overlayEntry);
try {
// Attendre que le future se termine
final result = await future;
// Supprimer l'overlay
overlayEntry.remove();
return result;
} catch (e) {
// En cas d'erreur, supprimer l'overlay et relancer l'erreur
overlayEntry.remove();
rethrow;
}
}
}

View File

@@ -0,0 +1,200 @@
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
/// Widget de carte réutilisable utilisant Mapbox
///
/// Ce widget encapsule un FlutterMap avec des tuiles Mapbox et fournit
/// des fonctionnalités pour afficher des marqueurs, des polygones et des contrôles.
class MapboxMap extends StatefulWidget {
/// Position initiale de la carte
final LatLng initialPosition;
/// Niveau de zoom initial
final double initialZoom;
/// Liste des marqueurs à afficher
final List<Marker>? markers;
/// Liste des polygones à afficher
final List<Polygon>? polygons;
/// Contrôleur de carte externe (optionnel)
final MapController? mapController;
/// Callback appelé lorsque la carte est déplacée
final void Function(MapEvent)? onMapEvent;
/// Afficher les boutons de contrôle (zoom, localisation)
final bool showControls;
/// Style de la carte Mapbox (optionnel)
/// Si non spécifié, utilise le style par défaut 'mapbox/streets-v12'
final String? mapStyle;
const MapboxMap({
super.key,
this.initialPosition = const LatLng(48.1173, -1.6778), // Rennes par défaut
this.initialZoom = 13.0,
this.markers,
this.polygons,
this.mapController,
this.onMapEvent,
this.showControls = true,
this.mapStyle,
});
@override
State<MapboxMap> createState() => _MapboxMapState();
}
class _MapboxMapState extends State<MapboxMap> {
/// Contrôleur de carte interne
late final MapController _mapController;
/// Niveau de zoom actuel
double _currentZoom = 13.0;
@override
void initState() {
super.initState();
_mapController = widget.mapController ?? MapController();
_currentZoom = widget.initialZoom;
}
@override
void dispose() {
// Ne pas disposer le contrôleur s'il a été fourni de l'extérieur
if (widget.mapController == null) {
_mapController.dispose();
}
super.dispose();
}
/// Construit un bouton de contrôle de carte
Widget _buildMapButton({
required IconData icon,
required VoidCallback onPressed,
}) {
return Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 6,
offset: const Offset(0, 3),
),
],
),
child: IconButton(
icon: Icon(icon, size: 20),
onPressed: onPressed,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
);
}
@override
Widget build(BuildContext context) {
// Déterminer l'URL du template de tuiles Mapbox
final String mapboxToken = AppKeys.mapboxApiKey;
final String mapStyle = widget.mapStyle ?? 'mapbox/streets-v11';
final String urlTemplate = 'https://api.mapbox.com/styles/v1/$mapStyle/tiles/256/{z}/{x}/{y}@2x?access_token=$mapboxToken';
return Stack(
children: [
// Carte principale
FlutterMap(
mapController: _mapController,
options: MapOptions(
initialCenter: widget.initialPosition,
initialZoom: widget.initialZoom,
onMapEvent: (event) {
if (event is MapEventMove) {
setState(() {
// Dans flutter_map 8.1.1, nous devons utiliser le contrôleur pour obtenir le zoom actuel
_currentZoom = _mapController.camera.zoom;
});
}
// Appeler le callback externe si fourni
if (widget.onMapEvent != null) {
widget.onMapEvent!(event);
}
},
),
children: [
// Tuiles de la carte (Mapbox)
TileLayer(
urlTemplate: urlTemplate,
userAgentPackageName: 'app.geosector.fr',
maxNativeZoom: 19,
additionalOptions: {
'accessToken': mapboxToken,
},
),
// Polygones
if (widget.polygons != null && widget.polygons!.isNotEmpty)
PolygonLayer(polygons: widget.polygons!),
// Marqueurs
if (widget.markers != null && widget.markers!.isNotEmpty)
MarkerLayer(markers: widget.markers!),
],
),
// Boutons de contrôle
if (widget.showControls)
Positioned(
bottom: 16,
right: 16,
child: Column(
children: [
// Bouton de zoom +
_buildMapButton(
icon: Icons.add,
onPressed: () {
_mapController.move(
_mapController.camera.center,
_mapController.camera.zoom + 1,
);
},
),
const SizedBox(height: 8),
// Bouton de zoom -
_buildMapButton(
icon: Icons.remove,
onPressed: () {
_mapController.move(
_mapController.camera.center,
_mapController.camera.zoom - 1,
);
},
),
const SizedBox(height: 8),
// Bouton de localisation
_buildMapButton(
icon: Icons.my_location,
onPressed: () {
_mapController.move(
widget.initialPosition,
15,
);
},
),
],
),
),
],
);
}
}

View File

@@ -0,0 +1,918 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:geosector_app/core/theme/app_theme.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
/// Un widget réutilisable pour afficher une liste de passages avec filtres
class PassagesListWidget extends StatefulWidget {
/// Liste des passages à afficher
final List<Map<String, dynamic>> passages;
/// Titre de la section (optionnel)
final String? title;
/// Nombre maximum de passages à afficher (optionnel)
final int? maxPassages;
/// Si vrai, les filtres seront affichés
final bool showFilters;
/// Si vrai, la barre de recherche sera affichée
final bool showSearch;
/// Si vrai, les boutons d'action (détails, modifier, etc.) seront affichés
final bool showActions;
/// Callback appelé lorsqu'un passage est sélectionné
final Function(Map<String, dynamic>)? onPassageSelected;
/// Callback appelé lorsqu'un passage est modifié
final Function(Map<String, dynamic>)? onPassageEdit;
/// Callback appelé lorsqu'un reçu est demandé
final Function(Map<String, dynamic>)? onReceiptView;
/// Callback appelé lorsque les détails sont demandés
final Function(Map<String, dynamic>)? onDetailsView;
/// Filtres initiaux (optionnels)
final String? initialTypeFilter;
final String? initialPaymentFilter;
final String? initialSearchQuery;
/// Filtres avancés (optionnels)
/// Liste des types de passages à exclure (ex: [2] pour exclure les passages "À finaliser")
final List<int>? excludePassageTypes;
/// ID de l'utilisateur pour filtrer les passages (null = tous les utilisateurs)
final int? filterByUserId;
/// ID du secteur pour filtrer les passages (null = tous les secteurs)
final int? filterBySectorId;
/// Période de filtrage pour la date passedAt
final String? periodFilter; // 'last15', 'lastWeek', 'lastMonth', 'custom'
/// Plage de dates personnalisée pour le filtrage (utilisé si periodFilter = 'custom')
final DateTimeRange? dateRange;
const PassagesListWidget({
super.key,
required this.passages,
this.title,
this.maxPassages,
this.showFilters = true,
this.showSearch = true,
this.showActions = true,
this.onPassageSelected,
this.onPassageEdit,
this.onReceiptView,
this.onDetailsView,
this.initialTypeFilter,
this.initialPaymentFilter,
this.initialSearchQuery,
this.excludePassageTypes,
this.filterByUserId,
this.filterBySectorId,
this.periodFilter,
this.dateRange,
});
@override
State<PassagesListWidget> createState() => _PassagesListWidgetState();
}
class _PassagesListWidgetState extends State<PassagesListWidget> {
// Filtres
late String _selectedTypeFilter;
late String _selectedPaymentFilter;
late String _searchQuery;
// Contrôleur de recherche
final TextEditingController _searchController = TextEditingController();
@override
void initState() {
super.initState();
// Initialiser les filtres
_selectedTypeFilter = widget.initialTypeFilter ?? 'Tous';
_selectedPaymentFilter = widget.initialPaymentFilter ?? 'Tous';
_searchQuery = widget.initialSearchQuery ?? '';
_searchController.text = _searchQuery;
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
// Liste filtrée avec gestion des erreurs
List<Map<String, dynamic>> get _filteredPassages {
try {
var filtered = widget.passages.where((passage) {
try {
// Vérification que le passage est valide
if (passage == null) {
return false;
}
// Exclure les types de passages spécifiés
if (widget.excludePassageTypes != null &&
passage.containsKey('type') &&
widget.excludePassageTypes!.contains(passage['type'])) {
return false;
}
// Filtrer par utilisateur
if (widget.filterByUserId != null &&
passage.containsKey('fkUser') &&
passage['fkUser'] != widget.filterByUserId) {
return false;
}
// Filtrer par secteur
if (widget.filterBySectorId != null &&
passage.containsKey('fkSector') &&
passage['fkSector'] != widget.filterBySectorId) {
return false;
}
// Filtre par type
if (_selectedTypeFilter != 'Tous') {
try {
final typeEntries = AppKeys.typesPassages.entries.where(
(entry) => entry.value['titre'] == _selectedTypeFilter);
if (typeEntries.isNotEmpty) {
final typeIndex = typeEntries.first.key;
if (!passage.containsKey('type') ||
passage['type'] != typeIndex) {
return false;
}
}
} catch (e) {
debugPrint('Erreur de filtrage par type: $e');
}
}
// Filtre par type de règlement
if (_selectedPaymentFilter != 'Tous') {
try {
final paymentEntries = AppKeys.typesReglements.entries.where(
(entry) => entry.value['titre'] == _selectedPaymentFilter);
if (paymentEntries.isNotEmpty) {
final paymentIndex = paymentEntries.first.key;
if (!passage.containsKey('payment') ||
passage['payment'] != paymentIndex) {
return false;
}
}
} catch (e) {
debugPrint('Erreur de filtrage par type de règlement: $e');
}
}
// Filtre par recherche
if (_searchQuery.isNotEmpty) {
try {
final query = _searchQuery.toLowerCase();
final address = passage.containsKey('address')
? passage['address']?.toString().toLowerCase() ?? ''
: '';
final name = passage.containsKey('name')
? passage['name']?.toString().toLowerCase() ?? ''
: '';
final notes = passage.containsKey('notes')
? passage['notes']?.toString().toLowerCase() ?? ''
: '';
return address.contains(query) ||
name.contains(query) ||
notes.contains(query);
} catch (e) {
debugPrint('Erreur de filtrage par recherche: $e');
return false;
}
}
return true;
} catch (e) {
debugPrint('Erreur lors du filtrage d\'un passage: $e');
return false;
}
}).toList();
// Trier les passages par date (les plus récents d'abord)
filtered.sort((a, b) {
if (a.containsKey('date') && b.containsKey('date')) {
final DateTime dateA = a['date'] as DateTime;
final DateTime dateB = b['date'] as DateTime;
return dateB.compareTo(dateA); // Ordre décroissant
}
return 0;
});
// Limiter le nombre de passages si maxPassages est défini
if (widget.maxPassages != null && filtered.length > widget.maxPassages!) {
filtered = filtered.sublist(0, widget.maxPassages!);
}
return filtered;
} catch (e) {
debugPrint('Erreur critique dans _filteredPassages: $e');
return [];
}
}
// Vérifier si un passage appartient à l'utilisateur courant
bool _isPassageOwnedByCurrentUser(Map<String, dynamic> passage) {
// Utiliser directement le champ isOwnedByCurrentUser s'il existe
if (passage.containsKey('isOwnedByCurrentUser')) {
return passage['isOwnedByCurrentUser'] == true;
}
// Sinon, vérifier si le passage appartient à l'utilisateur filtré
if (widget.filterByUserId != null && passage.containsKey('fkUser')) {
return passage['fkUser'].toString() == widget.filterByUserId.toString();
}
// Par défaut, considérer que le passage n'appartient pas à l'utilisateur courant
return false;
}
// Widget pour construire la ligne d'informations du passage (date, nom, montant, règlement)
Widget _buildPassageInfoRow(Map<String, dynamic> passage, ThemeData theme,
DateFormat dateFormat, Map<String, dynamic> typeReglement) {
try {
final bool hasName = passage.containsKey('name') &&
(passage['name'] as String?).toString().isNotEmpty;
final double amount =
passage.containsKey('amount') ? passage['amount'] as double : 0.0;
final bool hasValidAmount = amount > 0;
final bool isTypeEffectue = passage.containsKey('type') &&
passage['type'] == 1; // Type 1 = Effectué
final bool isOwnedByCurrentUser = _isPassageOwnedByCurrentUser(passage);
// Déterminer si nous sommes dans une page admin (pas de filterByUserId)
final bool isAdminPage = widget.filterByUserId == null;
// Dans les pages admin, tous les passages sont affichés normalement
// Dans les pages user, seuls les passages de l'utilisateur courant sont affichés normalement
final bool shouldGreyOut = !isAdminPage && !isOwnedByCurrentUser;
// Définir des styles différents en fonction du propriétaire du passage et du type de page
final TextStyle? baseTextStyle = shouldGreyOut
? theme.textTheme.bodyMedium
?.copyWith(color: theme.colorScheme.onSurface.withOpacity(0.5))
: theme.textTheme.bodyMedium;
return Row(
children: [
// Partie gauche: Date et informations
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Date (toujours affichée)
Row(
children: [
Icon(
Icons.calendar_today,
size: 16,
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
const SizedBox(width: 4),
Text(
passage.containsKey('date')
? dateFormat.format(passage['date'] as DateTime)
: 'Date non disponible',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
),
],
),
const SizedBox(height: 4),
// Ligne avec nom, montant et type de règlement
Row(
children: [
// Nom (si connu)
if (hasName)
Flexible(
child: Text(
passage['name'] as String,
style: baseTextStyle,
overflow: TextOverflow.ellipsis,
),
),
// Montant et type de règlement (si montant > 0)
if (hasValidAmount) ...[
const SizedBox(width: 8),
Icon(
Icons.euro,
size: 16,
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
const SizedBox(width: 4),
Text(
'${passage['amount']}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.6),
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 8),
// Type de règlement
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Color(typeReglement['couleur'] as int)
.withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
),
child: Text(
typeReglement['titre'] as String,
style: TextStyle(
color: Color(typeReglement['couleur'] as int),
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
),
],
],
),
],
),
),
// Partie droite: Boutons d'action
if (widget.showActions) ...[
// Bouton Reçu (pour les passages de type 1 - Effectué)
// Dans la page admin, afficher pour tous les passages
// Dans la page user, uniquement pour les passages de l'utilisateur courant
if (isTypeEffectue &&
widget.onReceiptView != null &&
(isAdminPage || isOwnedByCurrentUser))
IconButton(
icon: const Icon(Icons.picture_as_pdf, color: Colors.green),
tooltip: 'Reçu',
onPressed: () => widget.onReceiptView!(passage),
constraints: const BoxConstraints(),
padding: const EdgeInsets.all(8.0),
iconSize: 20,
),
// Bouton Modifier
// Dans la page admin, afficher pour tous les passages
// Dans la page user, uniquement pour les passages de l'utilisateur courant
if (widget.onPassageEdit != null &&
(isAdminPage || isOwnedByCurrentUser))
IconButton(
icon: const Icon(Icons.edit, color: Colors.blue),
tooltip: 'Modifier',
onPressed: () => widget.onPassageEdit!(passage),
constraints: const BoxConstraints(),
padding: const EdgeInsets.all(8.0),
iconSize: 20,
),
],
],
);
} catch (e) {
debugPrint(
'Erreur lors de la construction de la ligne d\'informations du passage: $e');
return const SizedBox();
}
}
// Construction d'une carte pour un passage
Widget _buildPassageCard(
Map<String, dynamic> passage, ThemeData theme, bool isDesktop) {
try {
// Vérification des données et valeurs par défaut
final int type = passage.containsKey('type') ? passage['type'] as int : 0;
final Map<String, dynamic> typePassage =
AppKeys.typesPassages[type] ?? AppKeys.typesPassages[1]!;
final int paymentType =
passage.containsKey('payment') ? passage['payment'] as int : 0;
final Map<String, dynamic> typeReglement =
AppKeys.typesReglements[paymentType] ?? AppKeys.typesReglements[0]!;
final DateFormat dateFormat = DateFormat('dd/MM/yyyy HH:mm');
final bool isOwnedByCurrentUser = _isPassageOwnedByCurrentUser(passage);
// Déterminer si nous sommes dans une page admin (pas de filterByUserId)
final bool isAdminPage = widget.filterByUserId == null;
// Dans les pages admin, tous les passages sont affichés normalement
// Dans les pages user, seuls les passages de l'utilisateur courant sont affichés normalement
final bool shouldGreyOut = !isAdminPage && !isOwnedByCurrentUser;
final bool isClickable = isAdminPage || isOwnedByCurrentUser;
return Card(
margin: const EdgeInsets.only(bottom: 8),
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
// Appliquer une couleur grisée uniquement dans les pages user et si le passage n'appartient pas à l'utilisateur courant
color: shouldGreyOut
? theme.colorScheme.surface.withOpacity(0.7)
: theme.colorScheme.surface,
child: InkWell(
// Rendre le passage cliquable uniquement s'il appartient à l'utilisateur courant
// ou si nous sommes dans la page admin
onTap: isClickable && widget.onPassageSelected != null
? () => widget.onPassageSelected!(passage)
: null,
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Icône du type de passage
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: Color(typePassage['couleur1'] as int)
.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
typePassage['icon_data'] as IconData,
color: Color(typePassage['couleur1'] as int),
),
),
const SizedBox(width: 10),
// Informations principales
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
passage['address'] as String,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Color(typePassage['couleur1'] as int)
.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
typePassage['titre'] as String,
style: TextStyle(
color:
Color(typePassage['couleur1'] as int),
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
],
),
const SizedBox(height: 2),
// Utilisation du widget de ligne d'informations pour tous les types de passages
_buildPassageInfoRow(
passage, theme, dateFormat, typeReglement),
],
),
),
],
),
if (passage['notes'] != null &&
passage['notes'].toString().isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 6.0),
child: Text(
'Notes: ${passage['notes']}',
style: theme.textTheme.bodyMedium?.copyWith(
fontStyle: FontStyle.italic,
color: theme.colorScheme.onSurface.withOpacity(0.7),
),
),
),
// Indicateur d'erreur (si présent)
if (passage['hasError'] == true)
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Row(
children: [
Icon(
Icons.error_outline,
color: Colors.red,
size: 16,
),
const SizedBox(width: 4),
Text(
'Erreur détectée',
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.red,
),
),
],
),
),
],
),
),
),
);
} catch (e) {
debugPrint('Erreur lors de la construction de la carte de passage: $e');
return const SizedBox();
}
}
// Construction d'un filtre déroulant (version standard)
Widget _buildDropdownFilter(
String label,
String selectedValue,
List<String> options,
Function(String) onChanged,
ThemeData theme,
) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
decoration: BoxDecoration(
border: Border.all(color: theme.colorScheme.outline),
borderRadius: BorderRadius.circular(8.0),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: selectedValue,
isExpanded: true,
icon: const Icon(Icons.arrow_drop_down),
items: options.map((String value) {
return DropdownMenuItem<String>(
value: value,
child: Text(
value,
style: theme.textTheme.bodyMedium,
overflow: TextOverflow.ellipsis,
),
);
}).toList(),
onChanged: (String? value) {
if (value != null) {
onChanged(value);
}
},
),
),
),
],
);
}
// Construction d'un filtre déroulant (version compacte)
Widget _buildCompactDropdownFilter(
String label,
String selectedValue,
List<String> options,
Function(String) onChanged,
ThemeData theme,
) {
return Row(
children: [
Text(
'$label:',
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 8),
Expanded(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
decoration: BoxDecoration(
border: Border.all(color: theme.colorScheme.outline),
borderRadius: BorderRadius.circular(8.0),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: selectedValue,
isExpanded: true,
icon: const Icon(Icons.arrow_drop_down),
items: options.map((String value) {
return DropdownMenuItem<String>(
value: value,
child: Text(
value,
style: theme.textTheme.bodyMedium,
overflow: TextOverflow.ellipsis,
),
);
}).toList(),
onChanged: (String? value) {
if (value != null) {
onChanged(value);
}
},
),
),
),
),
],
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final size = MediaQuery.of(context).size;
final isDesktop = size.width > 900;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Titre (si fourni)
if (widget.title != null)
Padding(
padding: const EdgeInsets.all(10.0),
child: Text(
widget.title!,
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
),
),
// Filtres (si activés)
if (widget.showFilters) _buildFilters(theme, isDesktop),
// Liste des passages dans une card de hauteur fixe avec défilement
Container(
height: 600, // Hauteur fixe de 600px
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: theme.shadowColor.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: _filteredPassages.isEmpty
? Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.search_off,
size: 64,
color: theme.colorScheme.onSurface.withOpacity(0.3),
),
const SizedBox(height: 16),
Text(
'Aucun passage trouvé',
style: theme.textTheme.titleLarge?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.5),
),
),
const SizedBox(height: 8),
Text(
'Essayez de modifier vos filtres de recherche',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.5),
),
),
],
),
),
)
: ListView.builder(
padding: const EdgeInsets.all(16.0),
itemCount: _filteredPassages.length,
itemBuilder: (context, index) {
final passage = _filteredPassages[index];
return _buildPassageCard(passage, theme, isDesktop);
},
),
),
],
);
}
// Construction du panneau de filtres
Widget _buildFilters(ThemeData theme, bool isDesktop) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
color: theme.colorScheme.surface,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (isDesktop)
// Version compacte pour le web (desktop)
Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Barre de recherche (si activée)
if (widget.showSearch)
Expanded(
flex: 2,
child: Padding(
padding: const EdgeInsets.only(right: 16.0),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Rechercher par adresse ou nom...',
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8.0),
borderSide: BorderSide(
color: theme.colorScheme.outline,
width: 1.0,
),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16.0, vertical: 14.0),
),
onChanged: (value) {
setState(() {
_searchQuery = value;
});
},
),
),
),
// Filtre par type de passage
Expanded(
child: Padding(
padding: const EdgeInsets.only(right: 16.0),
child: _buildCompactDropdownFilter(
'Type',
_selectedTypeFilter,
[
'Tous',
...AppKeys.typesPassages.values
.map((type) => type['titre'] as String)
],
(value) {
setState(() {
_selectedTypeFilter = value;
});
},
theme,
),
),
),
// Filtre par type de règlement
Expanded(
child: _buildCompactDropdownFilter(
'Règlement',
_selectedPaymentFilter,
[
'Tous',
...AppKeys.typesReglements.values
.map((type) => type['titre'] as String)
],
(value) {
setState(() {
_selectedPaymentFilter = value;
});
},
theme,
),
),
],
),
)
else
// Version mobile (non-desktop)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Barre de recherche (si activée)
if (widget.showSearch)
Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Rechercher par adresse ou nom...',
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
setState(() {
_searchQuery = '';
});
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8.0),
borderSide: BorderSide(
color: theme.colorScheme.outline,
width: 1.0,
),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16.0, vertical: 14.0),
),
onChanged: (value) {
setState(() {
_searchQuery = value;
});
},
),
),
// Filtres
Row(
children: [
// Filtre par type de passage
Expanded(
child: Padding(
padding: const EdgeInsets.only(right: 8.0),
child: _buildDropdownFilter(
'Type',
_selectedTypeFilter,
[
'Tous',
...AppKeys.typesPassages.values
.map((type) => type['titre'] as String)
],
(value) {
setState(() {
_selectedTypeFilter = value;
});
},
theme,
),
),
),
// Filtre par type de règlement
Expanded(
child: _buildDropdownFilter(
'Règlement',
_selectedPaymentFilter,
[
'Tous',
...AppKeys.typesReglements.values
.map((type) => type['titre'] as String)
],
(value) {
setState(() {
_selectedPaymentFilter = value;
});
},
theme,
),
),
],
),
],
),
],
),
);
}
}

View File

@@ -0,0 +1,356 @@
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';
/// Widget de profil commun pour toute l'application
/// Affiche une boîte de dialogue modale avec un formulaire de mise à jour
/// des données utilisateur
class ProfileDialog extends StatefulWidget {
/// ID de l'utilisateur dont on veut afficher/modifier le profil
final String userId;
const ProfileDialog({
Key? key,
required this.userId,
}) : super(key: key);
/// Affiche la boîte de dialogue de profil
static void show(BuildContext context, String userId) {
showDialog(
context: context,
builder: (context) => ProfileDialog(userId: userId),
);
}
@override
State<ProfileDialog> createState() => _ProfileDialogState();
}
class _ProfileDialogState extends State<ProfileDialog> {
/// Contrôleurs pour les champs du formulaire
final TextEditingController _firstNameController = TextEditingController();
final TextEditingController _lastNameController = TextEditingController();
final TextEditingController _emailController = TextEditingController();
final TextEditingController _phoneController = TextEditingController();
/// État de chargement
bool _isLoading = true;
/// État d'erreur
String? _errorMessage;
@override
void initState() {
super.initState();
_loadUserData();
}
@override
void dispose() {
_firstNameController.dispose();
_lastNameController.dispose();
_emailController.dispose();
_phoneController.dispose();
super.dispose();
}
/// Charge les données de l'utilisateur depuis l'API
Future<void> _loadUserData() async {
try {
setState(() {
_isLoading = true;
_errorMessage = null;
});
// Utiliser l'instance globale définie dans app.dart
final user = userRepository.currentUser;
// Si l'utilisateur est trouvé, remplir les champs du formulaire
if (user != null) {
_firstNameController.text = user.firstName ?? '';
_lastNameController.text = user.name ?? '';
_emailController.text = user.email ?? '';
// Note: Utiliser la propriété appropriée pour le téléphone si elle existe
// ou laisser vide si elle n'existe pas
_phoneController.text = ''; // Champ laissé vide par défaut
} else {
_errorMessage = 'Utilisateur non trouvé';
}
} catch (e) {
_errorMessage = 'Erreur lors du chargement des données: $e';
} finally {
setState(() {
_isLoading = false;
});
}
}
/// Enregistre les modifications du profil
Future<void> _saveProfile() async {
try {
setState(() {
_isLoading = true;
_errorMessage = null;
});
// Utiliser l'instance globale définie dans app.dart
// Mettre à jour les données de l'utilisateur
// Note: Cette partie dépend de l'implémentation réelle du UserRepository
// et devrait être adaptée en fonction de l'API disponible
// Récupérer l'utilisateur actuel
final user = userRepository.currentUser;
if (user != null) {
// Mettre à jour les propriétés de l'utilisateur
user.firstName = _firstNameController.text;
user.name = _lastNameController.text;
// Sauvegarder les modifications
// Note: Utiliser la méthode appropriée du repository
// Exemple: userRepo.saveUser(user) ou userRepo.updateUser(user)
// Pour l'instant, nous simulons une mise à jour réussie
// Cette partie devra être adaptée à l'API réelle
await Future.delayed(const Duration(milliseconds: 500));
// Fermer la boîte de dialogue
if (mounted) {
Navigator.of(context)
.pop(true); // Retourne true pour indiquer le succès
// Afficher un message de succès
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Profil mis à jour avec succès'),
backgroundColor: Theme.of(context).colorScheme.primary,
),
);
}
} else {
throw Exception('Utilisateur non trouvé');
}
} catch (e) {
setState(() {
_errorMessage = 'Erreur lors de la mise à jour du profil: $e';
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
final theme = Theme.of(context);
// Déterminer si nous sommes sur un appareil mobile ou un ordinateur de bureau
final isDesktop = size.width > 900;
// Calculer la largeur de la boîte de dialogue
// 90% de la largeur de l'écran pour les mobiles
// 50% de la largeur de l'écran pour les ordinateurs de bureau (max 600px)
final dialogWidth = isDesktop
? size.width * 0.5 > 600
? 600.0
: size.width * 0.5
: size.width * 0.9;
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
// Définir la largeur de la boîte de dialogue
child: Container(
width: dialogWidth,
padding: const EdgeInsets.all(24),
child: _isLoading
? const Center(
child: CircularProgressIndicator(),
)
: _errorMessage != null
? _buildErrorView()
: _buildProfileForm(),
),
);
}
/// Construit la vue d'erreur
Widget _buildErrorView() {
final theme = Theme.of(context);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.error_outline,
color: theme.colorScheme.error,
size: 48,
),
const SizedBox(height: 16),
Text(
'Erreur',
style: theme.textTheme.titleLarge?.copyWith(
color: theme.colorScheme.error,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
_errorMessage ?? 'Une erreur inconnue est survenue',
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(),
style: ElevatedButton.styleFrom(
backgroundColor: theme.colorScheme.primary,
foregroundColor: theme.colorScheme.onPrimary,
),
child: const Text('Fermer'),
),
],
);
}
/// Construit le formulaire de profil
Widget _buildProfileForm() {
final theme = Theme.of(context);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Titre du formulaire
Row(
children: [
Icon(
Icons.person,
color: theme.colorScheme.primary,
size: 28,
),
const SizedBox(width: 12),
Expanded(
child: Text(
'Mon compte',
style: theme.textTheme.titleLarge?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
tooltip: 'Fermer',
),
],
),
const Divider(height: 32),
// Formulaire
Form(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Prénom
TextFormField(
controller: _firstNameController,
decoration: const InputDecoration(
labelText: 'Prénom',
prefixIcon: Icon(Icons.person_outline),
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Veuillez entrer votre prénom';
}
return null;
},
),
const SizedBox(height: 16),
// Nom
TextFormField(
controller: _lastNameController,
decoration: const InputDecoration(
labelText: 'Nom',
prefixIcon: Icon(Icons.person_outline),
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Veuillez entrer votre nom';
}
return null;
},
),
const SizedBox(height: 16),
// Email
TextFormField(
controller: _emailController,
decoration: const InputDecoration(
labelText: 'Email',
prefixIcon: Icon(Icons.email_outlined),
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Veuillez entrer votre email';
}
if (!value.contains('@')) {
return 'Veuillez entrer un email valide';
}
return null;
},
),
const SizedBox(height: 16),
// Téléphone
TextFormField(
controller: _phoneController,
decoration: const InputDecoration(
labelText: 'Téléphone',
prefixIcon: Icon(Icons.phone_outlined),
border: OutlineInputBorder(),
),
),
],
),
),
const SizedBox(height: 24),
// Boutons d'action
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(
'Annuler',
style: TextStyle(
color: theme.colorScheme.error,
),
),
),
const SizedBox(width: 16),
ElevatedButton(
onPressed: _saveProfile,
style: ElevatedButton.styleFrom(
backgroundColor: theme.colorScheme.primary,
foregroundColor: theme.colorScheme.onPrimary,
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
),
child: const Text('Enregistrer'),
),
],
),
],
);
}
}

View File

@@ -0,0 +1,687 @@
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/constants/app_keys.dart';
import 'package:geosector_app/core/repositories/user_repository.dart';
import 'package:geosector_app/presentation/widgets/help_dialog.dart';
import 'package:geosector_app/presentation/widgets/profile_dialog.dart';
/// Widget qui fournit une navigation responsive pour l'application.
/// Affiche une sidebar en mode desktop et une bottomBar en mode mobile.
class ResponsiveNavigation extends StatefulWidget {
/// Le contenu principal à afficher
final Widget body;
/// Le titre de la page
final String title;
/// L'index de la page sélectionnée
final int selectedIndex;
/// Callback appelé lorsqu'un élément de navigation est sélectionné
final Function(int) onDestinationSelected;
/// Liste des destinations de navigation
final List<NavigationDestination> destinations;
/// Actions supplémentaires à afficher dans l'AppBar
final List<Widget>? additionalActions;
/// Indique si le bouton "Nouveau passage" doit être affiché
final bool showNewPassageButton;
/// Callback appelé lorsque le bouton "Nouveau passage" est pressé
final VoidCallback? onNewPassagePressed;
/// Clé de la boîte Hive pour sauvegarder les paramètres
final String settingsBoxKey;
/// Clé pour sauvegarder l'état de la sidebar
final String sidebarStateKey;
/// Widgets à afficher en bas de la sidebar
final List<Widget>? sidebarBottomItems;
/// Indique si l'utilisateur est un administrateur
final bool isAdmin;
/// Indique si l'AppBar doit être affiché
final bool showAppBar;
const ResponsiveNavigation({
Key? key,
required this.body,
required this.title,
required this.selectedIndex,
required this.onDestinationSelected,
required this.destinations,
this.additionalActions,
this.showNewPassageButton = true,
this.onNewPassagePressed,
this.settingsBoxKey = AppKeys.settingsBoxName,
this.sidebarStateKey = 'isSidebarMinimized',
this.sidebarBottomItems,
this.isAdmin = false,
this.showAppBar = true,
}) : super(key: key);
@override
State<ResponsiveNavigation> createState() => _ResponsiveNavigationState();
}
class _ResponsiveNavigationState extends State<ResponsiveNavigation> {
/// État de la barre latérale (minimisée ou non)
bool _isSidebarMinimized = false;
/// Référence à la boîte Hive pour les paramètres
late Box _settingsBox;
@override
void initState() {
super.initState();
_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(widget.settingsBoxKey)) {
_settingsBox = await Hive.openBox(widget.settingsBoxKey);
} else {
_settingsBox = Hive.box(widget.settingsBoxKey);
}
// Charger l'état de la barre latérale
final sidebarState = _settingsBox.get(widget.sidebarStateKey);
if (sidebarState != null && sidebarState is bool) {
setState(() {
_isSidebarMinimized = sidebarState;
});
}
} catch (e) {
debugPrint('Erreur lors du chargement des paramètres: $e');
}
}
/// Sauvegarder l'état de la barre latérale
void _saveSettings() {
try {
// Sauvegarder l'état de la barre latérale
_settingsBox.put(widget.sidebarStateKey, _isSidebarMinimized);
} catch (e) {
debugPrint('Erreur lors de la sauvegarde des paramètres: $e');
}
}
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
final isDesktop = size.width > 900;
return Scaffold(
appBar: widget.showAppBar
? AppBar(
title: Text(widget.title),
actions: _buildAppBarActions(context),
)
: null,
body: isDesktop ? _buildDesktopLayout() : _buildMobileLayout(),
bottomNavigationBar: (isDesktop) ? null : _buildBottomNavigationBar(),
);
}
/// Construction du layout pour les écrans de bureau (web)
Widget _buildDesktopLayout() {
// Utiliser une couleur de fond différente selon le type d'utilisateur
final backgroundColor = widget.isAdmin
? const Color(0xFFFFEBEE) // Fond rouge clair pour l'interface admin
: const Color(
0xFFE8F5E9); // Fond vert clair pour l'interface utilisateur
return Row(
children: [
_buildSidebar(),
Expanded(
child: Container(
color: backgroundColor,
child: widget.body,
),
),
],
);
}
/// Construction du layout pour les écrans mobiles
Widget _buildMobileLayout() {
// Utiliser une couleur de fond différente selon le type d'utilisateur
final backgroundColor = widget.isAdmin
? const Color(0xFFFFEBEE) // Fond rouge clair pour l'interface admin
: const Color(
0xFFE8F5E9); // Fond vert clair pour l'interface utilisateur
return Container(
color: backgroundColor,
child: widget.body,
);
}
/// Construction des actions de l'AppBar
List<Widget> _buildAppBarActions(BuildContext context) {
List<Widget> actions = [];
// Ajouter les actions supplémentaires si elles existent
if (widget.additionalActions != null &&
widget.additionalActions!.isNotEmpty) {
actions.addAll(widget.additionalActions!);
} else if (widget.showNewPassageButton && widget.selectedIndex == 0) {
// Ajouter le bouton "Nouveau passage" en haut à droite pour la page d'accueil
actions.add(
TextButton.icon(
icon: const Icon(Icons.add_location_alt, color: Colors.white),
label: const Text('Nouveau passage',
style: TextStyle(color: Colors.white)),
onPressed: widget.onNewPassagePressed ??
() {
// Fonction par défaut si onNewPassagePressed n'est pas fourni
_showPassageForm(context);
},
style: TextButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.secondary,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
),
);
actions.add(const SizedBox(width: 16)); // Espacement à droite
}
return actions;
}
/// Construction de la barre de navigation inférieure pour mobile
Widget _buildBottomNavigationBar() {
final theme = Theme.of(context);
return NavigationBar(
selectedIndex: widget.selectedIndex,
onDestinationSelected: widget.onDestinationSelected,
backgroundColor: theme.colorScheme.surface,
elevation: 8,
destinations: widget.destinations,
);
}
/// Obtenir le nom complet de l'utilisateur (prénom + nom)
String _getFullUserName(BuildContext context) {
// Utiliser l'instance globale définie dans app.dart
final user = userRepository.currentUser;
if (user == null) return 'Utilisateur';
String fullName = '';
// Ajouter le prénom si disponible
if (user.firstName != null && user.firstName!.isNotEmpty) {
fullName += user.firstName!;
}
// Ajouter le nom
if (user.name != null && user.name!.isNotEmpty) {
// Ajouter un espace si le prénom est déjà présent
if (fullName.isNotEmpty) {
fullName += ' ';
}
fullName += user.name!;
}
// Si aucun nom n'a été trouvé, utiliser 'Utilisateur' par défaut
return fullName.isEmpty ? 'Utilisateur' : fullName;
}
/// Obtenir les initiales du prénom et du nom de l'utilisateur
String _getUserInitials(BuildContext context) {
// Utiliser l'instance globale définie dans app.dart
final user = userRepository.currentUser;
if (user == null) return 'U';
String initials = '';
// Ajouter l'initiale du prénom si disponible
if (user.firstName != null && user.firstName!.isNotEmpty) {
initials += user.firstName!.substring(0, 1).toUpperCase();
}
// Ajouter l'initiale du nom
if (user.name != null && user.name!.isNotEmpty) {
initials += user.name!.substring(0, 1).toUpperCase();
}
// Si aucune initiale n'a été trouvée, utiliser 'U' par défaut
return initials.isEmpty ? 'U' : initials;
}
/// Afficher le sectName entre parenthèses s'il existe
Widget _buildSectNameText(BuildContext context) {
final theme = Theme.of(context);
// Utiliser l'instance globale définie dans app.dart
final user = userRepository.currentUser;
// Si l'utilisateur n'a pas de sectName ou s'il est vide, retourner un widget vide
if (user == null || user.sectName == null || user.sectName!.isEmpty) {
return const SizedBox.shrink();
}
// Sinon, afficher le sectName entre parenthèses
return Text(
'(${user.sectName})',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
);
}
/// Construction de la barre latérale pour la version web
Widget _buildSidebar() {
final theme = Theme.of(context);
return Card(
margin: EdgeInsets.zero,
elevation: 4,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.zero,
),
child: Container(
width: _isSidebarMinimized ? 70 : 250,
color: theme.colorScheme.surface,
child: Column(
children: [
// Bouton pour minimiser/maximiser la barre latérale
Align(
alignment: _isSidebarMinimized
? Alignment.center
: Alignment.centerRight,
child: Padding(
padding:
EdgeInsets.only(top: 8, right: _isSidebarMinimized ? 0 : 8),
child: IconButton(
icon: Icon(_isSidebarMinimized
? Icons.chevron_right
: Icons.chevron_left),
onPressed: () {
setState(() {
_isSidebarMinimized = !_isSidebarMinimized;
_saveSettings(); // Sauvegarder l'état de la barre latérale
});
},
tooltip: _isSidebarMinimized ? 'Développer' : 'Réduire',
),
),
),
const SizedBox(height: 8),
if (!_isSidebarMinimized)
CircleAvatar(
radius: 40,
backgroundColor: theme.colorScheme.primary,
child: Text(
_getUserInitials(context),
style: TextStyle(
fontSize: 28,
color: theme.colorScheme.onPrimary,
),
),
),
const SizedBox(height: 8),
if (!_isSidebarMinimized) ...[
Text(
_getFullUserName(context),
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
// Afficher le sectName entre parenthèses s'il existe
_buildSectNameText(context),
Text(
userRepository.currentUser?.email ?? '',
style: theme.textTheme.bodySmall,
),
const SizedBox(height: 24),
] else
const SizedBox(height: 8),
const Divider(),
// Éléments de navigation
for (int i = 0; i < widget.destinations.length; i++)
_buildNavItem(
i, widget.destinations[i].label, widget.destinations[i].icon),
const Spacer(),
const Divider(),
// Éléments du bas de la sidebar
if (widget.sidebarBottomItems != null && !_isSidebarMinimized)
...widget.sidebarBottomItems!,
// Éléments par défaut du bas de la sidebar
if (!_isSidebarMinimized)
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
'Paramètres',
style: theme.textTheme.titleSmall?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
),
_SettingsItem(
icon: Icons.person,
title: 'Mon compte',
subtitle: null,
isSidebarMinimized: _isSidebarMinimized,
onTap: () {
// Afficher la boîte de dialogue de profil avec l'ID de l'utilisateur actuel
// Utiliser l'instance globale définie dans app.dart
final user = userRepository.currentUser;
if (user != null && user.id != null) {
// Convertir l'ID en chaîne de caractères si nécessaire
ProfileDialog.show(context, user.id!.toString());
} else {
// Afficher un message d'erreur si l'utilisateur n'est pas trouvé
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Erreur: Utilisateur non trouvé'),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
}
},
),
if (widget.isAdmin && userRepository.currentUser?.role == 2)
_SettingsItem(
icon: Icons.people,
title: 'Amicale & membres',
isSidebarMinimized: _isSidebarMinimized,
onTap: () {
// Navigation vers le tableau de bord admin avec sélection de l'onglet "Amicale et membres"
context.go('/admin');
// Sélectionner l'onglet "Amicale et membres" (index 5)
// Nous devons sauvegarder cet index dans les paramètres pour que le tableau de bord
// puisse le récupérer et sélectionner le bon onglet
final settingsBox = Hive.box(AppKeys.settingsBoxName);
settingsBox.put('adminSelectedPageIndex', 5);
},
),
const SizedBox(height: 16),
_SettingsItem(
icon: Icons.help_outline,
title: 'Aide',
isSidebarMinimized: _isSidebarMinimized,
onTap: () {
// Afficher la boîte de dialogue d'aide avec le titre de la page courante
HelpDialog.show(context, widget.title);
},
),
],
),
),
);
}
/// Construction d'un élément de navigation pour la barre latérale
Widget _buildNavItem(int index, String title, Widget icon) {
final theme = Theme.of(context);
final isSelected = widget.selectedIndex == index;
final IconData? iconData = (icon is Icon) ? (icon as Icon).icon : null;
// Remplacer certains titres si l'interface est de type "user"
String displayTitle = title;
if (!widget.isAdmin) {
if (title == "Accueil") {
displayTitle = "Tableau de bord";
} else if (title == "Stats") {
displayTitle = "Statistiques";
}
}
if (_isSidebarMinimized) {
// Version minimisée - afficher uniquement l'icône
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Tooltip(
message: displayTitle,
child: InkWell(
onTap: () {
widget.onDestinationSelected(index);
},
child: Container(
width: 50,
height: 50,
decoration: BoxDecoration(
color: isSelected
? theme.colorScheme.primary.withOpacity(0.1)
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
child: iconData != null
? Icon(
iconData,
color: isSelected
? theme.colorScheme.primary
: theme.colorScheme.onSurface.withOpacity(0.6),
size: 24,
)
: icon,
),
),
),
);
} else {
// Version normale avec texte et icône
return ListTile(
leading: iconData != null
? Icon(
iconData,
color: isSelected
? theme.colorScheme.primary
: theme.colorScheme.onSurface.withOpacity(0.6),
)
: icon,
title: Text(
displayTitle,
style: TextStyle(
color: isSelected
? theme.colorScheme.primary
: theme.colorScheme.onSurface,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
),
),
tileColor:
isSelected ? theme.colorScheme.primary.withOpacity(0.1) : null,
onTap: () {
widget.onDestinationSelected(index);
},
);
}
}
/// 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'),
),
],
),
);
}
}
/// Widget pour les éléments de paramètres
class _SettingsItem extends StatelessWidget {
final IconData icon;
final String title;
final String? subtitle;
final Widget? trailing;
final VoidCallback onTap;
final bool isSidebarMinimized;
const _SettingsItem({
required this.icon,
required this.title,
this.subtitle,
this.trailing,
required this.onTap,
required this.isSidebarMinimized,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
if (isSidebarMinimized) {
// Version minimisée - afficher uniquement l'icône
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Tooltip(
message: title,
child: InkWell(
onTap: onTap,
child: Container(
width: 50,
height: 50,
decoration: BoxDecoration(
color: Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
icon,
color: theme.colorScheme.primary,
size: 24,
),
),
),
),
);
} else {
// Version normale avec texte et icône
return ListTile(
leading: Icon(
icon,
color: theme.colorScheme.primary,
),
title: Text(title),
subtitle: subtitle != null ? Text(subtitle!) : null,
trailing: trailing,
onTap: onTap,
);
}
}
}

View File

@@ -0,0 +1,206 @@
import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/data/models/passage_model.dart';
import 'package:geosector_app/core/data/models/sector_model.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/shared/app_theme.dart';
class SectorDistributionCard extends StatefulWidget {
final String title;
final double? height;
final EdgeInsetsGeometry? padding;
const SectorDistributionCard({
Key? key,
this.title = 'Répartition par secteur',
this.height,
this.padding,
}) : super(key: key);
@override
State<SectorDistributionCard> createState() => _SectorDistributionCardState();
}
class _SectorDistributionCardState extends State<SectorDistributionCard> {
List<Map<String, dynamic>> sectorStats = [];
bool isLoading = true;
@override
void initState() {
super.initState();
_loadSectorData();
}
Future<void> _loadSectorData() async {
setState(() {
isLoading = true;
});
try {
// S'assurer que les boîtes Hive sont ouvertes
if (!Hive.isBoxOpen(AppKeys.sectorsBoxName)) {
await Hive.openBox<SectorModel>(AppKeys.sectorsBoxName);
}
if (!Hive.isBoxOpen(AppKeys.passagesBoxName)) {
await Hive.openBox<PassageModel>(AppKeys.passagesBoxName);
}
// Récupérer tous les secteurs
final sectorsBox = Hive.box<SectorModel>(AppKeys.sectorsBoxName);
final List<SectorModel> sectors = sectorsBox.values.toList();
// Récupérer tous les passages
final passagesBox = Hive.box<PassageModel>(AppKeys.passagesBoxName);
final List<PassageModel> passages = passagesBox.values.toList();
// Compter les passages par secteur (en excluant ceux où fkType==2)
final Map<int, int> sectorCounts = {};
for (final passage in passages) {
// Exclure les passages où fkType==2
if (passage.fkSector != null && passage.fkType != 2) {
sectorCounts[passage.fkSector!] =
(sectorCounts[passage.fkSector!] ?? 0) + 1;
}
}
// Préparer les données pour l'affichage
List<Map<String, dynamic>> stats = [];
for (final sector in sectors) {
final count = sectorCounts[sector.id] ?? 0;
if (count > 0) {
stats.add({
'name': sector.libelle,
'count': count,
'color': sector.color.isEmpty
? 0xFF4B77BE
: int.tryParse(sector.color.replaceAll('#', '0xFF')) ??
0xFF4B77BE,
});
}
}
setState(() {
sectorStats = stats;
isLoading = false;
});
} catch (e) {
debugPrint('Erreur lors du chargement des données de secteur: $e');
setState(() {
isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
return Container(
height: widget.height,
padding: widget.padding ?? const EdgeInsets.all(AppTheme.spacingM),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
boxShadow: AppTheme.cardShadow,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
widget.title,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
if (isLoading)
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
else
IconButton(
icon: const Icon(Icons.refresh, size: 20),
onPressed: _loadSectorData,
tooltip: 'Rafraîchir',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
const SizedBox(height: AppTheme.spacingM),
Expanded(
child: isLoading
? const Center(child: CircularProgressIndicator())
: sectorStats.isEmpty
? const Center(
child: Text('Aucune donnée de secteur disponible'))
: ListView.builder(
itemCount: sectorStats.length,
itemBuilder: (context, index) {
final sector = sectorStats[index];
return _buildSectorItem(
context,
sector['name'],
sector['count'],
Color(sector['color']),
);
},
),
),
],
),
);
}
Widget _buildSectorItem(
BuildContext context,
String name,
int count,
Color color,
) {
final totalCount =
sectorStats.fold(0, (sum, item) => sum + (item['count'] as int));
final percentage = totalCount > 0 ? (count / totalCount) * 100 : 0;
return Padding(
padding: const EdgeInsets.only(bottom: AppTheme.spacingS),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
name,
style: const TextStyle(fontSize: 14),
overflow: TextOverflow.ellipsis,
),
),
Text(
'$count (${percentage.toInt()}%)',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
],
),
const SizedBox(height: 4),
LinearProgressIndicator(
value: percentage / 100,
backgroundColor: Colors.grey[200],
valueColor: AlwaysStoppedAnimation<Color>(color),
minHeight: 8,
borderRadius: BorderRadius.circular(4),
),
],
),
);
}
}