Initialisation du projet geosector complet (web + flutter)
This commit is contained in:
476
flutt/lib/presentation/widgets/charts/activity_chart.dart
Normal file
476
flutt/lib/presentation/widgets/charts/activity_chart.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
11
flutt/lib/presentation/widgets/charts/charts.dart
Normal file
11
flutt/lib/presentation/widgets/charts/charts.dart
Normal 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';
|
||||
313
flutt/lib/presentation/widgets/charts/combined_chart.dart
Normal file
313
flutt/lib/presentation/widgets/charts/combined_chart.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
99
flutt/lib/presentation/widgets/charts/passage_data.dart
Normal file
99
flutt/lib/presentation/widgets/charts/passage_data.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
459
flutt/lib/presentation/widgets/charts/passage_pie_chart.dart
Normal file
459
flutt/lib/presentation/widgets/charts/passage_pie_chart.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
214
flutt/lib/presentation/widgets/charts/passage_utils.dart
Normal file
214
flutt/lib/presentation/widgets/charts/passage_utils.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
27
flutt/lib/presentation/widgets/charts/payment_data.dart
Normal file
27
flutt/lib/presentation/widgets/charts/payment_data.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
404
flutt/lib/presentation/widgets/charts/payment_pie_chart.dart
Normal file
404
flutt/lib/presentation/widgets/charts/payment_pie_chart.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
33
flutt/lib/presentation/widgets/charts/payment_utils.dart
Normal file
33
flutt/lib/presentation/widgets/charts/payment_utils.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
219
flutt/lib/presentation/widgets/chat/chat_input.dart
Normal file
219
flutt/lib/presentation/widgets/chat/chat_input.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
245
flutt/lib/presentation/widgets/chat/chat_messages.dart
Normal file
245
flutt/lib/presentation/widgets/chat/chat_messages.dart
Normal 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')}';
|
||||
}
|
||||
}
|
||||
219
flutt/lib/presentation/widgets/chat/chat_sidebar.dart
Normal file
219
flutt/lib/presentation/widgets/chat/chat_sidebar.dart
Normal 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}';
|
||||
}
|
||||
}
|
||||
}
|
||||
156
flutt/lib/presentation/widgets/connectivity_indicator.dart
Normal file
156
flutt/lib/presentation/widgets/connectivity_indicator.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
71
flutt/lib/presentation/widgets/custom_button.dart
Normal file
71
flutt/lib/presentation/widgets/custom_button.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
134
flutt/lib/presentation/widgets/custom_text_field.dart
Normal file
134
flutt/lib/presentation/widgets/custom_text_field.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
169
flutt/lib/presentation/widgets/dashboard_app_bar.dart
Normal file
169
flutt/lib/presentation/widgets/dashboard_app_bar.dart
Normal 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);
|
||||
}
|
||||
142
flutt/lib/presentation/widgets/dashboard_layout.dart
Normal file
142
flutt/lib/presentation/widgets/dashboard_layout.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
110
flutt/lib/presentation/widgets/help_dialog.dart
Normal file
110
flutt/lib/presentation/widgets/help_dialog.dart
Normal 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'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
88
flutt/lib/presentation/widgets/loading_overlay.dart
Normal file
88
flutt/lib/presentation/widgets/loading_overlay.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
200
flutt/lib/presentation/widgets/mapbox_map.dart
Normal file
200
flutt/lib/presentation/widgets/mapbox_map.dart
Normal 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,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
356
flutt/lib/presentation/widgets/profile_dialog.dart
Normal file
356
flutt/lib/presentation/widgets/profile_dialog.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
687
flutt/lib/presentation/widgets/responsive_navigation.dart
Normal file
687
flutt/lib/presentation/widgets/responsive_navigation.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
206
flutt/lib/presentation/widgets/sector_distribution_card.dart
Normal file
206
flutt/lib/presentation/widgets/sector_distribution_card.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user