Amélioration de la splash_page et du login

This commit is contained in:
d6soft
2025-06-04 16:51:40 +02:00
parent 8c9e9a21c4
commit 41f1db1169
228 changed files with 366459 additions and 6183 deletions

View File

@@ -1,22 +1,20 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:geosector_app/core/data/models/amicale_model.dart';
import 'package:geosector_app/core/repositories/user_repository.dart';
import 'package:geosector_app/core/repositories/region_repository.dart';
import 'package:geosector_app/core/services/api_service.dart';
import 'package:geosector_app/presentation/widgets/mapbox_map.dart';
import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart';
import 'custom_text_field.dart';
class EntiteForm extends StatefulWidget {
class AmicaleForm extends StatefulWidget {
final AmicaleModel? amicale;
final Function(AmicaleModel)? onSubmit;
final bool readOnly;
const EntiteForm({
const AmicaleForm({
Key? key,
this.amicale,
this.onSubmit,
@@ -24,10 +22,10 @@ class EntiteForm extends StatefulWidget {
}) : super(key: key);
@override
State<EntiteForm> createState() => _EntiteFormState();
State<AmicaleForm> createState() => _AmicaleFormState();
}
class _EntiteFormState extends State<EntiteForm> {
class _AmicaleFormState extends State<AmicaleForm> {
final _formKey = GlobalKey<FormState>();
// Controllers
@@ -52,9 +50,6 @@ class _EntiteFormState extends State<EntiteForm> {
bool _chkActive = true;
bool _chkStripe = false;
// Liste des régions (sera chargée depuis le store)
List<Map<String, dynamic>> _regions = [];
@override
void initState() {
super.initState();
@@ -81,36 +76,6 @@ class _EntiteFormState extends State<EntiteForm> {
_chkAcceptSms = amicale?.chkAcceptSms ?? false;
_chkActive = amicale?.chkActive ?? true;
_chkStripe = amicale?.chkStripe ?? false;
// Charger les régions depuis le repository
WidgetsBinding.instance.addPostFrameCallback((_) {
_loadRegions();
});
}
void _loadRegions() {
try {
final regionRepository =
Provider.of<RegionRepository>(context, listen: false);
if (!regionRepository.isLoaded) {
// Initialiser le repository si ce n'est pas déjà fait
regionRepository.init().then((_) {
setState(() {
_regions = regionRepository.getRegionsForDropdown();
});
});
} else {
setState(() {
_regions = regionRepository.getRegionsForDropdown();
});
}
} catch (e) {
debugPrint('Erreur lors du chargement des régions: $e');
// Utiliser une liste vide en cas d'erreur
setState(() {
_regions = [];
});
}
}
@override
@@ -130,7 +95,7 @@ class _EntiteFormState extends State<EntiteForm> {
}
// Appeler l'API pour mettre à jour l'entité
Future<void> _updateEntite(AmicaleModel amicale) async {
Future<void> _updateAmicale(AmicaleModel amicale) async {
try {
// Afficher un indicateur de chargement
showDialog(
@@ -175,7 +140,7 @@ class _EntiteFormState extends State<EntiteForm> {
try {
// Obtenir l'instance du service API
final apiService = Provider.of<ApiService>(context, listen: false);
// Appeler la méthode post du service API
await apiService.post('/entite/update', data: data);
@@ -185,7 +150,7 @@ class _EntiteFormState extends State<EntiteForm> {
// Afficher un message de succès
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Entité mise à jour avec succès'),
content: Text('Amicale mise à jour avec succès'),
backgroundColor: Colors.green,
),
);
@@ -204,7 +169,8 @@ class _EntiteFormState extends State<EntiteForm> {
// Afficher un message d'erreur
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur lors de la mise à jour de l\'entité: $error'),
content:
Text('Erreur lors de la mise à jour de l\'amicale: $error'),
backgroundColor: Colors.red,
),
);
@@ -277,8 +243,8 @@ class _EntiteFormState extends State<EntiteForm> {
chkActive: _chkActive,
);
// Appeler l'API pour mettre à jour l'entité
_updateEntite(amicale);
// Appeler l'API pour mettre à jour l'amicale
_updateAmicale(amicale);
// Appeler la fonction onSubmit si elle existe (pour la compatibilité avec le code existant)
if (widget.onSubmit != null) {
@@ -391,8 +357,8 @@ class _EntiteFormState extends State<EntiteForm> {
width: 20,
height: 20,
child: const Icon(
Icons.location_on,
color: Color(0xFF20335E),
Icons.fireplace_rounded,
color: Color.fromARGB(255, 212, 34, 31),
size: 20,
),
),
@@ -424,6 +390,72 @@ class _EntiteFormState extends State<EntiteForm> {
);
}
// Construire le dropdown pour la région
Widget _buildRegionDropdown(bool restrictedFieldsReadOnly) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Afficher le libellé de la région en lecture seule
if (_libRegion != null && _libRegion!.isNotEmpty)
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
decoration: BoxDecoration(
color: Theme.of(context).inputDecorationTheme.fillColor,
borderRadius: BorderRadius.circular(4),
),
child: Text(
_libRegion!,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurface,
),
),
)
else
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
decoration: BoxDecoration(
color: Theme.of(context).inputDecorationTheme.fillColor,
borderRadius: BorderRadius.circular(4),
),
child: Text(
'Aucune région définie',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).hintColor,
),
),
),
],
);
}
// Construire une option checkbox
Widget _buildCheckboxOption({
required String label,
required bool value,
required void Function(bool?)? onChanged,
}) {
return Row(
children: [
Checkbox(
value: value,
onChanged: onChanged,
activeColor: Theme.of(context).colorScheme.primary,
),
Expanded(
child: Text(
label,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onBackground,
fontWeight: FontWeight.w500,
),
),
),
],
);
}
// Construire le formulaire principal
Widget _buildMainForm(ThemeData theme, bool restrictedFieldsReadOnly) {
return Column(
@@ -887,4 +919,46 @@ class _EntiteFormState extends State<EntiteForm> {
widget.readOnly || !canEditRestrictedFields;
// Calculer la largeur maximale du formulaire pour les écrans larges
final screenWidth = MediaQuery.of(context
final screenWidth = MediaQuery.of(context).size.width;
final maxFormWidth = screenWidth > 800 ? 800.0 : screenWidth;
return Scaffold(
appBar: AppBar(
title: Text(
widget.readOnly ? 'Détails de l\'amicale' : 'Modifier l\'amicale'),
backgroundColor: theme.appBarTheme.backgroundColor,
foregroundColor: theme.appBarTheme.foregroundColor,
),
body: Center(
child: Container(
width: maxFormWidth,
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header avec logo et minimap
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
// Section Logo
_buildLogoSection(),
// Section MiniMap
_buildMiniMap(),
],
),
const SizedBox(height: 24),
// Formulaire principal
_buildMainForm(theme, restrictedFieldsReadOnly),
],
),
),
),
),
),
);
}
}

View File

@@ -4,7 +4,7 @@ import 'package:geosector_app/core/data/models/amicale_model.dart';
import 'package:geosector_app/core/repositories/region_repository.dart';
import 'package:geosector_app/core/repositories/user_repository.dart';
import 'package:geosector_app/presentation/widgets/amicale_row_widget.dart';
import 'package:geosector_app/presentation/widgets/entite_form.dart';
import 'package:geosector_app/presentation/widgets/amicale_form.dart';
import 'package:provider/provider.dart';
/// Widget de tableau pour afficher une liste d'amicales
@@ -165,7 +165,7 @@ class AmicaleTableWidget extends StatelessWidget {
),
const SizedBox(height: 16),
// Formulaire EntiteForm en mode lecture seule
EntiteForm(
AmicaleForm(
amicale: amicale,
readOnly: readOnly,
onSubmit: (updatedAmicale) {

View File

@@ -1,18 +1,17 @@
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';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/data/models/passage_model.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}, ...]
/// Si useValueListenable est true, ce paramètre est ignoré
final List<Map<String, dynamic>>? passageData;
/// Type de période (Jour, Semaine, Mois, Année)
@@ -30,9 +29,6 @@ class ActivityChart extends StatefulWidget {
/// 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;
@@ -51,8 +47,8 @@ class ActivityChart extends StatefulWidget {
/// Si vrai, n'applique aucun filtrage par utilisateur (affiche tous les passages)
final bool showAllPassages;
/// Si vrai, force le rechargement des données
final bool forceRefresh;
/// Utiliser ValueListenableBuilder pour la mise à jour automatique
final bool useValueListenable;
const ActivityChart({
super.key,
@@ -62,16 +58,14 @@ class ActivityChart extends StatefulWidget {
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,
this.forceRefresh = false,
}) : assert(loadFromHive || passageData != null,
'Soit loadFromHive doit être true, soit passageData doit être fourni');
this.useValueListenable = true,
});
@override
State<ActivityChart> createState() => _ActivityChartState();
@@ -96,16 +90,6 @@ 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;
@@ -117,9 +101,6 @@ class _ActivityChartState extends State<ActivityChart>
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,
@@ -128,83 +109,29 @@ class _ActivityChartState extends State<ActivityChart>
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);
}
@override
void didUpdateWidget(ActivityChart oldWidget) {
super.didUpdateWidget(oldWidget);
void _loadData() {
// Marquer comme chargé immédiatement pour éviter les appels multiples pendant le chargement
// Mais permettre un rechargement ultérieur si nécessaire
if (_dataLoaded && _hasData) return;
// 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.useValueListenable
? false
: oldWidget.passageData != widget.passageData;
final bool filteringChanged = oldWidget.userId != widget.userId ||
!listEquals(
oldWidget.excludePassageTypes, widget.excludePassageTypes) ||
oldWidget.showAllPassages != widget.showAllPassages ||
oldWidget.useValueListenable != widget.useValueListenable;
_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;
});
// Si des paramètres importants ont changé, relancer l'animation
if (periodChanged || dataSourceChanged || filteringChanged) {
_animationController.reset();
_animationController.forward();
}
}
@@ -215,47 +142,141 @@ class _ActivityChartState extends State<ActivityChart>
}
@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;
final bool refreshForced = widget.forceRefresh && !oldWidget.forceRefresh;
// Si des paramètres importants ont changé ou si forceRefresh est passé à true, recharger les données
if (periodChanged ||
dataSourceChanged ||
filteringChanged ||
refreshForced) {
_selectedDays = widget.daysToShow;
_dataLoaded = false; // Réinitialiser l'état pour forcer le rechargement
_loadData();
Widget build(BuildContext context) {
if (widget.useValueListenable) {
return _buildWithValueListenable();
} else {
return _buildWithStaticData();
}
}
// 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
/// Construction du widget avec ValueListenableBuilder pour mise à jour automatique
Widget _buildWithValueListenable() {
return ValueListenableBuilder(
valueListenable:
Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
builder: (context, Box<PassageModel> passagesBox, child) {
final chartData = _calculateActivityData(passagesBox);
return _buildChart(chartData);
},
);
}
/// Prépare les données pour le graphique
void _prepareChartData() {
/// Construction du widget avec des données statiques
Widget _buildWithStaticData() {
if (widget.passageData == null) {
return SizedBox(
height: widget.height,
child: const Center(
child: Text('Aucune donnée fournie'),
),
);
}
final chartData = _prepareChartDataFromPassageData(widget.passageData!);
return _buildChart(chartData);
}
/// Calcule les données d'activité depuis la Hive box
List<ActivityData> _calculateActivityData(Box<PassageModel> passagesBox) {
try {
// Vérifier que les données sont disponibles
if (_passageData.isEmpty) {
_chartData = [];
return;
final passages = passagesBox.values.toList();
final currentUser = userRepository.getCurrentUser();
// Déterminer l'utilisateur cible selon les filtres
final int? targetUserId =
widget.showAllPassages ? null : (widget.userId ?? currentUser?.id);
// Calculer la date de début (nombre de jours en arrière)
final endDate = DateTime.now();
final startDate = endDate.subtract(Duration(days: widget.daysToShow - 1));
// Préparer les données par date
final Map<String, Map<int, int>> dataByDate = {};
// Initialiser toutes les dates de la période
for (int i = 0; i < widget.daysToShow; i++) {
final date = startDate.add(Duration(days: i));
final dateStr = DateFormat('yyyy-MM-dd').format(date);
dataByDate[dateStr] = {};
// Initialiser tous les types de passage possibles
for (final typeId in AppKeys.typesPassages.keys) {
if (!widget.excludePassageTypes.contains(typeId)) {
dataByDate[dateStr]![typeId] = 0;
}
}
}
// Parcourir les passages et les compter par date et type
for (final passage in passages) {
// Appliquer les filtres
bool shouldInclude = true;
// Filtrer par utilisateur si nécessaire
if (targetUserId != null && passage.fkUser != targetUserId) {
shouldInclude = false;
}
// Exclure certains types
if (widget.excludePassageTypes.contains(passage.fkType)) {
shouldInclude = false;
}
// Vérifier si le passage est dans la période
final passageDate = passage.passedAt;
if (passageDate.isBefore(startDate) || passageDate.isAfter(endDate)) {
shouldInclude = false;
}
if (shouldInclude) {
final dateStr = DateFormat('yyyy-MM-dd').format(passageDate);
if (dataByDate.containsKey(dateStr)) {
dataByDate[dateStr]![passage.fkType] =
(dataByDate[dateStr]![passage.fkType] ?? 0) + 1;
}
}
}
// Convertir en liste d'ActivityData
final List<ActivityData> chartData = [];
dataByDate.forEach((dateStr, passagesByType) {
final dateParts = dateStr.split('-');
if (dateParts.length == 3) {
try {
final date = DateTime(
int.parse(dateParts[0]),
int.parse(dateParts[1]),
int.parse(dateParts[2]),
);
chartData.add(ActivityData(
date: date,
dateStr: dateStr,
passagesByType: passagesByType,
));
} catch (e) {
debugPrint('Erreur de conversion de date: $dateStr');
}
}
});
// Trier par date
chartData.sort((a, b) => a.date.compareTo(b.date));
return chartData;
} catch (e) {
debugPrint('Erreur lors du calcul des données d\'activité: $e');
return [];
}
}
/// Prépare les données pour le graphique à partir des données de passage brutes (ancien système)
List<ActivityData> _prepareChartDataFromPassageData(
List<Map<String, dynamic>> passageData) {
try {
// Obtenir toutes les dates uniques
final Set<String> uniqueDatesSet = {};
for (final data in _passageData) {
for (final data in passageData) {
if (data.containsKey('date') && data['date'] != null) {
uniqueDatesSet.add(data['date'] as String);
}
@@ -266,7 +287,7 @@ class _ActivityChartState extends State<ActivityChart>
uniqueDates.sort();
// Créer les données pour chaque date
_chartData = [];
final List<ActivityData> chartData = [];
for (final dateStr in uniqueDates) {
final passagesByType = <int, int>{};
@@ -278,7 +299,7 @@ class _ActivityChartState extends State<ActivityChart>
}
// Remplir les données de passage
for (final data in _passageData) {
for (final data in passageData) {
if (data.containsKey('date') &&
data['date'] == dateStr &&
data.containsKey('type_passage') &&
@@ -301,37 +322,29 @@ class _ActivityChartState extends State<ActivityChart>
final date = DateTime(year, month, day);
// Ajouter les données à la liste
_chartData.add(ActivityData(
chartData.add(ActivityData(
date: date,
dateStr: dateStr,
passagesByType: passagesByType,
));
}
} catch (e) {
// Silencieux lors des erreurs de conversion de date pour éviter les logs excessifs
debugPrint('Erreur de conversion de date: $dateStr');
}
}
// Trier les données par date
_chartData.sort((a, b) => a.date.compareTo(b.date));
chartData.sort((a, b) => a.date.compareTo(b.date));
return chartData;
} catch (e) {
// Erreur silencieuse pour éviter les logs excessifs
_chartData = [];
debugPrint('Erreur lors de la préparation des données: $e');
return [];
}
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return SizedBox(
height: widget.height,
child: const Center(
child: CircularProgressIndicator(),
),
);
}
if (!_hasData || _chartData.isEmpty) {
/// Construit le graphique avec les données fournies
Widget _buildChart(List<ActivityData> chartData) {
if (chartData.isEmpty) {
return SizedBox(
height: widget.height,
child: const Center(
@@ -340,17 +353,12 @@ class _ActivityChartState extends State<ActivityChart>
);
}
// 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é)
// Titre
if (widget.title.isNotEmpty)
Padding(
padding: const EdgeInsets.only(
@@ -362,13 +370,13 @@ class _ActivityChartState extends State<ActivityChart>
),
),
),
// Graphique (occupe maintenant plus d'espace)
// Graphique
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(16.0, 8.0, 16.0, 16.0),
child: SfCartesianChart(
plotAreaBorderWidth: 0,
legend: Legend(
legend: const Legend(
isVisible: true,
position: LegendPosition.bottom,
overflowMode: LegendItemOverflowMode.wrap,
@@ -378,30 +386,25 @@ class _ActivityChartState extends State<ActivityChart>
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
minimum: chartData.isNotEmpty ? chartData.first.date : null,
maximum: chartData.isNotEmpty ? chartData.last.date : null,
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(
primaryYAxis: const NumericAxis(
labelStyle: TextStyle(fontSize: 10),
axisLine: AxisLine(width: 0),
majorTickLines: MajorTickLines(size: 0),
majorGridLines: MajorGridLines(
width: 0.5,
color: Colors.grey,
dashArray: <double>[5, 5], // Motif de pointillés
dashArray: <double>[5, 5],
),
title: const AxisTitle(
title: AxisTitle(
text: 'Passages',
textStyle: TextStyle(fontSize: 10, color: Colors.grey),
),
),
series: _buildSeries(),
series: _buildSeries(chartData),
tooltipBehavior: TooltipBehavior(enable: true),
zoomPanBehavior: _zoomPanBehavior,
),
@@ -413,11 +416,12 @@ class _ActivityChartState extends State<ActivityChart>
}
/// Construit les séries de données pour le graphique
List<CartesianSeries<ActivityData, DateTime>> _buildSeries() {
List<CartesianSeries<ActivityData, DateTime>> _buildSeries(
List<ActivityData> chartData) {
final List<CartesianSeries<ActivityData, DateTime>> series = [];
// Vérifier que les données sont disponibles
if (_chartData.isEmpty) {
if (chartData.isEmpty) {
return series;
}
@@ -444,25 +448,19 @@ class _ActivityChartState extends State<ActivityChart>
// Calculer le total pour ce type pour déterminer s'il faut l'afficher
int totalForType = 0;
for (final data in _chartData) {
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) {
// Ajouter la série pour ce type si elle a des données
if (totalForType > 0) {
series.add(
StackedColumnSeries<ActivityData, DateTime>(
name: typeName,
dataSource: _chartData,
dataSource: chartData,
xValueMapper: (ActivityData data, _) => data.date,
yValueMapper: (ActivityData data, _) {
final value = data.passagesByType.containsKey(typeId)
? data.passagesByType[typeId]!
: 0;
return value;
return data.passagesByType[typeId] ?? 0;
},
color: typeColor,
width: widget.columnWidth,

View File

@@ -3,9 +3,10 @@ library geosector_charts;
export 'payment_data.dart';
export 'payment_pie_chart.dart';
export 'payment_utils.dart';
export 'payment_summary_card.dart';
export 'passage_data.dart';
export 'passage_utils.dart';
export 'passage_pie_chart.dart';
export 'passage_summary_card.dart';
export 'activity_chart.dart';
export 'combined_chart.dart';

View File

@@ -1,13 +1,10 @@
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:flutter/foundation.dart' show listEquals;
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';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/data/models/passage_model.dart';
/// Modèle de données pour le graphique en camembert des passages
class PassageChartData {
@@ -38,7 +35,7 @@ class PassageChartData {
/// 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é
/// Si useValueListenable est true, ce paramètre est ignoré
final Map<int, int> passagesByType;
/// Taille du graphique
@@ -62,18 +59,21 @@ class PassagePieChart extends StatefulWidget {
/// Rayon central pour le format donut (en pourcentage)
final String innerRadius;
/// Charger les données depuis Hive
/// Charger les données depuis Hive (obsolète, utiliser useValueListenable)
final bool loadFromHive;
/// ID de l'utilisateur pour filtrer les passages (utilisé seulement si loadFromHive est true)
/// ID de l'utilisateur pour filtrer les passages
final int? userId;
/// Types de passages à exclure (utilisé seulement si loadFromHive est true)
/// Types de passages à exclure
final List<int> excludePassageTypes;
/// Afficher tous les passages sans filtrer par utilisateur (utilisé seulement si loadFromHive est true)
/// Afficher tous les passages sans filtrer par utilisateur
final bool showAllPassages;
/// Utiliser ValueListenableBuilder pour la mise à jour automatique
final bool useValueListenable;
const PassagePieChart({
super.key,
this.passagesByType = const {},
@@ -88,6 +88,7 @@ class PassagePieChart extends StatefulWidget {
this.userId,
this.excludePassageTypes = const [2],
this.showAllPassages = false,
this.useValueListenable = true,
});
@override
@@ -98,20 +99,9 @@ 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,
@@ -119,235 +109,21 @@ class _PassagePieChartState extends State<PassagePieChart>
);
_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;
// Relancer l'animation si les paramètres importants ont changé
final bool shouldResetAnimation = oldWidget.userId != widget.userId ||
!listEquals(oldWidget.excludePassageTypes, widget.excludePassageTypes) ||
oldWidget.showAllPassages != widget.showAllPassages ||
oldWidget.useValueListenable != widget.useValueListenable;
// 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é
if (shouldResetAnimation) {
_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 pendant le chargement
if (_isLoading) {
debugPrint('PassagePieChart: Déjà en cours de chargement, ignoré');
return;
}
// Si les données sont déjà chargées et non vides, ne pas recharger
if (_dataLoaded && _passagesByType.isNotEmpty) {
debugPrint('PassagePieChart: Données déjà chargées, ignoré');
return;
}
debugPrint('PassagePieChart: Début du chargement des données');
setState(() {
_isLoading = true;
});
// 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) {
debugPrint('PassagePieChart: Widget démonté, chargement annulé');
return;
}
try {
debugPrint('PassagePieChart: Création du service de données');
// Utiliser les instances globales définies dans app.dart
// Vérifier que les repositories sont disponibles
if (passageRepository == null) {
debugPrint('PassagePieChart: ERREUR - passageRepository est null');
if (mounted) {
setState(() {
_isLoading = false;
});
}
return;
}
if (userRepository == null) {
debugPrint('PassagePieChart: ERREUR - userRepository est null');
if (mounted) {
setState(() {
_isLoading = false;
});
}
return;
}
// Créer une instance du service de données
final passageDataService = PassageDataService(
passageRepository: passageRepository,
userRepository: userRepository,
);
debugPrint(
'PassagePieChart: Chargement des données avec excludePassageTypes=${widget.excludePassageTypes}, userId=${widget.userId}, showAllPassages=${widget.showAllPassages}');
// Utiliser le service pour charger les données
final data = passageDataService.loadPassageDataForPieChart(
excludePassageTypes: widget.excludePassageTypes,
userId: widget.userId,
showAllPassages: widget.showAllPassages,
);
debugPrint('PassagePieChart: Données chargées: $data');
// 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();
debugPrint('PassagePieChart: Données préparées pour le graphique');
}
} catch (e) {
// Gérer les erreurs et réinitialiser l'état pour permettre une future tentative
debugPrint(
'PassagePieChart: ERREUR lors du chargement des données: $e');
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) {
debugPrint('PassagePieChart: Utilisation des données en cache');
return _cachedChartData!;
}
debugPrint('PassagePieChart: Préparation des données pour le graphique');
debugPrint('PassagePieChart: Données brutes: $_passagesByType');
// Vérifier si les données sont vides
if (_passagesByType.isEmpty) {
debugPrint('PassagePieChart: Aucune donnée disponible');
return [];
}
// Vérifier si les données contiennent uniquement des passages de type 2
bool onlyType2 = true;
_passagesByType.forEach((typeId, count) {
if (typeId != 2 && count > 0) {
onlyType2 = false;
}
});
if (onlyType2) {
debugPrint(
'PassagePieChart: Les données contiennent uniquement des passages de type 2');
}
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)) {
// Vérifier si le type est exclu
bool isExcluded = widget.excludePassageTypes.contains(typeId);
if (isExcluded) {
debugPrint('PassagePieChart: Type $typeId exclu');
} else {
final typeInfo = AppKeys.typesPassages[typeId]!;
final typeName = typeInfo['titre'] as String;
debugPrint(
'PassagePieChart: Ajout du type $typeId ($typeName) avec $count passages');
chartData.add(PassageChartData(
typeId: typeId,
count: count,
title: typeName,
color: Color(typeInfo['couleur2'] as int),
icon: typeInfo['icon_data'] as IconData,
));
}
} else {
if (count <= 0) {
debugPrint('PassagePieChart: Type $typeId ignoré car count=$count');
} else if (!AppKeys.typesPassages.containsKey(typeId)) {
debugPrint(
'PassagePieChart: Type $typeId ignoré car non défini dans AppKeys.typesPassages');
}
}
});
debugPrint(
'PassagePieChart: ${chartData.length} types de passages ajoutés au graphique');
// Mettre en cache les données générées
_cachedChartData = chartData;
return chartData;
}
@override
@@ -358,19 +134,100 @@ class _PassagePieChartState extends State<PassagePieChart>
@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(),
),
);
if (widget.useValueListenable) {
return _buildWithValueListenable();
} else {
return _buildWithStaticData();
}
}
final chartData = _prepareChartData();
/// Construction du widget avec ValueListenableBuilder pour mise à jour automatique
Widget _buildWithValueListenable() {
return ValueListenableBuilder(
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
builder: (context, Box<PassageModel> passagesBox, child) {
final chartData = _calculatePassageData(passagesBox);
return _buildChart(chartData);
},
);
}
/// Construction du widget avec des données statiques (ancien système)
Widget _buildWithStaticData() {
final chartData = _prepareChartDataFromMap(widget.passagesByType);
return _buildChart(chartData);
}
/// Calcule les données de passage depuis la Hive box
List<PassageChartData> _calculatePassageData(Box<PassageModel> passagesBox) {
try {
final passages = passagesBox.values.toList();
final currentUser = userRepository.getCurrentUser();
// Calculer les données selon les filtres
final Map<int, int> passagesByType = {};
// Initialiser tous les types de passage possibles
for (final typeId in AppKeys.typesPassages.keys) {
if (!widget.excludePassageTypes.contains(typeId)) {
passagesByType[typeId] = 0;
}
}
for (final passage in passages) {
// Appliquer les filtres
bool shouldInclude = true;
// Filtrer par utilisateur si nécessaire
if (!widget.showAllPassages && widget.userId != null) {
shouldInclude = passage.fkUser == widget.userId;
} else if (!widget.showAllPassages && currentUser != null) {
shouldInclude = passage.fkUser == currentUser.id;
}
// Exclure certains types
if (widget.excludePassageTypes.contains(passage.fkType)) {
shouldInclude = false;
}
if (shouldInclude) {
passagesByType[passage.fkType] =
(passagesByType[passage.fkType] ?? 0) + 1;
}
}
return _prepareChartDataFromMap(passagesByType);
} catch (e) {
debugPrint('Erreur lors du calcul des données de passage: $e');
return [];
}
}
/// Prépare les données pour le graphique en camembert à partir d'une Map
List<PassageChartData> _prepareChartDataFromMap(Map<int, int> passagesByType) {
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,
));
}
});
return chartData;
}
/// Construit le graphique avec les données fournies
Widget _buildChart(List<PassageChartData> chartData) {
// Si aucune donnée, afficher un message
if (chartData.isEmpty) {
return SizedBox(
@@ -448,8 +305,7 @@ class _PassagePieChartState extends State<PassagePieChart>
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
animationDuration: 0,
startAngle: 270,
endAngle: 270 + (360 * progressAnimation.value).toInt(),
)
@@ -485,8 +341,7 @@ class _PassagePieChartState extends State<PassagePieChart>
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
animationDuration: 0,
startAngle: 270,
endAngle: 270 + (360 * progressAnimation.value).toInt(),
),
@@ -499,14 +354,9 @@ class _PassagePieChartState extends State<PassagePieChart>
);
}
/// Crée les annotations d'icônes pour le graphique avec mise en cache
/// Crée les annotations d'icônes pour le graphique
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
@@ -541,9 +391,6 @@ class _PassagePieChartState extends State<PassagePieChart>
currentAngle += segmentAngle;
}
// Mettre en cache les annotations générées
_cachedAnnotations = annotations;
return annotations;
}
}

View File

@@ -0,0 +1,353 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/presentation/widgets/charts/passage_pie_chart.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/theme/app_theme.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/data/models/passage_model.dart';
import 'package:geosector_app/app.dart';
/// Widget commun pour afficher une carte de synthèse des passages
/// avec liste des types à gauche et graphique en camembert à droite
class PassageSummaryCard extends StatelessWidget {
/// Titre de la carte
final String title;
/// Couleur de l'icône et du titre
final Color titleColor;
/// Icône à afficher dans le titre
final IconData? titleIcon;
/// Hauteur totale de la carte
final double? height;
/// Utiliser ValueListenableBuilder pour mise à jour automatique
final bool useValueListenable;
/// ID de l'utilisateur pour filtrer les passages (si null, tous les utilisateurs)
final int? userId;
/// Afficher tous les passages (admin) ou seulement ceux de l'utilisateur
final bool showAllPassages;
/// Types de passages à exclure du graphique
final List<int> excludePassageTypes;
/// Données statiques de passages par type (utilisé si useValueListenable = false)
final Map<int, int>? passagesByType;
/// Fonction de callback pour afficher la valeur totale personnalisée
final String Function(int totalPassages)? customTotalDisplay;
/// Afficher le graphique en mode desktop ou mobile
final bool isDesktop;
/// Icône d'arrière-plan (optionnelle)
final IconData? backgroundIcon;
/// Couleur de l'icône d'arrière-plan
final Color? backgroundIconColor;
/// Opacité de l'icône d'arrière-plan
final double backgroundIconOpacity;
/// Taille de l'icône d'arrière-plan
final double backgroundIconSize;
const PassageSummaryCard({
super.key,
required this.title,
this.titleColor = AppTheme.primaryColor,
this.titleIcon = Icons.route,
this.height,
this.useValueListenable = true,
this.userId,
this.showAllPassages = false,
this.excludePassageTypes = const [2], // Exclure "À finaliser" par défaut
this.passagesByType,
this.customTotalDisplay,
this.isDesktop = true,
this.backgroundIcon = Icons.route,
this.backgroundIconColor,
this.backgroundIconOpacity = 0.07,
this.backgroundIconSize = 180,
});
@override
Widget build(BuildContext context) {
return Card(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Stack(
children: [
// Icône d'arrière-plan (optionnelle)
if (backgroundIcon != null)
Positioned.fill(
child: Center(
child: Icon(
backgroundIcon,
size: backgroundIconSize,
color: (backgroundIconColor ?? AppTheme.primaryColor).withOpacity(backgroundIconOpacity),
),
),
),
// Contenu principal
Container(
height: height,
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Titre avec comptage
useValueListenable
? _buildTitleWithValueListenable()
: _buildTitleWithStaticData(),
const Divider(height: 24),
// Contenu principal
Expanded(
child: SizedBox(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Liste des passages à gauche
Expanded(
flex: isDesktop ? 1 : 2,
child: useValueListenable
? _buildPassagesListWithValueListenable()
: _buildPassagesListWithStaticData(),
),
// Séparateur vertical
if (isDesktop) const VerticalDivider(width: 24),
// Graphique en camembert à droite
Expanded(
flex: isDesktop ? 1 : 2,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: PassagePieChart(
useValueListenable: useValueListenable,
passagesByType: passagesByType ?? {},
excludePassageTypes: excludePassageTypes,
userId: showAllPassages ? null : userId,
size: double.infinity,
labelSize: 12,
showPercentage: true,
showIcons: false,
showLegend: false,
isDonut: true,
innerRadius: '50%',
),
),
),
],
),
),
),
],
),
),
],
),
);
}
/// Construction du titre avec ValueListenableBuilder
Widget _buildTitleWithValueListenable() {
return ValueListenableBuilder(
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
builder: (context, Box<PassageModel> passagesBox, child) {
final totalUserPassages = _calculateUserPassagesCount(passagesBox);
return Row(
children: [
if (titleIcon != null) ...[
Icon(
titleIcon,
color: titleColor,
size: 24,
),
const SizedBox(width: 8),
],
Expanded(
child: Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
Text(
customTotalDisplay?.call(totalUserPassages) ?? totalUserPassages.toString(),
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: titleColor,
),
),
],
);
},
);
}
/// Construction du titre avec données statiques
Widget _buildTitleWithStaticData() {
final totalPassages = passagesByType?.values.fold(0, (sum, count) => sum + count) ?? 0;
return Row(
children: [
if (titleIcon != null) ...[
Icon(
titleIcon,
color: titleColor,
size: 24,
),
const SizedBox(width: 8),
],
Expanded(
child: Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
Text(
customTotalDisplay?.call(totalPassages) ?? totalPassages.toString(),
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: titleColor,
),
),
],
);
}
/// Construction de la liste des passages avec ValueListenableBuilder
Widget _buildPassagesListWithValueListenable() {
return ValueListenableBuilder(
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
builder: (context, Box<PassageModel> passagesBox, child) {
final passagesCounts = _calculatePassagesCounts(passagesBox);
return _buildPassagesList(passagesCounts);
},
);
}
/// Construction de la liste des passages avec données statiques
Widget _buildPassagesListWithStaticData() {
return _buildPassagesList(passagesByType ?? {});
}
/// Construction de la liste des passages
Widget _buildPassagesList(Map<int, int> passagesCounts) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
...AppKeys.typesPassages.entries.map((entry) {
final int typeId = entry.key;
final Map<String, dynamic> typeData = entry.value;
final int count = passagesCounts[typeId] ?? 0;
final Color color = Color(typeData['couleur2'] as int);
final IconData iconData = typeData['icon_data'] as IconData;
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Row(
children: [
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
child: Icon(
iconData,
color: Colors.white,
size: 16,
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
typeData['titres'] as String,
style: const TextStyle(fontSize: 14),
),
),
Text(
count.toString(),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: color,
),
),
],
),
);
}).toList(),
],
);
}
/// Calcule le nombre total de passages pour l'utilisateur
int _calculateUserPassagesCount(Box<PassageModel> passagesBox) {
if (showAllPassages) {
// Pour les administrateurs : tous les passages sauf ceux exclus
return passagesBox.values
.where((passage) => !excludePassageTypes.contains(passage.fkType))
.length;
} else {
// Pour les utilisateurs : seulement leurs passages
final currentUser = userRepository.getCurrentUser();
final targetUserId = userId ?? currentUser?.id;
if (targetUserId == null) return 0;
return passagesBox.values
.where((passage) =>
passage.fkUser == targetUserId &&
!excludePassageTypes.contains(passage.fkType))
.length;
}
}
/// Calcule les compteurs de passages par type
Map<int, int> _calculatePassagesCounts(Box<PassageModel> passagesBox) {
final Map<int, int> counts = {};
// Initialiser tous les types
for (final typeId in AppKeys.typesPassages.keys) {
counts[typeId] = 0;
}
if (showAllPassages) {
// Pour les administrateurs : compter tous les passages
for (final passage in passagesBox.values) {
counts[passage.fkType] = (counts[passage.fkType] ?? 0) + 1;
}
} else {
// Pour les utilisateurs : compter seulement leurs passages
final currentUser = userRepository.getCurrentUser();
final targetUserId = userId ?? currentUser?.id;
if (targetUserId != null) {
for (final passage in passagesBox.values) {
if (passage.fkUser == targetUserId) {
counts[passage.fkType] = (counts[passage.fkType] ?? 0) + 1;
}
}
}
}
return counts;
}
}

View File

@@ -1,11 +1,16 @@
import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_charts/charts.dart';
import 'package:geosector_app/presentation/widgets/charts/payment_data.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/data/models/passage_model.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/app.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
/// Si useValueListenable est true, ce paramètre est ignoré
final List<PaymentData> payments;
/// Taille du graphique
@@ -41,9 +46,15 @@ class PaymentPieChart extends StatefulWidget {
/// Utiliser un dégradé pour simuler l'effet 3D
final bool useGradient;
/// Utiliser ValueListenableBuilder pour la mise à jour automatique
final bool useValueListenable;
/// ID de l'utilisateur pour filtrer les passages
final int? userId;
const PaymentPieChart({
super.key,
required this.payments,
this.payments = const [],
this.size = 300,
this.labelSize = 12,
this.showPercentage = true,
@@ -55,6 +66,8 @@ class PaymentPieChart extends StatefulWidget {
this.effect3DIntensity = 1.0,
this.enableEnhancedExplode = false,
this.useGradient = false,
this.useValueListenable = true,
this.userId,
});
@override
@@ -80,20 +93,24 @@ class _PaymentPieChartState extends State<PaymentPieChart>
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
// Relancer l'animation si les paramètres importants ont changé
bool shouldResetAnimation = false;
if (oldWidget.payments.length != widget.payments.length) {
if (widget.useValueListenable != oldWidget.useValueListenable ||
widget.userId != oldWidget.userId) {
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) {
} else if (!widget.useValueListenable) {
// Pour les données statiques, comparer les éléments
if (oldWidget.payments.length != widget.payments.length) {
shouldResetAnimation = true;
break;
} else {
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;
}
}
}
}
@@ -110,15 +127,115 @@ class _PaymentPieChartState extends State<PaymentPieChart>
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();
if (widget.useValueListenable) {
return _buildWithValueListenable();
} else {
return _buildWithStaticData();
}
}
/// Construction du widget avec ValueListenableBuilder pour mise à jour automatique
Widget _buildWithValueListenable() {
return ValueListenableBuilder(
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
builder: (context, Box<PassageModel> passagesBox, child) {
final paymentData = _calculatePaymentData(passagesBox);
return _buildChart(paymentData);
},
);
}
/// Construction du widget avec des données statiques
Widget _buildWithStaticData() {
return _buildChart(widget.payments);
}
/// Calcule les données de règlement depuis la Hive box
List<PaymentData> _calculatePaymentData(Box<PassageModel> passagesBox) {
try {
final passages = passagesBox.values.toList();
final currentUser = userRepository.getCurrentUser();
final int? currentUserId = widget.userId ?? currentUser?.id;
// Initialiser les montants par type de règlement
final Map<int, double> paymentAmounts = {
0: 0.0, // Pas de règlement
1: 0.0, // Espèces
2: 0.0, // Chèques
3: 0.0, // CB
};
// Parcourir les passages et calculer les montants par type de règlement
for (final passage in passages) {
// Vérifier si le passage appartient à l'utilisateur actuel
if (currentUserId != null && passage.fkUser == currentUserId) {
final int typeReglement = passage.fkTypeReglement;
// Convertir la chaîne de montant en double
double montant = 0.0;
try {
// Gérer les formats possibles (virgule ou point)
String montantStr = passage.montant.replaceAll(',', '.');
montant = double.tryParse(montantStr) ?? 0.0;
} catch (e) {
debugPrint('Erreur de conversion du montant: ${passage.montant}');
}
// Ne compter que les passages avec un montant > 0
if (montant > 0) {
// Ajouter au montant total par type de règlement
if (paymentAmounts.containsKey(typeReglement)) {
paymentAmounts[typeReglement] =
(paymentAmounts[typeReglement] ?? 0.0) + montant;
} else {
// Si le type n'est pas dans notre map, l'ajouter à la catégorie par défaut
paymentAmounts[0] = (paymentAmounts[0] ?? 0.0) + montant;
}
}
}
}
// Convertir le Map en List<PaymentData>
final List<PaymentData> paymentDataList = [];
paymentAmounts.forEach((typeReglement, montant) {
if (montant > 0) { // Ne retourner que les types avec un montant > 0
// Récupérer les informations depuis AppKeys.typesReglements
final reglementInfo = AppKeys.typesReglements[typeReglement];
if (reglementInfo != null) {
paymentDataList.add(PaymentData(
typeId: typeReglement,
title: reglementInfo['titre'] as String,
amount: montant,
color: Color(reglementInfo['couleur'] as int),
icon: reglementInfo['icon_data'] as IconData,
));
} else {
// Fallback pour les types non définis
paymentDataList.add(PaymentData(
typeId: typeReglement,
title: 'Type inconnu',
amount: montant,
color: Colors.grey,
icon: Icons.help_outline,
));
}
}
});
return paymentDataList;
} catch (e) {
debugPrint('Erreur lors du calcul des données de règlement: $e');
return [];
}
}
/// Construit le graphique avec les données fournies
Widget _buildChart(List<PaymentData> paymentData) {
final chartData = _prepareChartData(paymentData);
// Si aucune donnée, afficher un message
if (chartData.isEmpty) {
@@ -170,7 +287,6 @@ class _PaymentPieChartState extends State<PaymentPieChart>
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;
@@ -181,11 +297,9 @@ class _PaymentPieChartState extends State<PaymentPieChart>
}
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);
@@ -196,18 +310,14 @@ class _PaymentPieChartState extends State<PaymentPieChart>
},
dataLabelSettings: DataLabelSettings(
isVisible: true,
labelPosition: ChartDataLabelPosition
.inside, // Afficher les étiquettes à l'intérieur du donut
labelPosition: ChartDataLabelPosition.inside,
textStyle: TextStyle(
fontSize: widget.labelSize,
color: Colors
.white, // Texte blanc pour meilleure lisibilité
fontWeight: FontWeight
.bold, // Texte en gras pour meilleure lisibilité
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
innerRadius: widget.innerRadius,
// Effet d'explosion plus prononcé pour donner du relief avec animation
explode: true,
explodeAll: widget.enableEnhancedExplode,
explodeIndex: widget.enableEnhancedExplode ? null : 0,
@@ -216,13 +326,10 @@ class _PaymentPieChartState extends State<PaymentPieChart>
? '${(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
animationDuration: 0,
startAngle: 270,
endAngle: 270 + (360 * progressAnimation.value).toInt(),
)
@@ -232,7 +339,6 @@ class _PaymentPieChartState extends State<PaymentPieChart>
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;
@@ -243,11 +349,9 @@ class _PaymentPieChartState extends State<PaymentPieChart>
}
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);
@@ -265,7 +369,6 @@ class _PaymentPieChartState extends State<PaymentPieChart>
length: '15%',
),
),
// Effet d'explosion plus prononcé pour donner du relief avec animation
explode: true,
explodeAll: widget.enableEnhancedExplode,
explodeIndex: widget.enableEnhancedExplode ? null : 0,
@@ -274,40 +377,35 @@ class _PaymentPieChartState extends State<PaymentPieChart>
? '${(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
animationDuration: 0,
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
),
);
},
);
}
/// Ce 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);
/// Ppare les données pour le graphique en camembert
List<PaymentData> _prepareChartData(List<PaymentData> payments) {
// Filtrer les règlements avec un montant > 0
return payments.where((payment) => payment.amount > 0).toList();
}
// Augmenter la luminosité pour simuler un éclairage
/// Crée une couleur avec effet 3D en ajustant les nuances
Color _create3DColor(Color baseColor, double intensity) {
final hslColor = HSLColor.fromColor(baseColor);
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);
@@ -321,24 +419,17 @@ class _PaymentPieChartState extends State<PaymentPieChart>
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);
@@ -356,10 +447,7 @@ class _PaymentPieChartState extends State<PaymentPieChart>
/// 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);
@@ -371,21 +459,15 @@ class _PaymentPieChartState extends State<PaymentPieChart>
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(
@@ -394,11 +476,10 @@ class _PaymentPieChartState extends State<PaymentPieChart>
size: 16,
),
radius: '50%',
angle: (midAngle * (180 / 3.14159)).toInt(), // Convertir en degrés
angle: (midAngle * (180 / 3.14159)).toInt(),
),
);
// Mettre à jour l'angle actuel
currentAngle += segmentAngle;
}

View File

@@ -0,0 +1,463 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/presentation/widgets/charts/payment_pie_chart.dart';
import 'package:geosector_app/presentation/widgets/charts/payment_data.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/theme/app_theme.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/data/models/passage_model.dart';
import 'package:geosector_app/app.dart';
/// Widget commun pour afficher une carte de synthèse des règlements
/// avec liste des types à gauche et graphique en camembert à droite
class PaymentSummaryCard extends StatelessWidget {
/// Titre de la carte
final String title;
/// Couleur de l'icône et du titre
final Color titleColor;
/// Icône à afficher dans le titre
final IconData? titleIcon;
/// Hauteur totale de la carte
final double? height;
/// Utiliser ValueListenableBuilder pour mise à jour automatique
final bool useValueListenable;
/// ID de l'utilisateur pour filtrer les passages (si null, tous les utilisateurs)
final int? userId;
/// Afficher tous les règlements (admin) ou seulement ceux de l'utilisateur
final bool showAllPayments;
/// Données statiques de règlements par type (utilisé si useValueListenable = false)
final Map<int, double>? paymentsByType;
/// Fonction de callback pour afficher la valeur totale personnalisée
final String Function(double totalAmount)? customTotalDisplay;
/// Afficher le graphique en mode desktop ou mobile
final bool isDesktop;
/// Icône d'arrière-plan (optionnelle)
final IconData? backgroundIcon;
/// Couleur de l'icône d'arrière-plan
final Color? backgroundIconColor;
/// Opacité de l'icône d'arrière-plan
final double backgroundIconOpacity;
/// Taille de l'icône d'arrière-plan
final double backgroundIconSize;
const PaymentSummaryCard({
super.key,
required this.title,
this.titleColor = AppTheme.accentColor,
this.titleIcon = Icons.payments,
this.height,
this.useValueListenable = true,
this.userId,
this.showAllPayments = false,
this.paymentsByType,
this.customTotalDisplay,
this.isDesktop = true,
this.backgroundIcon = Icons.euro_symbol,
this.backgroundIconColor,
this.backgroundIconOpacity = 0.07,
this.backgroundIconSize = 180,
});
@override
Widget build(BuildContext context) {
return Card(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Stack(
children: [
// Icône d'arrière-plan (optionnelle)
if (backgroundIcon != null)
Positioned.fill(
child: Center(
child: Icon(
backgroundIcon,
size: backgroundIconSize,
color: (backgroundIconColor ?? Colors.blue).withOpacity(backgroundIconOpacity),
),
),
),
// Contenu principal
Container(
height: height,
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Titre avec comptage
useValueListenable
? _buildTitleWithValueListenable()
: _buildTitleWithStaticData(),
const Divider(height: 24),
// Contenu principal
Expanded(
child: SizedBox(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Liste des règlements à gauche
Expanded(
flex: isDesktop ? 1 : 2,
child: useValueListenable
? _buildPaymentsListWithValueListenable()
: _buildPaymentsListWithStaticData(),
),
// Séparateur vertical
if (isDesktop) const VerticalDivider(width: 24),
// Graphique en camembert à droite
Expanded(
flex: isDesktop ? 1 : 2,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: PaymentPieChart(
useValueListenable: useValueListenable,
payments: useValueListenable ? [] : _convertMapToPaymentData(paymentsByType ?? {}),
userId: showAllPayments ? null : userId,
size: double.infinity,
labelSize: 12,
showPercentage: true,
showIcons: false,
showLegend: false,
isDonut: true,
innerRadius: '50%',
enable3DEffect: false,
effect3DIntensity: 0.0,
enableEnhancedExplode: false,
useGradient: false,
),
),
),
],
),
),
),
],
),
),
],
),
);
}
/// Construction du titre avec ValueListenableBuilder
Widget _buildTitleWithValueListenable() {
return ValueListenableBuilder(
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
builder: (context, Box<PassageModel> passagesBox, child) {
final paymentStats = _calculatePaymentStats(passagesBox);
return Row(
children: [
if (titleIcon != null) ...[
Icon(
titleIcon,
color: titleColor,
size: 24,
),
const SizedBox(width: 8),
],
Expanded(
child: Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
Text(
customTotalDisplay?.call(paymentStats['totalAmount']) ??
'${paymentStats['totalAmount'].toStringAsFixed(2)}',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: titleColor,
),
),
],
);
},
);
}
/// Construction du titre avec données statiques
Widget _buildTitleWithStaticData() {
final totalAmount = paymentsByType?.values.fold(0.0, (sum, amount) => sum + amount) ?? 0.0;
return Row(
children: [
if (titleIcon != null) ...[
Icon(
titleIcon,
color: titleColor,
size: 24,
),
const SizedBox(width: 8),
],
Expanded(
child: Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
Text(
customTotalDisplay?.call(totalAmount) ?? '${totalAmount.toStringAsFixed(2)}',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: titleColor,
),
),
],
);
}
/// Construction de la liste des règlements avec ValueListenableBuilder
Widget _buildPaymentsListWithValueListenable() {
return ValueListenableBuilder(
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
builder: (context, Box<PassageModel> passagesBox, child) {
final paymentAmounts = _calculatePaymentAmounts(passagesBox);
return _buildPaymentsList(paymentAmounts);
},
);
}
/// Construction de la liste des règlements avec données statiques
Widget _buildPaymentsListWithStaticData() {
return _buildPaymentsList(paymentsByType ?? {});
}
/// Construction de la liste des règlements
Widget _buildPaymentsList(Map<int, double> paymentAmounts) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
...AppKeys.typesReglements.entries.map((entry) {
final int typeId = entry.key;
final Map<String, dynamic> typeData = entry.value;
final double amount = paymentAmounts[typeId] ?? 0.0;
final Color color = Color(typeData['couleur'] as int);
final IconData iconData = typeData['icon_data'] as IconData;
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Row(
children: [
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
child: Icon(
iconData,
color: Colors.white,
size: 16,
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
typeData['titre'] as String,
style: const TextStyle(fontSize: 14),
),
),
Text(
'${amount.toStringAsFixed(2)}',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: color,
),
),
],
),
);
}).toList(),
],
);
}
/// Calcule les statistiques de règlement
Map<String, dynamic> _calculatePaymentStats(Box<PassageModel> passagesBox) {
if (showAllPayments) {
// Pour les administrateurs : tous les règlements
int passagesWithPaymentCount = 0;
double totalAmount = 0.0;
for (final passage in passagesBox.values) {
// Convertir la chaîne de montant en double
double montant = 0.0;
try {
String montantStr = passage.montant.replaceAll(',', '.');
montant = double.tryParse(montantStr) ?? 0.0;
} catch (e) {
// Ignorer les erreurs de conversion
}
if (montant > 0) {
passagesWithPaymentCount++;
totalAmount += montant;
}
}
return {
'passagesCount': passagesWithPaymentCount,
'totalAmount': totalAmount,
};
} else {
// Pour les utilisateurs : seulement leurs règlements
final currentUser = userRepository.getCurrentUser();
final targetUserId = userId ?? currentUser?.id;
if (targetUserId == null) {
return {'passagesCount': 0, 'totalAmount': 0.0};
}
int passagesWithPaymentCount = 0;
double totalAmount = 0.0;
for (final passage in passagesBox.values) {
if (passage.fkUser == targetUserId) {
// Convertir la chaîne de montant en double
double montant = 0.0;
try {
String montantStr = passage.montant.replaceAll(',', '.');
montant = double.tryParse(montantStr) ?? 0.0;
} catch (e) {
// Ignorer les erreurs de conversion
}
if (montant > 0) {
passagesWithPaymentCount++;
totalAmount += montant;
}
}
}
return {
'passagesCount': passagesWithPaymentCount,
'totalAmount': totalAmount,
};
}
}
/// Calcule les montants par type de règlement
Map<int, double> _calculatePaymentAmounts(Box<PassageModel> passagesBox) {
final Map<int, double> paymentAmounts = {};
// Initialiser tous les types
for (final typeId in AppKeys.typesReglements.keys) {
paymentAmounts[typeId] = 0.0;
}
if (showAllPayments) {
// Pour les administrateurs : compter tous les règlements
for (final passage in passagesBox.values) {
final int typeReglement = passage.fkTypeReglement;
// Convertir la chaîne de montant en double
double montant = 0.0;
try {
String montantStr = passage.montant.replaceAll(',', '.');
montant = double.tryParse(montantStr) ?? 0.0;
} catch (e) {
// Ignorer les erreurs de conversion
}
if (montant > 0) {
if (paymentAmounts.containsKey(typeReglement)) {
paymentAmounts[typeReglement] = (paymentAmounts[typeReglement] ?? 0.0) + montant;
} else {
paymentAmounts[0] = (paymentAmounts[0] ?? 0.0) + montant;
}
}
}
} else {
// Pour les utilisateurs : compter seulement leurs règlements
final currentUser = userRepository.getCurrentUser();
final targetUserId = userId ?? currentUser?.id;
if (targetUserId != null) {
for (final passage in passagesBox.values) {
if (passage.fkUser == targetUserId) {
final int typeReglement = passage.fkTypeReglement;
// Convertir la chaîne de montant en double
double montant = 0.0;
try {
String montantStr = passage.montant.replaceAll(',', '.');
montant = double.tryParse(montantStr) ?? 0.0;
} catch (e) {
// Ignorer les erreurs de conversion
}
if (montant > 0) {
if (paymentAmounts.containsKey(typeReglement)) {
paymentAmounts[typeReglement] = (paymentAmounts[typeReglement] ?? 0.0) + montant;
} else {
paymentAmounts[0] = (paymentAmounts[0] ?? 0.0) + montant;
}
}
}
}
}
}
return paymentAmounts;
}
/// Convertit une Map<int, double> en List<PaymentData> pour les données statiques
List<PaymentData> _convertMapToPaymentData(Map<int, double> paymentsMap) {
final List<PaymentData> paymentDataList = [];
paymentsMap.forEach((typeReglement, montant) {
if (montant > 0) { // Ne retourner que les types avec un montant > 0
// Récupérer les informations depuis AppKeys.typesReglements
final reglementInfo = AppKeys.typesReglements[typeReglement];
if (reglementInfo != null) {
paymentDataList.add(PaymentData(
typeId: typeReglement,
title: reglementInfo['titre'] as String,
amount: montant,
color: Color(reglementInfo['couleur'] as int),
icon: reglementInfo['icon_data'] as IconData,
));
} else {
// Fallback pour les types non définis
paymentDataList.add(PaymentData(
typeId: typeReglement,
title: 'Type inconnu',
amount: montant,
color: Colors.grey,
icon: Icons.help_outline,
));
}
}
});
return paymentDataList;
}
}

View File

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

View File

@@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/shared/app_theme.dart';
import 'package:geosector_app/core/theme/app_theme.dart';
/// Widget pour la zone de saisie des messages
class ChatInput extends StatefulWidget {

View File

@@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/shared/app_theme.dart';
import 'package:geosector_app/core/theme/app_theme.dart';
/// Widget pour afficher les messages d'une conversation
class ChatMessages extends StatelessWidget {

View File

@@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/shared/app_theme.dart';
import 'package:geosector_app/core/theme/app_theme.dart';
/// Widget pour afficher la barre latérale des contacts
class ChatSidebar extends StatelessWidget {

View File

@@ -22,6 +22,7 @@ class CustomTextField extends StatelessWidget {
final Color? fillColor;
final String? helperText;
final Function(String)? onFieldSubmitted;
final bool isRequired;
const CustomTextField({
super.key,
@@ -45,6 +46,7 @@ class CustomTextField extends StatelessWidget {
this.fillColor,
this.helperText,
this.onFieldSubmitted,
this.isRequired = false,
});
@override
@@ -65,93 +67,113 @@ class CustomTextField extends StatelessWidget {
const SizedBox(height: 8),
],
// Ajouter un Container avec une ombre pour créer un effet d'élévation
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: 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,
// Couleur de fond différente selon l'état (lecture seule ou éditable)
fillColor: fillColor ??
(readOnly
? const Color(0xFFF8F9FA) // Gris plus clair pour readOnly
: const Color(
0xFFECEFF1)), // Gris plus foncé pour éditable
filled: true,
// Ajouter une élévation avec une petite ombre
border: OutlineInputBorder(
Stack(
children: [
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
// Ajouter une ombre pour créer un effet d'élévation
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
gapPadding: 0,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: theme.colorScheme.primary,
width: 2,
child: 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,
),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: theme.colorScheme.error,
width: 2,
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,
// Couleur de fond différente selon l'état (lecture seule ou éditable)
fillColor: fillColor ??
(readOnly
? const Color(
0xFFF8F9FA) // Gris plus clair pour readOnly
: const Color(
0xFFECEFF1)), // Gris plus foncé pour éditable
filled: true,
// Ajouter une élévation avec une petite ombre
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
// Ajouter une ombre pour créer un effet d'élévation
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
gapPadding: 0,
),
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,
),
),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: theme.colorScheme.error,
width: 2,
),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 16,
),
),
),
// Point rouge en haut à droite pour indiquer que le champ est obligatoire
if (isRequired)
Positioned(
top: 0,
right: 0,
child: Container(
width: 10,
height: 10,
margin: const EdgeInsets.only(top: 8, right: 8),
decoration: const BoxDecoration(
color: Colors.red,
shape: BoxShape.circle,
),
),
),
],
),
],
);

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/app.dart';
import 'package:geosector_app/core/services/app_info_service.dart';
import 'package:geosector_app/presentation/widgets/connectivity_indicator.dart';
import 'package:geosector_app/presentation/widgets/profile_dialog.dart';
import 'package:go_router/go_router.dart';
@@ -12,9 +13,6 @@ class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget {
/// 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;
@@ -31,7 +29,6 @@ class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget {
Key? key,
required this.title,
this.pageTitle,
this.additionalActions,
this.showNewPassageButton = true,
this.onNewPassagePressed,
this.isAdmin = false,
@@ -56,10 +53,15 @@ class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget {
Widget _buildLogo() {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Image.asset(
'assets/images/logo-geosector-1024.png',
width: 40,
height: 40,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset(
'assets/images/logo-geosector-1024.png',
width: 40,
height: 40,
),
],
),
);
}
@@ -71,33 +73,38 @@ class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget {
// Ajouter l'indicateur de connectivité
actions.add(
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 12.0),
child: const ConnectivityIndicator(
const Padding(
padding: EdgeInsets.symmetric(horizontal: 8.0, vertical: 12.0),
child: 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 la version de l'application
actions.add(
Text(
AppInfoService.fullVersion,
style: const TextStyle(
fontSize: 12,
color: Colors.white70,
),
);
}
),
);
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 "Mon compte"
actions.add(
@@ -128,25 +135,34 @@ class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget {
tooltip: 'Déconnexion',
onPressed: onLogoutPressed ??
() {
// Si aucun callback n'est fourni, utiliser le userRepository global
showDialog(
context: context,
builder: (context) => AlertDialog(
builder: (dialogContext) => AlertDialog(
title: const Text('Déconnexion'),
content:
const Text('Voulez-vous vraiment vous déconnecter ?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('Annuler'),
),
TextButton(
onPressed: () async {
Navigator.of(context).pop();
// Utiliser directement userRepository pour la déconnexion
// qui gère à la fois le nettoyage des données et la redirection
await userRepository.logoutWithUI(context);
// La redirection est gérée dans logoutWithUI
// Fermer la dialog d'abord
Navigator.of(dialogContext).pop();
// Utiliser le context original de l'AppBar pour la navigation
final success = await userRepository.logout(context);
// Vérification supplémentaire et navigation forcée si nécessaire
if (success && context.mounted) {
// Attendre un court instant pour que les changements d'état se propagent
await Future.delayed(
const Duration(milliseconds: 100));
// Navigation forcée vers la page d'accueil
context.go('/');
}
},
child: const Text('Déconnexion'),
),

View File

@@ -85,7 +85,6 @@ class DashboardLayout extends StatelessWidget {
appBar: DashboardAppBar(
title: title,
pageTitle: destinations[selectedIndex].label,
additionalActions: additionalActions,
showNewPassageButton: showNewPassageButton,
onNewPassagePressed: onNewPassagePressed,
isAdmin: isAdmin,

View File

@@ -1,141 +0,0 @@
# Documentation des Widgets Amicale
Cette documentation explique comment utiliser les widgets `AmicaleRowWidget` et `AmicaleTableWidget` pour afficher et gérer les données des amicales dans l'application.
## AmicaleRowWidget
Le widget `AmicaleRowWidget` représente une ligne dans un tableau d'amicales. Il affiche les informations d'une amicale avec les colonnes suivantes :
- ID
- Nom
- Code Postal
- Région
- Actions (boutons selon les droits de l'utilisateur)
### Propriétés
| Propriété | Type | Description |
| ------------- | --------------- | ---------------------------------------------------------------------------------- |
| `amicale` | `AmicaleModel` | **Obligatoire**. L'objet amicale à afficher. |
| `onEdit` | `VoidCallback?` | Fonction appelée lorsque l'utilisateur clique sur le bouton d'édition. |
| `onDelete` | `VoidCallback?` | Fonction appelée lorsque l'utilisateur clique sur le bouton de suppression. |
| `isAlternate` | `bool` | Indique si la ligne doit avoir une couleur de fond alternée. Par défaut à `false`. |
### Gestion des droits d'accès
Le widget gère automatiquement l'affichage des boutons d'action en fonction du rôle de l'utilisateur :
- Le bouton d'édition (crayon) est visible pour tous les utilisateurs avec un rôle > 1
- Le bouton de suppression (corbeille) est visible uniquement pour les utilisateurs avec un rôle > 2
### Exemple d'utilisation
```dart
AmicaleRowWidget(
amicale: amicale,
isAlternate: index % 2 == 1, // Alterner les couleurs
onEdit: () {
// Code pour gérer l'édition
},
onDelete: () {
// Code pour gérer la suppression
},
)
```
## AmicaleTableWidget
Le widget `AmicaleTableWidget` affiche un tableau complet d'amicales avec un en-tête et des lignes. Il utilise le widget `AmicaleRowWidget` pour afficher chaque ligne.
### Propriétés
| Propriété | Type | Description |
| -------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `amicales` | `List<AmicaleModel>` | **Obligatoire**. La liste des amicales à afficher. |
| `onEdit` | `Function(AmicaleModel)?` | Fonction appelée lorsque l'utilisateur clique sur le bouton d'édition d'une amicale. |
| `onDelete` | `Function(AmicaleModel)?` | Fonction appelée lorsque l'utilisateur clique sur le bouton de suppression d'une amicale. |
| `isLoading` | `bool` | Indique si les données sont en cours de chargement. Affiche un indicateur de chargement si `true`. Par défaut à `false`. |
| `emptyMessage` | `String?` | Message à afficher lorsque la liste des amicales est vide. |
### États du tableau
Le widget gère automatiquement différents états :
1. **Chargement** : Affiche un indicateur de chargement circulaire lorsque `isLoading` est `true`.
2. **Liste vide** : Affiche un message lorsque la liste des amicales est vide.
3. **Affichage normal** : Affiche la liste des amicales avec des lignes alternées.
### Exemple d'utilisation
```dart
AmicaleTableWidget(
amicales: _amicales,
isLoading: _isLoading,
onEdit: (amicale) {
// Code pour gérer l'édition de l'amicale
},
onDelete: (amicale) {
// Code pour gérer la suppression de l'amicale
},
emptyMessage: 'Aucune amicale trouvée. Veuillez en créer une nouvelle.',
)
```
## Intégration avec AmicaleRepository
Pour utiliser ces widgets avec le repository des amicales, vous devez :
1. Récupérer les amicales depuis le repository :
```dart
final amicaleRepository = Provider.of<AmicaleRepository>(context, listen: false);
final amicales = amicaleRepository.getAllAmicales();
```
2. Gérer les actions d'édition et de suppression :
```dart
void _handleEdit(AmicaleModel amicale) {
// Naviguer vers la page d'édition ou afficher une boîte de dialogue
}
Future<void> _handleDelete(AmicaleModel amicale) async {
// Afficher une confirmation puis supprimer
final amicaleRepository = Provider.of<AmicaleRepository>(context, listen: false);
await amicaleRepository.deleteAmicale(amicale.id);
// Recharger la liste
setState(() {
_amicales = amicaleRepository.getAllAmicales();
});
}
```
## Exemple complet
Un exemple complet d'utilisation est disponible dans le fichier `app/lib/presentation/widgets/examples/amicale_table_example.dart`.
## Personnalisation
### Styles
Les widgets utilisent les styles du thème de l'application pour la cohérence visuelle. Vous pouvez personnaliser l'apparence en modifiant le thème ou en surchargeant les styles dans votre implémentation.
### Colonnes et flexibilité
Les colonnes du tableau ont des valeurs de flex prédéfinies pour une mise en page optimale :
- ID : flex 1
- Nom : flex 4
- Code Postal : flex 2
- Région : flex 3
- Actions : flex 2
Vous pouvez ajuster ces valeurs en modifiant le code source si nécessaire.
## Bonnes pratiques
1. **Gestion des erreurs** : Ajoutez toujours une gestion des erreurs lors de l'interaction avec le repository.
2. **Confirmation des actions** : Demandez toujours une confirmation avant de supprimer une amicale.
3. **Actualisation des données** : Prévoyez un moyen de rafraîchir les données (bouton ou pull-to-refresh).
4. **Pagination** : Pour les grandes listes, envisagez d'implémenter une pagination.

View File

@@ -1,204 +0,0 @@
# Documentation du Widget EntiteForm
Cette documentation décrit le widget `EntiteForm` créé pour la création et la modification des entités (amicales) dans l'application GeoSector.
## Description
Le widget `EntiteForm` est un formulaire complet permettant de créer ou modifier une entité (amicale). Il gère l'affichage de tous les champs nécessaires, la validation des données et les restrictions d'accès basées sur le rôle de l'utilisateur.
## Propriétés
- `amicale` (AmicaleModel?, optionnel) : Le modèle d'amicale à modifier. Si null, le formulaire sera en mode création.
- `onSubmit` (Function(AmicaleModel)?, optionnel) : Callback appelé lorsque le formulaire est soumis avec succès.
- `readOnly` (bool, défaut: false) : Si true, tous les champs du formulaire seront en lecture seule.
## Champs du formulaire
Le formulaire inclut les champs suivants :
### Informations générales
- **Nom** : Nom de l'amicale (obligatoire)
### Adresse
- **Adresse ligne 1** : Première ligne d'adresse
- **Adresse ligne 2** : Seconde ligne d'adresse (optionnelle)
- **Code Postal** : Code postal (validation pour 5 chiffres)
- **Ville** : Nom de la ville
- **Région** : Sélection de la région via un dropdown
### Contact
- **Téléphone fixe** : Numéro de téléphone fixe (validation pour 10 chiffres)
- **Téléphone mobile** : Numéro de téléphone mobile (validation pour 10 chiffres)
- **Email** : Adresse email (obligatoire, avec validation de format)
### Informations avancées (visibles uniquement pour les administrateurs ou si déjà remplies)
- **GPS Latitude** : Coordonnée GPS latitude
- **GPS Longitude** : Coordonnée GPS longitude
- **Stripe ID** : Identifiant Stripe pour les paiements
### Options
- **Mode démo** : Indique si l'amicale est en mode démo
- **Copie des mails reçus** : Indique si l'amicale reçoit une copie des emails
- **Accepte les SMS** : Indique si l'amicale accepte les SMS
- **Actif** : Indique si l'amicale est active
## Restrictions d'accès
Certains champs sont soumis à des restrictions d'accès basées sur le rôle de l'utilisateur :
- Les champs suivants sont en lecture seule pour les utilisateurs avec un rôle ≤ 2 :
- fkRegion/libRegion
- gpsLat
- gpsLng
- stripeId
- chkDemo
- chkActive
## Exemple d'utilisation
Un exemple complet d'utilisation est disponible dans le fichier `app/lib/presentation/widgets/examples/entite_form_example.dart`.
### Utilisation simple
```dart
// Création d'une nouvelle amicale
EntiteForm(
onSubmit: (amicale) {
// Gérer la soumission
print('Nouvelle amicale: ${amicale.name}');
},
)
// Modification d'une amicale existante
EntiteForm(
amicale: amicaleExistante,
onSubmit: (amicale) {
// Gérer la soumission
print('Amicale modifiée: ${amicale.name}');
},
)
// Affichage en lecture seule
EntiteForm(
amicale: amicaleExistante,
readOnly: true,
)
```
### Utilisation avec gestion d'état
```dart
class _MyWidgetState extends State<MyWidget> {
AmicaleModel? _amicale;
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadAmicale();
}
Future<void> _loadAmicale() async {
setState(() {
_isLoading = true;
});
try {
if (widget.amicaleId != null) {
// Récupérer l'amicale depuis le repository
final amicaleRepository = Provider.of<AmicaleRepository>(context, listen: false);
final amicale = amicaleRepository.getAmicaleById(widget.amicaleId!);
setState(() {
_amicale = amicale;
_isLoading = false;
});
} else {
// Création d'une nouvelle amicale
setState(() {
_amicale = null;
_isLoading = false;
});
}
} catch (e) {
debugPrint('Erreur lors du chargement de l\'amicale: $e');
setState(() {
_isLoading = false;
});
}
}
void _handleSubmit(AmicaleModel amicale) async {
try {
final amicaleRepository = Provider.of<AmicaleRepository>(context, listen: false);
// Sauvegarder l'amicale
final savedAmicale = await amicaleRepository.saveAmicale(amicale);
// Afficher un message de confirmation
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Amicale ${savedAmicale.name} sauvegardée avec succès'),
backgroundColor: Colors.green,
),
);
} catch (e) {
debugPrint('Erreur lors de la sauvegarde de l\'amicale: $e');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur lors de la sauvegarde: $e'),
backgroundColor: Colors.red,
),
);
}
}
@override
Widget build(BuildContext context) {
return _isLoading
? const Center(child: CircularProgressIndicator())
: EntiteForm(
amicale: _amicale,
onSubmit: _handleSubmit,
);
}
}
```
## Intégration avec le système de rôles
Le widget utilise le `UserRepository` pour déterminer le rôle de l'utilisateur actuel et appliquer les restrictions d'accès en conséquence. Assurez-vous que le `UserRepository` est disponible dans l'arbre des widgets via un `Provider`.
```dart
// Dans le widget parent
return MultiProvider(
providers: [
Provider<UserRepository>(
create: (context) => userRepository,
),
Provider<AmicaleRepository>(
create: (context) => amicaleRepository,
),
],
child: MyWidget(),
);
```
## Personnalisation
Le widget utilise le thème de l'application pour le style. Vous pouvez personnaliser l'apparence en modifiant le thème ou en étendant le widget pour créer votre propre version personnalisée.
## Validation des données
Le formulaire inclut une validation pour les champs suivants :
- **Nom** : Ne peut pas être vide
- **Code Postal** : Doit contenir 5 chiffres s'il est rempli
- **Téléphone fixe** : Doit contenir 10 chiffres s'il est rempli
- **Téléphone mobile** : Doit contenir 10 chiffres s'il est rempli
- **Email** : Ne peut pas être vide et doit contenir un '@' et un '.'

View File

@@ -1,160 +0,0 @@
# Documentation du Widget EntiteForm avec RegionRepository
Cette documentation explique comment utiliser le widget `EntiteForm` avec le `RegionRepository` pour afficher et gérer les régions dans le formulaire d'entité.
## Intégration du RegionRepository
Le widget `EntiteForm` est conçu pour fonctionner avec le `RegionRepository` afin de récupérer la liste des régions disponibles pour le champ de sélection de région. Voici comment l'intégrer :
### 1. Initialisation du RegionRepository
Le `RegionRepository` doit être initialisé et fourni au widget `EntiteForm` via un `Provider`. Voici un exemple d'initialisation :
```dart
final regionRepository = RegionRepository();
await regionRepository.init();
```
### 2. Fournir le RegionRepository via Provider
Pour que le widget `EntiteForm` puisse accéder au `RegionRepository`, vous devez le fournir via un `Provider` :
```dart
MultiProvider(
providers: [
ChangeNotifierProvider<RegionRepository>.value(value: regionRepository),
// Autres providers si nécessaire
],
child: EntiteForm(
amicale: amicale,
onSubmit: handleSubmit,
readOnly: false,
),
)
```
### 3. Mise à jour des régions depuis l'API
Lorsque l'API renvoie les données des régions dans la réponse de login, vous devez les mettre à jour dans le `RegionRepository` :
```dart
// Dans le service qui gère la connexion
void handleLoginResponse(Map<String, dynamic> response) {
// Autres traitements...
// Mise à jour des régions si présentes dans la réponse
if (response.containsKey('regions') && response['regions'] is List) {
final regionRepository = Provider.of<RegionRepository>(context, listen: false);
regionRepository.updateRegionsFromApi(response['regions']);
}
}
```
## Fonctionnement avec les restrictions d'accès
Le widget `EntiteForm` gère automatiquement les restrictions d'accès basées sur le rôle de l'utilisateur :
- Pour les utilisateurs avec un rôle ≤ 2, le champ de sélection de région est en lecture seule
- Pour les utilisateurs avec un rôle > 2, le champ de sélection de région est modifiable
## Filtrage des régions selon le code postal
Le `RegionRepository` offre une méthode `getRegionByPostalCode` qui permet de filtrer les régions en fonction du code postal :
```dart
// Récupérer la région correspondant au code postal
final codePostal = '75001';
final region = regionRepository.getRegionByPostalCode(codePostal);
if (region != null) {
// Utiliser la région trouvée
print('Région trouvée : ${region.libelle}');
}
```
Cette fonctionnalité est particulièrement utile pour pré-remplir le champ de région lorsque l'utilisateur entre un code postal.
## Exemple complet d'utilisation
Un exemple complet d'utilisation est disponible dans le fichier `app/lib/presentation/widgets/examples/entite_form_with_regions_example.dart`.
### Exemple simplifié
```dart
class MyWidget extends StatefulWidget {
@override
State<MyWidget> createState() => _MyWidgetState();
}
class _MyWidgetState extends State<MyWidget> {
late RegionRepository _regionRepository;
AmicaleModel? _amicale;
bool _isLoading = true;
@override
void initState() {
super.initState();
_regionRepository = RegionRepository();
_initData();
}
Future<void> _initData() async {
setState(() {
_isLoading = true;
});
try {
// Initialiser le repository des régions
await _regionRepository.init();
// Charger l'amicale si nécessaire
// ...
setState(() {
_isLoading = false;
});
} catch (e) {
debugPrint('Erreur lors de l\'initialisation: $e');
setState(() {
_isLoading = false;
});
}
}
void _handleSubmit(AmicaleModel amicale) {
// Traiter la soumission du formulaire
}
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider<RegionRepository>.value(value: _regionRepository),
],
child: Scaffold(
appBar: AppBar(title: Text('Formulaire d\'entité')),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: EntiteForm(
amicale: _amicale,
onSubmit: _handleSubmit,
readOnly: false,
),
),
),
);
}
}
```
## Mise à jour du code postal et de la région
Pour mettre à jour automatiquement la région lorsque l'utilisateur change le code postal, vous pouvez étendre le widget `EntiteForm` ou créer un wrapper qui écoute les changements du champ de code postal et met à jour la région en conséquence.
## Remarques importantes
1. Assurez-vous que le `RegionRepository` est initialisé avant d'afficher le formulaire.
2. Le widget `EntiteForm` s'adapte automatiquement au rôle de l'utilisateur pour les restrictions d'accès.
3. Les régions sont filtrées en fonction du code postal de l'amicale pour les utilisateurs avec un rôle ≤ 2.
4. Pour les utilisateurs avec un rôle > 2, toutes les régions sont disponibles dans le dropdown.

View File

@@ -1,207 +0,0 @@
# Documentation des Widgets Membre
Cette documentation décrit les widgets créés pour afficher et gérer les données des membres dans l'application GeoSector.
## Widgets disponibles
### 1. MembreRowWidget
Widget qui représente une ligne individuelle dans un tableau de membres. Il affiche les informations d'un membre et des boutons d'action pour l'édition et la suppression.
#### Propriétés
- `membre` (MembreModel, requis) : Le modèle de membre à afficher
- `onEdit` (Function()?, optionnel) : Callback appelé lorsque le bouton d'édition est pressé
- `onDelete` (Function()?, optionnel) : Callback appelé lorsque le bouton de suppression est pressé
#### Colonnes affichées
- ID : Identifiant unique du membre
- Prénom (firstName) : Prénom du membre
- Nom (name) : Nom de famille du membre
- Secteur (sectName) : Nom du secteur auquel le membre est associé
- Rôle (fkRole) : Rôle du membre (affiché sous forme de texte : User, Admin, Super)
- Actions : Boutons d'édition et de suppression
### 2. MembreTableWidget
Widget qui affiche un tableau complet de membres avec en-tête et lignes. Il utilise le widget `MembreRowWidget` pour afficher chaque ligne.
#### Propriétés
- `membres` (List<MembreModel>, requis) : La liste des membres à afficher
- `onEdit` (Function(MembreModel)?, optionnel) : Callback appelé lorsque le bouton d'édition est pressé pour un membre
- `onDelete` (Function(MembreModel)?, optionnel) : Callback appelé lorsque le bouton de suppression est pressé pour un membre
- `showHeader` (bool, défaut: true) : Indique si l'en-tête du tableau doit être affiché
- `height` (double?, optionnel) : Hauteur du tableau (null pour prendre toute la hauteur disponible)
- `padding` (EdgeInsetsGeometry?, optionnel) : Padding du tableau
## Exemple d'utilisation
Un exemple complet d'utilisation est disponible dans le fichier `app/lib/presentation/widgets/examples/membre_table_example.dart`.
### Utilisation simple
```dart
// S'assurer que la boîte Hive est ouverte
if (!Hive.isBoxOpen(AppKeys.membresBoxName)) {
await Hive.openBox<MembreModel>(AppKeys.membresBoxName);
}
// Récupérer les membres depuis la boîte Hive
final membresBox = Hive.box<MembreModel>(AppKeys.membresBoxName);
final membres = membresBox.values.toList();
// Afficher le tableau
return MembreTableWidget(
membres: membres,
onEdit: (membre) {
// Gérer l'édition
},
onDelete: (membre) {
// Gérer la suppression
},
);
```
### Utilisation avec gestion d'état
```dart
class _MyWidgetState extends State<MyWidget> {
List<MembreModel> _membres = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadMembres();
}
Future<void> _loadMembres() async {
setState(() {
_isLoading = true;
});
try {
// S'assurer que la boîte Hive est ouverte
if (!Hive.isBoxOpen(AppKeys.membresBoxName)) {
await Hive.openBox<MembreModel>(AppKeys.membresBoxName);
}
// Récupérer les membres depuis la boîte Hive
final membresBox = Hive.box<MembreModel>(AppKeys.membresBoxName);
final membres = membresBox.values.toList();
setState(() {
_membres = membres;
_isLoading = false;
});
} catch (e) {
debugPrint('Erreur lors du chargement des membres: $e');
setState(() {
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
return _isLoading
? const Center(child: CircularProgressIndicator())
: MembreTableWidget(
membres: _membres,
onEdit: _handleEdit,
onDelete: _handleDelete,
);
}
}
```
## Gestion des événements
### Édition d'un membre
```dart
void _handleEdit(MembreModel membre) {
// Exemple de gestion de l'événement d'édition
debugPrint('Édition du membre: ${membre.firstName} ${membre.name} (ID: ${membre.id})');
// Ici, vous pourriez ouvrir une boîte de dialogue ou naviguer vers une page d'édition
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Édition de membre'),
content: Text('Vous avez demandé à éditer le membre ${membre.firstName} ${membre.name}'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Fermer'),
),
],
),
);
}
```
### Suppression d'un membre
```dart
void _handleDelete(MembreModel membre) {
// Exemple de gestion de l'événement de suppression
debugPrint('Suppression du membre: ${membre.firstName} ${membre.name} (ID: ${membre.id})');
// Demander confirmation avant de supprimer
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Confirmation de suppression'),
content: Text('Êtes-vous sûr de vouloir supprimer le membre ${membre.firstName} ${membre.name} ?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
TextButton(
onPressed: () async {
// Fermer la boîte de dialogue
Navigator.of(context).pop();
try {
// Supprimer le membre de la boîte Hive
final membresBox = Hive.box<MembreModel>(AppKeys.membresBoxName);
await membresBox.delete(membre.id);
// Mettre à jour l'état
setState(() {
_membres = _membres.where((m) => m.id != membre.id).toList();
});
// Afficher un message de confirmation
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Membre ${membre.firstName} ${membre.name} supprimé'),
backgroundColor: Colors.green,
),
);
} catch (e) {
debugPrint('Erreur lors de la suppression du membre: $e');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur lors de la suppression: $e'),
backgroundColor: Colors.red,
),
);
}
},
child: const Text('Supprimer'),
style: TextButton.styleFrom(foregroundColor: Colors.red),
),
],
),
);
}
```
## Personnalisation
Les widgets utilisent le thème de l'application pour le style. Vous pouvez personnaliser l'apparence en modifiant le thème ou en étendant les widgets pour créer vos propres versions personnalisées.

View File

@@ -1,234 +0,0 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/core/data/models/amicale_model.dart';
import 'package:geosector_app/core/repositories/amicale_repository.dart';
import 'package:geosector_app/presentation/widgets/amicale_table_widget.dart';
import 'package:provider/provider.dart';
/// Exemple d'utilisation du widget AmicaleTableWidget
///
/// Ce widget montre comment intégrer le tableau d'amicales dans une page
/// et comment gérer les actions d'édition et de suppression.
class AmicaleTableExample extends StatefulWidget {
const AmicaleTableExample({Key? key}) : super(key: key);
@override
State<AmicaleTableExample> createState() => _AmicaleTableExampleState();
}
class _AmicaleTableExampleState extends State<AmicaleTableExample> {
bool _isLoading = true;
List<AmicaleModel> _amicales = [];
String? _errorMessage;
@override
void initState() {
super.initState();
_loadAmicales();
}
Future<void> _loadAmicales() async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
// Récupérer les amicales depuis le repository
final amicaleRepository =
Provider.of<AmicaleRepository>(context, listen: false);
final amicales = amicaleRepository.getAllAmicales();
setState(() {
_amicales = amicales;
_isLoading = false;
});
} catch (e) {
setState(() {
_errorMessage = 'Erreur lors du chargement des amicales: $e';
_isLoading = false;
});
}
}
void _handleEdit(AmicaleModel amicale) {
// Afficher une boîte de dialogue de confirmation
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Modifier l\'amicale'),
content: Text('Voulez-vous modifier l\'amicale ${amicale.name} ?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
// Naviguer vers la page de modification
// Navigator.of(context).push(
// MaterialPageRoute(
// builder: (context) => EditAmicalePage(amicale: amicale),
// ),
// );
},
child: const Text('Modifier'),
),
],
),
);
}
void _handleDelete(AmicaleModel amicale) {
// Afficher une boîte de dialogue de confirmation
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Supprimer l\'amicale'),
content: Text(
'Êtes-vous sûr de vouloir supprimer l\'amicale ${amicale.name} ?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
onPressed: () {
Navigator.of(context).pop();
_deleteAmicale(amicale);
},
child: const Text('Supprimer'),
),
],
),
);
}
Future<void> _deleteAmicale(AmicaleModel amicale) async {
try {
setState(() {
_isLoading = true;
});
// Supprimer l'amicale via le repository
final amicaleRepository =
Provider.of<AmicaleRepository>(context, listen: false);
await amicaleRepository.deleteAmicale(amicale.id);
// Recharger la liste
await _loadAmicales();
// Afficher un message de succès
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Amicale ${amicale.name} supprimée avec succès'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
setState(() {
_isLoading = false;
_errorMessage = 'Erreur lors de la suppression: $e';
});
// Afficher un message d'erreur
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur: $_errorMessage'),
backgroundColor: Colors.red,
),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Liste des amicales'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _loadAmicales,
tooltip: 'Actualiser',
),
],
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Titre et description
Text(
'Gestion des amicales',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
'Consultez, modifiez ou supprimez les amicales selon vos droits d\'accès.',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 24),
// Message d'erreur si présent
if (_errorMessage != null)
Container(
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.red.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.red.withOpacity(0.3)),
),
child: Row(
children: [
const Icon(Icons.error_outline, color: Colors.red),
const SizedBox(width: 12),
Expanded(
child: Text(
_errorMessage!,
style: const TextStyle(color: Colors.red),
),
),
],
),
),
// Tableau des amicales
Expanded(
child: AmicaleTableWidget(
amicales: _amicales,
isLoading: _isLoading,
onDelete: _handleDelete,
emptyMessage:
'Aucune amicale trouvée. Veuillez en créer une nouvelle.',
readOnly: false, // Permettre la modification dans la modale
),
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
// Naviguer vers la page de création
// Navigator.of(context).push(
// MaterialPageRoute(
// builder: (context) => CreateAmicalePage(),
// ),
// );
},
tooltip: 'Ajouter une amicale',
child: const Icon(Icons.add),
),
);
}
}

View File

@@ -1,195 +0,0 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/core/data/models/amicale_model.dart';
import 'package:geosector_app/core/repositories/amicale_repository.dart';
import 'package:geosector_app/core/repositories/user_repository.dart';
import 'package:geosector_app/presentation/widgets/entite_form.dart';
import 'package:provider/provider.dart';
/// Exemple d'utilisation du widget EntiteForm
///
/// Ce widget montre comment intégrer le formulaire d'entité dans une page
/// et comment gérer les événements de soumission.
class EntiteFormExample extends StatefulWidget {
final int? amicaleId;
final bool readOnly;
const EntiteFormExample({
Key? key,
this.amicaleId,
this.readOnly = false,
}) : super(key: key);
@override
State<EntiteFormExample> createState() => _EntiteFormExampleState();
}
class _EntiteFormExampleState extends State<EntiteFormExample> {
AmicaleModel? _amicale;
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadAmicale();
}
Future<void> _loadAmicale() async {
setState(() {
_isLoading = true;
});
try {
if (widget.amicaleId != null) {
// Récupérer l'amicale depuis le repository
final amicaleRepository =
Provider.of<AmicaleRepository>(context, listen: false);
final amicale = amicaleRepository.getAmicaleById(widget.amicaleId!);
setState(() {
_amicale = amicale;
_isLoading = false;
});
} else {
// Création d'une nouvelle amicale
setState(() {
_amicale = null;
_isLoading = false;
});
}
} catch (e) {
debugPrint('Erreur lors du chargement de l\'amicale: $e');
setState(() {
_isLoading = false;
});
}
}
void _handleSubmit(AmicaleModel amicale) async {
try {
final amicaleRepository =
Provider.of<AmicaleRepository>(context, listen: false);
// Sauvegarder l'amicale
final savedAmicale = await amicaleRepository.saveAmicale(amicale);
// Afficher un message de confirmation
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content:
Text('Amicale ${savedAmicale.name} sauvegardée avec succès'),
backgroundColor: Colors.green,
),
);
// Retourner à la page précédente
Navigator.of(context).pop();
}
} catch (e) {
debugPrint('Erreur lors de la sauvegarde de l\'amicale: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur lors de la sauvegarde: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
@override
Widget build(BuildContext context) {
final userRepository = Provider.of<UserRepository>(context, listen: false);
final userRole = userRepository.getUserRole();
final bool canCreate = userRole >
1; // Seuls les utilisateurs avec rôle > 1 peuvent créer/modifier
return Scaffold(
appBar: AppBar(
title: Text(widget.amicaleId != null
? (widget.readOnly
? 'Détails de l\'amicale'
: 'Modifier l\'amicale')
: 'Nouvelle amicale'),
actions: [
if (!widget.readOnly && _amicale != null)
IconButton(
icon: const Icon(Icons.delete),
onPressed: () => _showDeleteConfirmation(context),
tooltip: 'Supprimer',
),
],
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: !canCreate && _amicale == null
? const Center(
child: Text(
'Vous n\'avez pas les droits pour créer une amicale'),
)
: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: EntiteForm(
amicale: _amicale,
onSubmit: widget.readOnly ? null : _handleSubmit,
readOnly: widget.readOnly,
),
),
);
}
void _showDeleteConfirmation(BuildContext context) {
if (_amicale == null) return;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Confirmation de suppression'),
content: Text(
'Êtes-vous sûr de vouloir supprimer l\'amicale ${_amicale!.name} ?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
TextButton(
onPressed: () async {
Navigator.of(context).pop();
try {
final amicaleRepository =
Provider.of<AmicaleRepository>(context, listen: false);
await amicaleRepository.deleteAmicale(_amicale!.id);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Amicale ${_amicale!.name} supprimée'),
backgroundColor: Colors.green,
),
);
// Retourner à la page précédente
Navigator.of(context).pop();
}
} catch (e) {
debugPrint('Erreur lors de la suppression de l\'amicale: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur lors de la suppression: $e'),
backgroundColor: Colors.red,
),
);
}
}
},
child: const Text('Supprimer'),
style: TextButton.styleFrom(foregroundColor: Colors.red),
),
],
),
);
}
}

View File

@@ -1,136 +0,0 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/core/data/models/amicale_model.dart';
import 'package:geosector_app/core/repositories/amicale_repository.dart';
import 'package:geosector_app/core/repositories/region_repository.dart';
import 'package:geosector_app/core/repositories/user_repository.dart';
import 'package:geosector_app/presentation/widgets/entite_form.dart';
import 'package:provider/provider.dart';
/// Exemple d'utilisation du widget EntiteForm avec le RegionRepository
///
/// Ce widget montre comment intégrer le formulaire d'entité dans une page
/// et comment utiliser le RegionRepository pour charger les régions.
class EntiteFormWithRegionsExample extends StatefulWidget {
final int? amicaleId;
final bool readOnly;
const EntiteFormWithRegionsExample({
Key? key,
this.amicaleId,
this.readOnly = false,
}) : super(key: key);
@override
State<EntiteFormWithRegionsExample> createState() =>
_EntiteFormWithRegionsExampleState();
}
class _EntiteFormWithRegionsExampleState
extends State<EntiteFormWithRegionsExample> {
AmicaleModel? _amicale;
bool _isLoading = true;
late RegionRepository _regionRepository;
@override
void initState() {
super.initState();
_regionRepository = RegionRepository();
_initRepositories();
}
Future<void> _initRepositories() async {
setState(() {
_isLoading = true;
});
try {
// Initialiser le repository des régions
await _regionRepository.init();
// Charger l'amicale si un ID est fourni
if (widget.amicaleId != null) {
final amicaleRepository =
Provider.of<AmicaleRepository>(context, listen: false);
final amicale = amicaleRepository.getAmicaleById(widget.amicaleId!);
setState(() {
_amicale = amicale;
_isLoading = false;
});
} else {
setState(() {
_isLoading = false;
});
}
} catch (e) {
debugPrint('Erreur lors de l\'initialisation: $e');
setState(() {
_isLoading = false;
});
}
}
void _handleSubmit(AmicaleModel amicale) async {
try {
final amicaleRepository =
Provider.of<AmicaleRepository>(context, listen: false);
// Sauvegarder l'amicale
final savedAmicale = await amicaleRepository.saveAmicale(amicale);
// Afficher un message de confirmation
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content:
Text('Amicale ${savedAmicale.name} sauvegardée avec succès'),
backgroundColor: Colors.green,
),
);
// Retourner à la page précédente
Navigator.of(context).pop();
}
} catch (e) {
debugPrint('Erreur lors de la sauvegarde de l\'amicale: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur lors de la sauvegarde: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
// Fournir le RegionRepository pour qu'il soit accessible par le widget EntiteForm
ChangeNotifierProvider<RegionRepository>.value(
value: _regionRepository),
],
child: Scaffold(
appBar: AppBar(
title: Text(widget.amicaleId != null
? (widget.readOnly
? 'Détails de l\'amicale'
: 'Modifier l\'amicale')
: 'Nouvelle amicale'),
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: EntiteForm(
amicale: _amicale,
onSubmit: widget.readOnly ? null : _handleSubmit,
readOnly: widget.readOnly,
),
),
),
);
}
}

View File

@@ -1,167 +0,0 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/data/models/membre_model.dart';
import 'package:geosector_app/presentation/widgets/membre_table_widget.dart';
import 'package:hive_flutter/hive_flutter.dart';
/// Exemple d'utilisation du widget MembreTableWidget
///
/// Ce widget montre comment intégrer le tableau de membres dans une page
/// et comment gérer les événements d'édition et de suppression.
class MembreTableExample extends StatefulWidget {
const MembreTableExample({Key? key}) : super(key: key);
@override
State<MembreTableExample> createState() => _MembreTableExampleState();
}
class _MembreTableExampleState extends State<MembreTableExample> {
List<MembreModel> _membres = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadMembres();
}
Future<void> _loadMembres() async {
setState(() {
_isLoading = true;
});
try {
// S'assurer que la boîte Hive est ouverte
if (!Hive.isBoxOpen(AppKeys.membresBoxName)) {
await Hive.openBox<MembreModel>(AppKeys.membresBoxName);
}
// Récupérer les membres depuis la boîte Hive
final membresBox = Hive.box<MembreModel>(AppKeys.membresBoxName);
final membres = membresBox.values.toList();
setState(() {
_membres = membres;
_isLoading = false;
});
} catch (e) {
debugPrint('Erreur lors du chargement des membres: $e');
setState(() {
_isLoading = false;
});
}
}
void _handleEdit(MembreModel membre) {
// Exemple de gestion de l'événement d'édition
debugPrint(
'Édition du membre: ${membre.firstName} ${membre.name} (ID: ${membre.id})');
// Ici, vous pourriez ouvrir une boîte de dialogue ou naviguer vers une page d'édition
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Édition de membre'),
content: Text(
'Vous avez demandé à éditer le membre ${membre.firstName} ${membre.name}'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Fermer'),
),
],
),
);
}
void _handleDelete(MembreModel membre) {
// Exemple de gestion de l'événement de suppression
debugPrint(
'Suppression du membre: ${membre.firstName} ${membre.name} (ID: ${membre.id})');
// Demander confirmation avant de supprimer
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Confirmation de suppression'),
content: Text(
'Êtes-vous sûr de vouloir supprimer le membre ${membre.firstName} ${membre.name} ?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
TextButton(
onPressed: () async {
// Fermer la boîte de dialogue
Navigator.of(context).pop();
try {
// Supprimer le membre de la boîte Hive
final membresBox =
Hive.box<MembreModel>(AppKeys.membresBoxName);
await membresBox.delete(membre.id);
// Mettre à jour l'état
setState(() {
_membres = _membres.where((m) => m.id != membre.id).toList();
});
// Afficher un message de confirmation
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Membre ${membre.firstName} ${membre.name} supprimé'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
debugPrint('Erreur lors de la suppression du membre: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur lors de la suppression: $e'),
backgroundColor: Colors.red,
),
);
}
}
},
child: const Text('Supprimer'),
style: TextButton.styleFrom(foregroundColor: Colors.red),
),
],
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Tableau des Membres'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _loadMembres,
tooltip: 'Rafraîchir',
),
],
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: _isLoading
? const Center(child: CircularProgressIndicator())
: MembreTableWidget(
membres: _membres,
onEdit: _handleEdit,
onDelete: _handleDelete,
height:
null, // Utiliser null pour que le widget prenne toute la hauteur disponible
),
),
);
}
}

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'custom_text_field.dart';
import '../custom_text_field.dart';
class PassageForm extends StatefulWidget {
final Function(Map<String, dynamic>)? onSubmit;

View File

@@ -1,12 +1,9 @@
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.
@@ -125,7 +122,6 @@ class _ResponsiveNavigationState extends State<ResponsiveNavigation> {
appBar: widget.showAppBar
? AppBar(
title: Text(widget.title),
actions: _buildAppBarActions(context),
)
: null,
body: isDesktop ? _buildDesktopLayout() : _buildMobileLayout(),
@@ -157,38 +153,6 @@ class _ResponsiveNavigationState extends State<ResponsiveNavigation> {
);
}
/// 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);
@@ -350,103 +314,15 @@ class _ResponsiveNavigationState extends State<ResponsiveNavigation> {
const Spacer(),
const Divider(),
// Éléments du bas de la sidebar
// Éléments du bas de la sidebar (widgets personnalisés)
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'utilisateur actuel
// Utiliser l'instance globale définie dans app.dart
final user = userRepository.currentUser;
if (user != null) {
// Passer l'objet utilisateur complet
ProfileDialog.show(context, user);
} 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,
),
);
}
},
),
// Option "Amicale & membres" - uniquement pour les administrateurs avec le rôle 2 et en version web
if (widget.isAdmin &&
userRepository.currentUser?.role == 2 &&
MediaQuery.of(context).size.width > 900)
_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);
// Notifier l'utilisateur que la page est en cours de chargement
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content:
Text('Chargement de la page Amicale & membres...'),
duration: Duration(seconds: 2),
),
);
// Attendre un court instant pour permettre à la navigation de se terminer
Future.delayed(const Duration(milliseconds: 300), () {
// Forcer la sélection de l'onglet Amicale & membres
if (widget.isAdmin && widget.selectedIndex != 5) {
widget.onDestinationSelected(5);
}
});
},
),
// Option "Opérations" - uniquement pour les administrateurs et en version web
// Options administrateur - uniquement pour les administrateurs et en version web
if (widget.isAdmin && MediaQuery.of(context).size.width > 900)
_SettingsItem(
icon: Icons.calendar_today,
title: 'Opérations',
isSidebarMinimized: _isSidebarMinimized,
onTap: () {
// Navigation vers le tableau de bord admin
context.go('/admin');
...[],
// Note: Pas de page spécifique pour le moment, juste un placeholder
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Fonctionnalité à venir'),
duration: Duration(seconds: 2),
),
);
},
),
const SizedBox(height: 16),
// Option "Aide" - toujours visible en bas
_SettingsItem(
icon: Icons.help_outline,
title: 'Aide',
@@ -456,6 +332,8 @@ class _ResponsiveNavigationState extends State<ResponsiveNavigation> {
HelpDialog.show(context, widget.title);
},
),
const SizedBox(height: 16),
],
),
),
@@ -538,119 +416,6 @@ class _ResponsiveNavigationState extends State<ResponsiveNavigation> {
);
}
}
/// 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

View File

@@ -3,113 +3,25 @@ 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';
import 'package:geosector_app/core/theme/app_theme.dart';
class SectorDistributionCard extends StatefulWidget {
class SectorDistributionCard extends StatelessWidget {
final String title;
final double? height;
final EdgeInsetsGeometry? padding;
final bool forceRefresh;
const SectorDistributionCard({
Key? key,
this.title = 'Répartition par secteur',
this.height,
this.padding,
this.forceRefresh = false,
}) : 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();
}
@override
void didUpdateWidget(SectorDistributionCard oldWidget) {
super.didUpdateWidget(oldWidget);
// Recharger les données si forceRefresh est passé à true
if (widget.forceRefresh && !oldWidget.forceRefresh) {
_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),
height: height,
padding: padding ?? const EdgeInsets.all(AppTheme.spacingM),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
@@ -118,65 +30,117 @@ class _SectorDistributionCardState extends State<SectorDistributionCard> {
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(),
),
],
Text(
title,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
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']),
);
},
),
child: _buildAutoRefreshContent(),
),
],
),
);
}
Widget _buildAutoRefreshContent() {
// Écouter les changements des deux boîtes
return ValueListenableBuilder(
valueListenable: Hive.box<SectorModel>(AppKeys.sectorsBoxName).listenable(),
builder: (context, Box<SectorModel> sectorsBox, child) {
return ValueListenableBuilder(
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
builder: (context, Box<PassageModel> passagesBox, child) {
return _buildContent(sectorsBox, passagesBox);
},
);
},
);
}
Widget _buildContent(Box<SectorModel> sectorsBox, Box<PassageModel> passagesBox) {
try {
// Calculer les statistiques
final sectorStats = _calculateSectorStats(sectorsBox, passagesBox);
if (sectorStats.isEmpty) {
return const Center(
child: Text('Aucune donnée de secteur disponible'),
);
}
return ListView.builder(
itemCount: sectorStats.length,
itemBuilder: (context, index) {
final sector = sectorStats[index];
return _buildSectorItem(
sector['name'],
sector['count'],
Color(sector['color']),
sectorStats,
);
},
);
} catch (e) {
debugPrint('Erreur lors du calcul des statistiques: $e');
return Center(
child: Text('Erreur: ${e.toString()}'),
);
}
}
List<Map<String, dynamic>> _calculateSectorStats(
Box<SectorModel> sectorsBox,
Box<PassageModel> passagesBox,
) {
// Récupérer tous les secteurs et passages
final List<SectorModel> sectors = sectorsBox.values.toList();
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,
});
}
}
// Trier par nombre de passages (décroissant)
stats.sort((a, b) => (b['count'] as int).compareTo(a['count'] as int));
return stats;
}
Widget _buildSectorItem(
BuildContext context,
String name,
int count,
Color color,
List<Map<String, dynamic>> allStats,
) {
final totalCount =
sectorStats.fold(0, (sum, item) => sum + (item['count'] as int));
allStats.fold(0, (sum, item) => sum + (item['count'] as int));
final percentage = totalCount > 0 ? (count / totalCount) * 100 : 0;
return Padding(
@@ -215,4 +179,4 @@ class _SectorDistributionCardState extends State<SectorDistributionCard> {
),
);
}
}
}