Restructuration majeure du projet: migration de flutt vers app, ajout de l'API et mise à jour du site web

This commit is contained in:
d6soft
2025-05-16 09:19:03 +02:00
parent b5aafc424b
commit 5c2620de30
391 changed files with 19780 additions and 7233 deletions

View File

@@ -0,0 +1,167 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/app.dart';
import 'package:geosector_app/core/data/models/amicale_model.dart';
/// Widget pour afficher une ligne du tableau d'amicales
/// Affiche les colonnes id, name, codePostal, libRegion et une colonne Actions
/// La colonne Actions contient un bouton Delete pour les utilisateurs avec rôle > 2
/// La ligne entière est cliquable pour afficher les détails de l'amicale
class AmicaleRowWidget extends StatelessWidget {
final AmicaleModel amicale;
final Function(AmicaleModel)? onTap;
final Function(AmicaleModel)? onDelete;
final bool isHeader;
final bool isAlternate;
const AmicaleRowWidget({
Key? key,
required this.amicale,
this.onTap,
this.onDelete,
this.isHeader = false,
this.isAlternate = false,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final userRole = userRepository.getUserRole();
// Définir les styles en fonction du type de ligne (en-tête ou données)
final textStyle = isHeader
? theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
)
: theme.textTheme.bodyMedium;
// Couleur de fond en fonction du type de ligne
final backgroundColor = isHeader
? theme.colorScheme.primary.withOpacity(0.1)
: (isAlternate
? theme.colorScheme.surface
: theme.colorScheme.background);
return InkWell(
onTap: isHeader || onTap == null ? null : () => onTap!(amicale),
child: Container(
decoration: BoxDecoration(
color: backgroundColor,
border: Border(
bottom: BorderSide(
color: theme.dividerColor.withOpacity(0.3),
width: 1,
),
),
),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
children: [
// Colonne ID
Expanded(
flex: 1,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Text(
isHeader ? 'ID' : amicale.id.toString(),
style: textStyle,
overflow: TextOverflow.ellipsis,
),
),
),
// Colonne Nom
Expanded(
flex: 4,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Text(
isHeader ? 'Nom' : amicale.name,
style: textStyle,
overflow: TextOverflow.ellipsis,
),
),
),
// Colonne Code Postal
Expanded(
flex: 2,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Text(
isHeader ? 'Code Postal' : amicale.codePostal,
style: textStyle,
overflow: TextOverflow.ellipsis,
),
),
),
// Colonne Ville
Expanded(
flex: 2,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Text(
isHeader ? 'Ville' : (amicale.ville ?? ''),
style: textStyle,
overflow: TextOverflow.ellipsis,
),
),
),
// Colonne Région
Expanded(
flex: 3,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Text(
isHeader ? 'Région' : (amicale.libRegion ?? ''),
style: textStyle,
overflow: TextOverflow.ellipsis,
),
),
),
// Colonne Actions - seulement si l'utilisateur a le rôle > 2 et onDelete n'est pas null
if (isHeader || (userRole > 2 && onDelete != null))
Expanded(
flex: 2,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: isHeader
? Text(
'Actions',
style: textStyle,
overflow: TextOverflow.ellipsis,
)
: Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
// Bouton Delete
IconButton(
icon: Icon(
Icons.delete,
color: theme.colorScheme.error,
size: 20,
),
tooltip: 'Supprimer',
onPressed: () => onDelete!(amicale),
constraints: const BoxConstraints(
minWidth: 36,
minHeight: 36,
),
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
),
],
),
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,184 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/app.dart';
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:provider/provider.dart';
/// Widget de tableau pour afficher une liste d'amicales
///
/// Ce widget affiche un tableau avec les colonnes :
/// - ID
/// - Nom
/// - Code Postal
/// - Région
/// - Actions (boutons selon les droits de l'utilisateur)
///
/// Lorsqu'on clique sur une ligne, une modale s'affiche avec le formulaire EntiteForm
class AmicaleTableWidget extends StatelessWidget {
final List<AmicaleModel> amicales;
final Function(AmicaleModel)? onDelete;
final bool isLoading;
final String? emptyMessage;
final bool readOnly;
const AmicaleTableWidget({
Key? key,
required this.amicales,
this.onDelete,
this.isLoading = false,
this.emptyMessage,
this.readOnly = false,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// En-tête du tableau - utiliser AmicaleRowWidget pour l'en-tête
AmicaleRowWidget(
amicale: AmicaleModel(
id: 0,
name: '',
codePostal: '',
ville: '',
libRegion: '',
),
isHeader: true,
onTap: null,
onDelete: null,
),
// Corps du tableau
Container(
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(8),
bottomRight: Radius.circular(8),
),
border: Border.all(
color: theme.colorScheme.primary.withOpacity(0.1),
width: 1,
),
),
child: _buildTableContent(context),
),
],
);
}
Widget _buildTableContent(BuildContext context) {
// Afficher un indicateur de chargement si isLoading est true
if (isLoading) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 32.0),
child: Center(child: CircularProgressIndicator()),
);
}
// Afficher un message si la liste est vide
if (amicales.isEmpty) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 32.0),
child: Center(
child: Text(
emptyMessage ?? 'Aucune amicale trouvée',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color:
Theme.of(context).colorScheme.onSurface.withOpacity(0.6),
),
),
),
);
}
// Afficher la liste des amicales
return ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: amicales.length,
itemBuilder: (context, index) {
final amicale = amicales[index];
return AmicaleRowWidget(
amicale: amicale,
isAlternate: index % 2 == 1, // Alterner les couleurs
onTap: (selectedAmicale) =>
_showAmicaleDetails(context, selectedAmicale),
onDelete: onDelete,
);
},
);
}
// Afficher une modale avec le formulaire EntiteForm
void _showAmicaleDetails(BuildContext context, AmicaleModel amicale) {
// Utiliser l'instance globale de userRepository définie dans app.dart
final userRepo = userRepository;
// Créer une instance de RegionRepository
final regionRepo = RegionRepository();
showDialog(
context: context,
builder: (dialogContext) => MultiProvider(
providers: [
// Fournir les repositories nécessaires au formulaire
Provider<UserRepository>.value(value: userRepo),
Provider<RegionRepository>.value(value: regionRepo),
],
child: Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Container(
width: MediaQuery.of(dialogContext).size.width * 0.6,
padding: const EdgeInsets.all(24),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Détails de l\'amicale',
style: Theme.of(dialogContext)
.textTheme
.headlineSmall
?.copyWith(
fontWeight: FontWeight.bold,
color:
Theme.of(dialogContext).colorScheme.primary,
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(dialogContext).pop(),
),
],
),
const SizedBox(height: 16),
// Formulaire EntiteForm en mode lecture seule
EntiteForm(
amicale: amicale,
readOnly: readOnly,
onSubmit: (updatedAmicale) {
Navigator.of(dialogContext).pop();
// Ici, vous pourriez ajouter une logique pour mettre à jour l'amicale
},
),
],
),
),
),
),
),
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,153 @@
import 'package:flutter/material.dart';
/// Widget de dialogue pour informer l'utilisateur qu'il doit vider le cache de son navigateur
/// et recharger l'application en raison d'une incompatibilité après une mise à jour.
class ClearCacheDialog extends StatelessWidget {
/// Callback appelé lorsque l'utilisateur ferme le dialogue
final VoidCallback? onClose;
const ClearCacheDialog({
Key? key,
this.onClose,
}) : super(key: key);
/// Affiche le dialogue de nettoyage du cache
static Future<void> show(BuildContext context,
{VoidCallback? onClose}) async {
return showDialog<void>(
context: context,
barrierDismissible:
false, // L'utilisateur doit appuyer sur un bouton pour fermer le dialogue
builder: (BuildContext dialogContext) {
return ClearCacheDialog(onClose: onClose);
},
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
title: Row(
children: [
Icon(
Icons.warning_amber_rounded,
color: Colors.orange,
size: 28,
),
const SizedBox(width: 12),
Text(
'Mise à jour requise',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.onSurface,
),
),
],
),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Une incompatibilité a été détectée avec les données stockées localement. Veuillez suivre ces étapes pour résoudre le problème :',
style: theme.textTheme.bodyLarge,
),
const SizedBox(height: 16),
_buildInstructionStep(
context,
1,
'Videz le cache de votre navigateur',
'Dans Chrome : Menu > Plus d\'outils > Effacer les données de navigation > Sélectionnez "Cookies et données de site" > Effacer les données'),
const SizedBox(height: 12),
_buildInstructionStep(
context,
2,
'Fermez complètement le navigateur',
'Assurez-vous de fermer toutes les fenêtres du navigateur'),
const SizedBox(height: 12),
_buildInstructionStep(context, 3, 'Rouvrez l\'application',
'Reconnectez-vous à l\'application pour récupérer vos données depuis le serveur'),
const SizedBox(height: 16),
Text(
'Note : Cette opération est nécessaire en raison d\'une mise à jour de la structure des données. Toutes vos données seront récupérées depuis le serveur après reconnexion.',
style: theme.textTheme.bodySmall?.copyWith(
fontStyle: FontStyle.italic,
color: theme.colorScheme.onSurface.withOpacity(0.7),
),
),
],
),
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
if (onClose != null) {
onClose!();
}
},
style: TextButton.styleFrom(
foregroundColor: theme.colorScheme.primary,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
child: const Text('J\'ai compris'),
),
],
);
}
/// Construit une étape d'instruction avec un numéro, un titre et une description
Widget _buildInstructionStep(
BuildContext context, int stepNumber, String title, String description) {
final theme = Theme.of(context);
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: theme.colorScheme.primary,
shape: BoxShape.circle,
),
child: Center(
child: Text(
'$stepNumber',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
description,
style: theme.textTheme.bodyMedium,
),
],
),
),
],
);
}
}

View File

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

View File

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

View File

@@ -0,0 +1,159 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class CustomTextField extends StatelessWidget {
final TextEditingController controller;
final String label;
final String? hintText;
final IconData? prefixIcon;
final Widget? suffixIcon;
final bool obscureText;
final TextInputType keyboardType;
final String? Function(String?)? validator;
final List<TextInputFormatter>? inputFormatters;
final int? maxLines;
final int? minLines;
final bool readOnly;
final VoidCallback? onTap;
final Function(String)? onChanged;
final bool autofocus;
final FocusNode? focusNode;
final String? errorText;
final Color? fillColor;
final String? helperText;
final Function(String)? onFieldSubmitted;
const CustomTextField({
super.key,
required this.controller,
required this.label,
this.hintText,
this.prefixIcon,
this.suffixIcon,
this.obscureText = false,
this.keyboardType = TextInputType.text,
this.validator,
this.inputFormatters,
this.maxLines = 1,
this.minLines,
this.readOnly = false,
this.onTap,
this.onChanged,
this.autofocus = false,
this.focusNode,
this.errorText,
this.fillColor,
this.helperText,
this.onFieldSubmitted,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (label.isNotEmpty) ...[
Text(
label,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w500,
color: theme.colorScheme.onBackground,
),
),
const SizedBox(height: 8),
],
// 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(
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,
),
),
),
),
],
);
}
}

View File

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

View File

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

View File

@@ -0,0 +1,141 @@
# 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

@@ -0,0 +1,204 @@
# 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

@@ -0,0 +1,160 @@
# 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

@@ -0,0 +1,207 @@
# 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

@@ -0,0 +1,644 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.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:provider/provider.dart';
import 'custom_text_field.dart';
class EntiteForm extends StatefulWidget {
final AmicaleModel? amicale;
final Function(AmicaleModel)? onSubmit;
final bool readOnly;
const EntiteForm({
Key? key,
this.amicale,
this.onSubmit,
this.readOnly = false,
}) : super(key: key);
@override
State<EntiteForm> createState() => _EntiteFormState();
}
class _EntiteFormState extends State<EntiteForm> {
final _formKey = GlobalKey<FormState>();
// Controllers
late final TextEditingController _nameController;
late final TextEditingController _adresse1Controller;
late final TextEditingController _adresse2Controller;
late final TextEditingController _codePostalController;
late final TextEditingController _villeController;
late final TextEditingController _phoneController;
late final TextEditingController _mobileController;
late final TextEditingController _emailController;
late final TextEditingController _gpsLatController;
late final TextEditingController _gpsLngController;
late final TextEditingController _stripeIdController;
// Form values
int? _fkRegion;
String? _libRegion;
bool _chkDemo = false;
bool _chkCopieMailRecu = false;
bool _chkAcceptSms = false;
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();
// Initialize controllers with amicale data if available
final amicale = widget.amicale;
_nameController = TextEditingController(text: amicale?.name ?? '');
_adresse1Controller = TextEditingController(text: amicale?.adresse1 ?? '');
_adresse2Controller = TextEditingController(text: amicale?.adresse2 ?? '');
_codePostalController =
TextEditingController(text: amicale?.codePostal ?? '');
_villeController = TextEditingController(text: amicale?.ville ?? '');
_phoneController = TextEditingController(text: amicale?.phone ?? '');
_mobileController = TextEditingController(text: amicale?.mobile ?? '');
_emailController = TextEditingController(text: amicale?.email ?? '');
_gpsLatController = TextEditingController(text: amicale?.gpsLat ?? '');
_gpsLngController = TextEditingController(text: amicale?.gpsLng ?? '');
_stripeIdController = TextEditingController(text: amicale?.stripeId ?? '');
_fkRegion = amicale?.fkRegion;
_libRegion = amicale?.libRegion;
_chkDemo = amicale?.chkDemo ?? false;
_chkCopieMailRecu = amicale?.chkCopieMailRecu ?? false;
_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
void dispose() {
_nameController.dispose();
_adresse1Controller.dispose();
_adresse2Controller.dispose();
_codePostalController.dispose();
_villeController.dispose();
_phoneController.dispose();
_mobileController.dispose();
_emailController.dispose();
_gpsLatController.dispose();
_gpsLngController.dispose();
_stripeIdController.dispose();
super.dispose();
}
void _submitForm() {
if (_formKey.currentState!.validate()) {
final amicale = widget.amicale?.copyWith(
name: _nameController.text,
adresse1: _adresse1Controller.text,
adresse2: _adresse2Controller.text,
codePostal: _codePostalController.text,
ville: _villeController.text,
fkRegion: _fkRegion,
libRegion: _libRegion,
phone: _phoneController.text,
mobile: _mobileController.text,
email: _emailController.text,
gpsLat: _gpsLatController.text,
gpsLng: _gpsLngController.text,
stripeId: _stripeIdController.text,
chkDemo: _chkDemo,
chkCopieMailRecu: _chkCopieMailRecu,
chkAcceptSms: _chkAcceptSms,
chkActive: _chkActive,
chkStripe: _chkStripe,
) ??
AmicaleModel(
id: 0, // Sera remplacé par l'API
name: _nameController.text,
adresse1: _adresse1Controller.text,
adresse2: _adresse2Controller.text,
codePostal: _codePostalController.text,
ville: _villeController.text,
fkRegion: _fkRegion,
libRegion: _libRegion,
phone: _phoneController.text,
mobile: _mobileController.text,
email: _emailController.text,
gpsLat: _gpsLatController.text,
gpsLng: _gpsLngController.text,
stripeId: _stripeIdController.text,
chkDemo: _chkDemo,
chkCopieMailRecu: _chkCopieMailRecu,
chkAcceptSms: _chkAcceptSms,
chkActive: _chkActive,
);
if (widget.onSubmit != null) {
widget.onSubmit!(amicale);
}
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final userRepository = Provider.of<UserRepository>(context, listen: false);
final userRole = userRepository.getUserRole();
// Déterminer si l'utilisateur peut modifier les champs restreints
final bool canEditRestrictedFields = userRole > 2;
// Lecture seule pour les champs restreints si l'utilisateur n'a pas les droits
final bool restrictedFieldsReadOnly =
widget.readOnly || !canEditRestrictedFields;
// Calculer la largeur maximale du formulaire pour les écrans larges
final screenWidth = MediaQuery.of(context).size.width;
final formMaxWidth = screenWidth > 800 ? 600.0 : screenWidth;
return Form(
key: _formKey,
child: Container(
constraints: BoxConstraints(maxWidth: formMaxWidth),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Nom
CustomTextField(
controller: _nameController,
label: "Nom",
readOnly: widget.readOnly,
validator: (value) {
if (value == null || value.isEmpty) {
return "Veuillez entrer un nom";
}
return null;
},
),
const SizedBox(height: 16),
// Bloc Adresse
Text(
"Adresse",
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.onBackground,
),
),
const SizedBox(height: 8),
// Adresse 1
CustomTextField(
controller: _adresse1Controller,
label: "Adresse ligne 1",
readOnly: widget.readOnly,
),
const SizedBox(height: 16),
// Adresse 2
CustomTextField(
controller: _adresse2Controller,
label: "Adresse ligne 2",
readOnly: widget.readOnly,
),
const SizedBox(height: 16),
// Code Postal et Ville
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Code Postal
Expanded(
flex: 1,
child: CustomTextField(
controller: _codePostalController,
label: "Code Postal",
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(5),
],
readOnly: widget.readOnly,
validator: (value) {
if (value != null &&
value.isNotEmpty &&
value.length < 5) {
return "Le code postal doit contenir 5 chiffres";
}
return null;
},
),
),
const SizedBox(width: 16),
// Ville
Expanded(
flex: 2,
child: CustomTextField(
controller: _villeController,
label: "Ville",
readOnly: widget.readOnly,
),
),
],
),
const SizedBox(height: 16),
// Région
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Région",
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w500,
color: theme.colorScheme.onBackground,
),
),
const SizedBox(height: 8),
_buildRegionDropdown(restrictedFieldsReadOnly),
],
),
const SizedBox(height: 16),
// Contact
Text(
"Contact",
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.onBackground,
),
),
const SizedBox(height: 8),
// Téléphone fixe et mobile sur la même ligne
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Téléphone fixe
Expanded(
child: CustomTextField(
controller: _phoneController,
label: "Téléphone fixe",
keyboardType: TextInputType.phone,
readOnly: widget.readOnly,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
],
validator: (value) {
if (value != null &&
value.isNotEmpty &&
value.length < 10) {
return "Le numéro de téléphone doit contenir 10 chiffres";
}
return null;
},
),
),
const SizedBox(width: 16),
// Téléphone mobile
Expanded(
child: CustomTextField(
controller: _mobileController,
label: "Téléphone mobile",
keyboardType: TextInputType.phone,
readOnly: widget.readOnly,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
],
validator: (value) {
if (value != null &&
value.isNotEmpty &&
value.length < 10) {
return "Le numéro de mobile doit contenir 10 chiffres";
}
return null;
},
),
),
],
),
const SizedBox(height: 16),
// Email
CustomTextField(
controller: _emailController,
label: "Email",
keyboardType: TextInputType.emailAddress,
readOnly: widget.readOnly,
validator: (value) {
if (value == null || value.isEmpty) {
return "Veuillez entrer l'adresse email";
}
if (!value.contains('@') || !value.contains('.')) {
return "Veuillez entrer une adresse email valide";
}
return null;
},
),
const SizedBox(height: 16),
// Informations avancées (visibles uniquement pour les administrateurs)
if (canEditRestrictedFields ||
(_gpsLatController.text.isNotEmpty ||
_gpsLngController.text.isNotEmpty ||
_stripeIdController.text.isNotEmpty)) ...[
Text(
"Informations avancées",
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.onBackground,
),
),
const SizedBox(height: 8),
// GPS Latitude et Longitude
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// GPS Latitude
Expanded(
child: CustomTextField(
controller: _gpsLatController,
label: "GPS Latitude",
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
readOnly: restrictedFieldsReadOnly,
),
),
const SizedBox(width: 16),
// GPS Longitude
Expanded(
child: CustomTextField(
controller: _gpsLngController,
label: "GPS Longitude",
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
readOnly: restrictedFieldsReadOnly,
),
),
],
),
const SizedBox(height: 16),
// Stripe Checkbox et Stripe ID sur la même ligne
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Checkbox Stripe
Checkbox(
value: _chkStripe,
onChanged: restrictedFieldsReadOnly
? null
: (value) {
setState(() {
_chkStripe = value!;
});
},
activeColor: const Color(0xFF20335E),
),
Text(
"Stripe activé",
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onBackground,
fontWeight: FontWeight.w500,
),
),
const SizedBox(width: 16),
// Stripe ID
Expanded(
child: CustomTextField(
controller: _stripeIdController,
label: "Stripe ID",
readOnly: restrictedFieldsReadOnly,
),
),
],
),
const SizedBox(height: 16),
],
// Options
Text(
"Options",
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.onBackground,
),
),
const SizedBox(height: 8),
// Checkbox Demo
_buildCheckboxOption(
label: "Mode démo",
value: _chkDemo,
onChanged: restrictedFieldsReadOnly
? null
: (value) {
setState(() {
_chkDemo = value!;
});
},
),
const SizedBox(height: 8),
// Checkbox Copie Mail Reçu
_buildCheckboxOption(
label: "Copie des mails reçus",
value: _chkCopieMailRecu,
onChanged: widget.readOnly
? null
: (value) {
setState(() {
_chkCopieMailRecu = value!;
});
},
),
const SizedBox(height: 8),
// Checkbox Accept SMS
_buildCheckboxOption(
label: "Accepte les SMS",
value: _chkAcceptSms,
onChanged: widget.readOnly
? null
: (value) {
setState(() {
_chkAcceptSms = value!;
});
},
),
const SizedBox(height: 8),
// Checkbox Active
_buildCheckboxOption(
label: "Actif",
value: _chkActive,
onChanged: restrictedFieldsReadOnly
? null
: (value) {
setState(() {
_chkActive = value!;
});
},
),
const SizedBox(height: 25),
// Bouton Enregistrer
if (!widget.readOnly)
Center(
child: ElevatedButton(
onPressed: _submitForm,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF20335E),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 24, vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(50),
),
minimumSize: const Size(200, 50),
),
child: const Text(
'Enregistrer',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w500,
),
),
),
),
],
),
),
);
}
Widget _buildCheckboxOption({
required String label,
required bool value,
required Function(bool?)? onChanged,
}) {
final theme = Theme.of(context);
return Row(
children: [
Checkbox(
value: value,
onChanged: onChanged,
activeColor: const Color(0xFF20335E),
),
Text(
label,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onBackground,
fontWeight: FontWeight.w500,
),
),
],
);
}
Widget _buildRegionDropdown(bool readOnly) {
final theme = Theme.of(context);
// Si en lecture seule, afficher simplement le texte
if (readOnly && _libRegion != null) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text(
_libRegion!,
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onBackground,
),
),
);
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 5),
decoration: BoxDecoration(
color: const Color(0xFFF4F5F6).withOpacity(0.85),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: const Color(0xFF20335E).withOpacity(0.1),
width: 1,
),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<int>(
value: _fkRegion,
isExpanded: true,
hint: const Text("Sélectionnez une région"),
icon: const Icon(
Icons.keyboard_arrow_down,
color: Color(0xFF20335E),
),
style: theme.textTheme.bodyMedium?.copyWith(
color: const Color(0xFF20335E),
),
dropdownColor: Colors.white,
items: _regions
.map<DropdownMenuItem<int>>((Map<String, dynamic> region) {
return DropdownMenuItem<int>(
value: region['id'] as int,
child: Text(region['name'] as String),
);
}).toList(),
onChanged: readOnly
? null
: (int? newValue) {
setState(() {
_fkRegion = newValue;
// Trouver le libellé correspondant
if (newValue != null) {
final selectedRegion = _regions.firstWhere(
(region) => region['id'] == newValue,
orElse: () => {'id': newValue, 'name': ''},
);
_libRegion = selectedRegion['name'] as String;
} else {
_libRegion = null;
}
});
},
),
),
);
}
}

View File

@@ -0,0 +1,111 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:geosector_app/core/services/api_service.dart';
/// Widget qui affiche les informations sur l'environnement actuel
/// Utile pour le débogage
class EnvironmentInfoWidget extends StatelessWidget {
final bool showInDialog;
const EnvironmentInfoWidget({
Key? key,
this.showInDialog = false,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final apiService = Provider.of<ApiService>(context, listen: false);
final environment = apiService.getCurrentEnvironment();
final apiUrl = apiService.getCurrentApiUrl();
final appIdentifier = apiService.getCurrentAppIdentifier();
final content = Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'🌍 Environnement GeoSector',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: _getEnvironmentColor(environment)),
),
const SizedBox(height: 16),
_buildInfoRow(context, 'Environnement', environment),
const Divider(),
_buildInfoRow(context, 'URL API', apiUrl),
const Divider(),
_buildInfoRow(context, 'App Identifier', appIdentifier),
const SizedBox(height: 20),
if (!showInDialog)
Align(
alignment: Alignment.center,
child: TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Fermer'),
),
),
],
),
);
if (showInDialog) {
return content;
}
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: content,
);
}
Widget _buildInfoRow(BuildContext context, String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 120,
child: Text(
label,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(fontWeight: FontWeight.bold),
),
),
Expanded(
child: Text(
value,
style: Theme.of(context).textTheme.bodyMedium,
),
),
],
),
);
}
Color _getEnvironmentColor(String environment) {
switch (environment) {
case 'DEV':
return Colors.green;
case 'REC':
return Colors.orange;
case 'PROD':
return Colors.red;
default:
return Colors.grey;
}
}
/// Méthode statique pour afficher les informations sur l'environnement
/// dans une boîte de dialogue
static void show(BuildContext context) {
showDialog(
context: context,
builder: (context) => const EnvironmentInfoWidget(),
);
}
}

View File

@@ -0,0 +1,234 @@
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

@@ -0,0 +1,195 @@
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

@@ -0,0 +1,136 @@
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

@@ -0,0 +1,167 @@
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

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

View File

@@ -0,0 +1,113 @@
import 'package:flutter/material.dart';
/// Widget de dialogue pour informer l'utilisateur que les données locales ont été réinitialisées
/// en raison d'une incompatibilité après une mise à jour de l'application.
class HiveResetDialog extends StatelessWidget {
/// Callback appelé lorsque l'utilisateur ferme le dialogue
final VoidCallback? onClose;
const HiveResetDialog({
Key? key,
this.onClose,
}) : super(key: key);
/// Affiche le dialogue de réinitialisation Hive
static Future<void> show(BuildContext context,
{VoidCallback? onClose}) async {
return showDialog<void>(
context: context,
barrierDismissible:
false, // L'utilisateur doit appuyer sur un bouton pour fermer le dialogue
builder: (BuildContext dialogContext) {
return HiveResetDialog(onClose: onClose);
},
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
title: Row(
children: [
Icon(
Icons.sync_problem,
color: theme.colorScheme.error,
size: 28,
),
const SizedBox(width: 12),
Text(
'Données réinitialisées',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.onSurface,
),
),
],
),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Suite à une mise à jour de l\'application, vos données locales ont dû être réinitialisées pour assurer la compatibilité.',
style: theme.textTheme.bodyLarge,
),
const SizedBox(height: 16),
Text(
'Que s\'est-il passé ?',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'Une incompatibilité a été détectée entre la structure des données de la version précédente et celle de la version actuelle. Pour éviter tout problème, les données locales ont été effacées.',
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 16),
Text(
'Que dois-je faire ?',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'Vous devez vous reconnecter à votre compte pour récupérer vos données depuis le serveur. Toutes vos données seront restaurées automatiquement après la connexion.',
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 16),
Text(
'Note : Si vous aviez des modifications non synchronisées, elles ont été perdues. Nous vous recommandons de synchroniser régulièrement vos données.',
style: theme.textTheme.bodySmall?.copyWith(
fontStyle: FontStyle.italic,
color: theme.colorScheme.onSurface.withOpacity(0.7),
),
),
],
),
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
if (onClose != null) {
onClose!();
}
},
style: TextButton.styleFrom(
foregroundColor: theme.colorScheme.primary,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
child: const Text('J\'ai compris'),
),
],
);
}
}

View File

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

View File

@@ -0,0 +1,199 @@
import 'package:flutter/material.dart';
import 'dart:ui';
/// Widget d'overlay de chargement amélioré qui affiche une barre de progression
/// avec un effet de flou sur l'arrière-plan et un message détaillé sur l'étape en cours
class LoadingProgressOverlay extends StatefulWidget {
final String? message;
final double progress;
final String? stepDescription;
final Color backgroundColor;
final Color progressColor;
final Color textColor;
final double blurAmount;
final bool showPercentage;
const LoadingProgressOverlay({
Key? key,
this.message,
required this.progress,
this.stepDescription,
this.backgroundColor = Colors.black54,
this.progressColor = Colors.white,
this.textColor = Colors.white,
this.blurAmount = 5.0,
this.showPercentage = true,
}) : super(key: key);
@override
State<LoadingProgressOverlay> createState() => _LoadingProgressOverlayState();
}
class _LoadingProgressOverlayState extends State<LoadingProgressOverlay>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _progressAnimation;
@override
void initState() {
super.initState();
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
_progressAnimation = Tween<double>(begin: 0, end: widget.progress).animate(
CurvedAnimation(parent: _animationController, curve: Curves.easeInOut),
);
_animationController.forward();
}
@override
void didUpdateWidget(LoadingProgressOverlay oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.progress != widget.progress) {
_progressAnimation = Tween<double>(
begin: oldWidget.progress,
end: widget.progress,
).animate(
CurvedAnimation(parent: _animationController, curve: Curves.easeInOut),
);
_animationController.reset();
_animationController.forward();
}
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BackdropFilter(
filter: ImageFilter.blur(
sigmaX: widget.blurAmount, sigmaY: widget.blurAmount),
child: Container(
color: widget.backgroundColor,
child: Center(
child: Container(
width: MediaQuery.of(context).size.width * 0.85,
padding: const EdgeInsets.all(30),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.9),
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black45,
blurRadius: 15,
spreadRadius: 5,
offset: const Offset(0, 5),
),
],
border: Border.all(
color: Colors.white.withOpacity(0.1),
width: 1.5,
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.message != null) ...[
Text(
widget.message!,
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: widget.textColor,
letterSpacing: 0.5,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
],
AnimatedBuilder(
animation: _progressAnimation,
builder: (context, child) {
return Column(
children: [
LinearProgressIndicator(
value: _progressAnimation.value,
backgroundColor:
widget.progressColor.withOpacity(0.3),
valueColor: AlwaysStoppedAnimation<Color>(
widget.progressColor),
minHeight: 15,
borderRadius: BorderRadius.circular(8),
),
if (widget.showPercentage) ...[
const SizedBox(height: 8),
Text(
'${(_progressAnimation.value * 100).toInt()}%',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: widget.textColor,
letterSpacing: 1.2,
),
),
],
],
);
},
),
if (widget.stepDescription != null) ...[
const SizedBox(height: 16),
Text(
widget.stepDescription!,
style: TextStyle(
fontSize: 16,
color: widget.textColor.withOpacity(0.9),
fontStyle: FontStyle.italic,
),
textAlign: TextAlign.center,
),
],
],
),
),
),
),
);
}
}
/// Classe utilitaire pour gérer l'overlay de chargement avec progression
class LoadingProgressOverlayUtils {
/// Méthode pour afficher l'overlay de chargement avec progression
static OverlayEntry show({
required BuildContext context,
String? message,
double progress = 0.0,
String? stepDescription,
double blurAmount = 5.0,
bool showPercentage = true,
}) {
final overlayEntry = OverlayEntry(
builder: (context) => LoadingProgressOverlay(
message: message,
progress: progress,
stepDescription: stepDescription,
blurAmount: blurAmount,
showPercentage: showPercentage,
),
);
Overlay.of(context).insert(overlayEntry);
return overlayEntry;
}
/// Méthode pour mettre à jour l'overlay existant
static void update({
required OverlayEntry overlayEntry,
String? message,
required double progress,
String? stepDescription,
}) {
overlayEntry.markNeedsBuild();
}
}

View File

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

View File

@@ -0,0 +1,129 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/core/data/models/membre_model.dart';
class MembreRowWidget extends StatelessWidget {
final MembreModel membre;
final Function()? onEdit;
final Function()? onDelete;
const MembreRowWidget({
Key? key,
required this.membre,
this.onEdit,
this.onDelete,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8.0),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Row(
children: [
// ID
Expanded(
flex: 1,
child: Text(
membre.id.toString(),
style: theme.textTheme.bodyMedium,
),
),
// Prénom (firstName)
Expanded(
flex: 2,
child: Text(
membre.firstName,
style: theme.textTheme.bodyMedium,
overflow: TextOverflow.ellipsis,
),
),
// Nom (name)
Expanded(
flex: 2,
child: Text(
membre.name,
style: theme.textTheme.bodyMedium,
overflow: TextOverflow.ellipsis,
),
),
// Secteur (sectName)
Expanded(
flex: 2,
child: Text(
membre.sectName ?? '',
style: theme.textTheme.bodyMedium,
overflow: TextOverflow.ellipsis,
),
),
// Rôle (fkRole)
Expanded(
flex: 1,
child: Text(
_getRoleName(membre.fkRole),
style: theme.textTheme.bodyMedium,
),
),
// Actions
Expanded(
flex: 2,
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
// Bouton Edit
IconButton(
icon: const Icon(Icons.edit, size: 20),
color: theme.colorScheme.primary,
onPressed: onEdit,
tooltip: 'Modifier',
constraints: const BoxConstraints(),
padding: const EdgeInsets.all(8),
),
// Bouton Delete
IconButton(
icon: const Icon(Icons.delete, size: 20),
color: theme.colorScheme.error,
onPressed: onDelete,
tooltip: 'Supprimer',
constraints: const BoxConstraints(),
padding: const EdgeInsets.all(8),
),
],
),
),
],
),
);
}
// Méthode pour convertir l'ID de rôle en nom lisible
String _getRoleName(int roleId) {
switch (roleId) {
case 1:
return 'User';
case 2:
return 'Admin';
case 3:
return 'Super';
default:
return roleId.toString();
}
}
}

View File

@@ -0,0 +1,149 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/core/data/models/membre_model.dart';
import 'package:geosector_app/presentation/widgets/membre_row_widget.dart';
class MembreTableWidget extends StatelessWidget {
final List<MembreModel> membres;
final Function(MembreModel)? onEdit;
final Function(MembreModel)? onDelete;
final bool showHeader;
final double? height;
final EdgeInsetsGeometry? padding;
const MembreTableWidget({
Key? key,
required this.membres,
this.onEdit,
this.onDelete,
this.showHeader = true,
this.height,
this.padding,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
height: height,
padding: padding ?? const EdgeInsets.all(16.0),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8.0),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// En-tête du tableau
if (showHeader)
Padding(
padding:
const EdgeInsets.only(bottom: 16.0, left: 16.0, right: 16.0),
child: Row(
children: [
// ID
Expanded(
flex: 1,
child: Text(
'ID',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
// Prénom (firstName)
Expanded(
flex: 2,
child: Text(
'Prénom',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
// Nom (name)
Expanded(
flex: 2,
child: Text(
'Nom',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
// Secteur (sectName)
Expanded(
flex: 2,
child: Text(
'Secteur',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
// Rôle (fkRole)
Expanded(
flex: 1,
child: Text(
'Rôle',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
// Actions
Expanded(
flex: 2,
child: Text(
'Actions',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.end,
),
),
],
),
),
// Liste des membres
Expanded(
child: membres.isEmpty
? Center(
child: Text(
'Aucun membre disponible',
style: theme.textTheme.bodyMedium,
),
)
: ListView.separated(
itemCount: membres.length,
separatorBuilder: (context, index) =>
const SizedBox(height: 8.0),
itemBuilder: (context, index) {
final membre = membres[index];
return MembreRowWidget(
membre: membre,
onEdit: onEdit != null ? () => onEdit!(membre) : null,
onDelete:
onDelete != null ? () => onDelete!(membre) : null,
);
},
),
),
],
),
);
}
}

View File

@@ -0,0 +1,422 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'custom_text_field.dart';
class PassageForm extends StatefulWidget {
final Function(Map<String, dynamic>)? onSubmit;
final Map<String, dynamic>? initialData;
const PassageForm({
Key? key,
this.onSubmit,
this.initialData,
}) : super(key: key);
@override
State<PassageForm> createState() => _PassageFormState();
}
class _PassageFormState extends State<PassageForm> {
final _formKey = GlobalKey<FormState>();
// Controllers
late final TextEditingController _villeController;
late final TextEditingController _adresseController;
late final TextEditingController _nomHabitantController;
late final TextEditingController _emailController;
late final TextEditingController _montantController;
late final TextEditingController _commentairesController;
// Form values
String _typeHabitat = 'Individuel';
String _typeReglement = 'Espèces';
@override
void initState() {
super.initState();
// Initialize controllers with initial data if available
final data = widget.initialData ?? {};
_villeController = TextEditingController(text: data['ville'] ?? '');
_adresseController = TextEditingController(text: data['adresse'] ?? '');
_nomHabitantController =
TextEditingController(text: data['nomHabitant'] ?? '');
_emailController = TextEditingController(text: data['email'] ?? '');
_montantController = TextEditingController(text: data['montant'] ?? '');
_commentairesController =
TextEditingController(text: data['commentaires'] ?? '');
_typeHabitat = data['typeHabitat'] ?? 'Individuel';
_typeReglement = data['typeReglement'] ?? 'Espèces';
}
@override
void dispose() {
_villeController.dispose();
_adresseController.dispose();
_nomHabitantController.dispose();
_emailController.dispose();
_montantController.dispose();
_commentairesController.dispose();
super.dispose();
}
void _submitForm() {
if (_formKey.currentState!.validate()) {
final formData = {
'ville': _villeController.text,
'adresse': _adresseController.text,
'typeHabitat': _typeHabitat,
'nomHabitant': _nomHabitantController.text,
'email': _emailController.text,
'montant': _montantController.text,
'typeReglement': _typeReglement,
'commentaires': _commentairesController.text,
};
if (widget.onSubmit != null) {
widget.onSubmit!(formData);
}
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Ville
CustomTextField(
controller: _villeController,
label: 'Ville',
validator: (value) {
if (value == null || value.isEmpty) {
return 'Veuillez entrer une ville';
}
return null;
},
),
const SizedBox(height: 16),
// Adresse
CustomTextField(
controller: _adresseController,
label: 'Adresse',
validator: (value) {
if (value == null || value.isEmpty) {
return 'Veuillez entrer une adresse';
}
return null;
},
),
const SizedBox(height: 16),
// Type d'habitat
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Type d'habitat",
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w500,
color: theme.colorScheme.onBackground,
),
),
const SizedBox(height: 8),
Row(
children: [
_buildRadioOption(
value: 'Individuel',
groupValue: _typeHabitat,
onChanged: (value) {
setState(() {
_typeHabitat = value!;
});
},
),
const SizedBox(width: 40),
_buildRadioOption(
value: 'Collectif',
groupValue: _typeHabitat,
onChanged: (value) {
setState(() {
_typeHabitat = value!;
});
},
),
],
),
],
),
const SizedBox(height: 16),
// Nom de l'habitant
CustomTextField(
controller: _nomHabitantController,
label: "Nom de l'habitant",
validator: (value) {
if (value == null || value.isEmpty) {
return "Veuillez entrer le nom de l'habitant";
}
return null;
},
),
const SizedBox(height: 16),
// Email de l'habitant
CustomTextField(
controller: _emailController,
label: "Adresse email de l'habitant",
keyboardType: TextInputType.emailAddress,
validator: (value) {
if (value == null || value.isEmpty) {
return null; // Email optionnel
}
// Simple email validation
if (!value.contains('@') || !value.contains('.')) {
return "Veuillez entrer une adresse email valide";
}
return null;
},
),
const SizedBox(height: 16),
// Montant et Type de règlement
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Montant reçu
Expanded(
flex: 1,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Montant reçu",
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w500,
color: theme.colorScheme.onBackground,
),
),
const SizedBox(height: 8),
TextFormField(
controller: _montantController,
keyboardType:
TextInputType.numberWithOptions(decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(
RegExp(r'^\d+\.?\d{0,2}')),
],
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onBackground,
),
decoration: InputDecoration(
hintText: '0.00 €',
hintStyle: theme.textTheme.bodyLarge?.copyWith(
color:
theme.colorScheme.onBackground.withOpacity(0.5),
),
fillColor: const Color(0xFFF4F5F6),
filled: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color:
theme.colorScheme.onBackground.withOpacity(0.1),
width: 1,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color:
theme.colorScheme.onBackground.withOpacity(0.1),
width: 1,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: theme.colorScheme.primary,
width: 2,
),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 16,
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Requis';
}
return null;
},
),
],
),
),
const SizedBox(width: 20),
// Type de règlement
Expanded(
flex: 2,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Type de règlement",
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w500,
color: theme.colorScheme.onBackground,
),
),
const SizedBox(height: 8),
_buildDropdown(),
],
),
),
],
),
const SizedBox(height: 16),
// Commentaires
CustomTextField(
controller: _commentairesController,
label: "Commentaires",
hintText: "Placeholder",
maxLines: 3,
),
const SizedBox(height: 25),
// Titre de section
Text(
"Mise à jour du passage effectué",
style: theme.textTheme.bodyLarge?.copyWith(
color: const Color(0xFF20335E),
),
),
const SizedBox(height: 16),
// Bouton Enregistrer
Center(
child: ElevatedButton(
onPressed: _submitForm,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF20335E),
foregroundColor: Colors.white,
padding:
const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(50),
),
minimumSize: const Size(200, 50),
),
child: const Text(
'Enregistrer',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w500,
),
),
),
),
],
),
);
}
Widget _buildRadioOption({
required String value,
required String groupValue,
required Function(String?) onChanged,
}) {
final theme = Theme.of(context);
final isSelected = value == groupValue;
return Row(
children: [
Radio<String>(
value: value,
groupValue: groupValue,
onChanged: onChanged,
activeColor: const Color(0xFF20335E),
),
Text(
value,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onBackground,
fontWeight: FontWeight.w500,
),
),
],
);
}
Widget _buildDropdown() {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 5),
decoration: BoxDecoration(
color: const Color(0xFFF4F5F6).withOpacity(0.85),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: const Color(0xFF20335E).withOpacity(0.1),
width: 1,
),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: _typeReglement,
isExpanded: true,
icon: const Icon(
Icons.keyboard_arrow_down,
color: Color(0xFF20335E),
),
style: theme.textTheme.bodyMedium?.copyWith(
color: const Color(0xFF20335E),
),
dropdownColor: Colors.white,
items: <String>['Espèces', 'CB', 'Chèque']
.map<DropdownMenuItem<String>>((String value) {
return DropdownMenuItem<String>(
value: value,
child: Row(
children: [
_getPaymentIcon(value),
const SizedBox(width: 8),
Text(value),
],
),
);
}).toList(),
onChanged: (String? newValue) {
setState(() {
_typeReglement = newValue!;
});
},
),
),
);
}
Widget _getPaymentIcon(String type) {
switch (type) {
case 'Espèces':
return const Icon(Icons.payments_outlined,
color: Color(0xFF20335E), size: 20);
case 'CB':
return const Icon(Icons.credit_card,
color: Color(0xFF20335E), size: 20);
case 'Chèque':
return const Icon(Icons.account_balance_wallet_outlined,
color: Color(0xFF20335E), size: 20);
default:
return const SizedBox.shrink();
}
}
}

View File

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

View File

@@ -0,0 +1,268 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/app.dart';
import 'package:geosector_app/core/data/models/user_model.dart';
import 'package:geosector_app/presentation/widgets/user_form.dart';
class ProfileDialog extends StatefulWidget {
final UserModel user;
const ProfileDialog({
Key? key,
required this.user,
}) : super(key: key);
/// Méthode statique pour afficher la boîte de dialogue
static Future<bool?> show(BuildContext context, UserModel user) {
return showDialog<bool>(
context: context,
builder: (context) => ProfileDialog(user: user),
);
}
@override
State<ProfileDialog> createState() => _ProfileDialogState();
}
class _ProfileDialogState extends State<ProfileDialog> {
final _formKey = GlobalKey<FormState>();
late UserModel _user;
bool _isLoading = false;
@override
void initState() {
super.initState();
_user = widget.user;
}
// Fonction pour capitaliser la première lettre de chaque mot
String _capitalizeFirstLetter(String text) {
if (text.isEmpty) return text;
return text.split(' ').map((word) {
if (word.isEmpty) return word;
return word[0].toUpperCase() + word.substring(1).toLowerCase();
}).join(' ');
}
// Fonction pour mettre en majuscule
String _toUpperCase(String text) {
return text.toUpperCase();
}
// Fonction pour valider et soumettre le formulaire
Future<void> _saveProfile(UserModel updatedUser) async {
// Validation supplémentaire
if (!_validateUser(updatedUser)) {
return;
}
setState(() {
_isLoading = true;
});
try {
// Formatage des données
final formattedUser = updatedUser.copyWith(
name: _toUpperCase(updatedUser.name ?? ''),
firstName: _capitalizeFirstLetter(updatedUser.firstName ?? ''),
);
// Sauvegarde de l'utilisateur
await userRepository.saveUser(formattedUser);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Profil mis à jour avec succès'),
backgroundColor: Colors.green,
),
);
Navigator.of(context).pop(true); // Fermer la modale avec succès
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur lors de la mise à jour du profil: $e'),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
// Validation supplémentaire
bool _validateUser(UserModel user) {
// Vérifier que l'email est valide
if (user.email.isEmpty ||
!user.email.contains('@') ||
!user.email.contains('.')) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Veuillez entrer une adresse email valide'),
backgroundColor: Colors.red,
),
);
return false;
}
// Vérifier que le nom ou le sectName est renseigné
if ((user.name == null || user.name!.isEmpty) &&
(user.sectName == null || user.sectName!.isEmpty)) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Le nom ou le nom du secteur doit être renseigné'),
backgroundColor: Colors.red,
),
);
return false;
}
// Vérifier que le téléphone fixe est valide s'il est renseigné
if (user.phone != null &&
user.phone!.isNotEmpty &&
user.phone!.length != 10) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content:
Text('Le numéro de téléphone fixe doit contenir 10 chiffres'),
backgroundColor: Colors.red,
),
);
return false;
}
// Vérifier que le téléphone mobile est valide s'il est renseigné
if (user.mobile != null &&
user.mobile!.isNotEmpty &&
user.mobile!.length != 10) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content:
Text('Le numéro de téléphone mobile doit contenir 10 chiffres'),
backgroundColor: Colors.red,
),
);
return false;
}
return true;
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
elevation: 0,
backgroundColor: Colors.transparent,
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, 5),
),
],
),
constraints: const BoxConstraints(
maxWidth: 600,
maxHeight: 700,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Titre
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Mon compte',
style: theme.textTheme.headlineSmall?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
tooltip: 'Fermer',
),
],
),
const Divider(),
const SizedBox(height: 10),
// Formulaire
Expanded(
child: SingleChildScrollView(
child: UserForm(
user: _user,
onSubmit: _saveProfile,
),
),
),
// Boutons
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed:
_isLoading ? null : () => Navigator.of(context).pop(),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 20, vertical: 12),
),
child: const Text('Fermer'),
),
const SizedBox(width: 10),
ElevatedButton(
onPressed: _isLoading
? null
: () {
// Appeler directement la méthode onSubmit du UserForm
// qui va déclencher la validation et la soumission
_saveProfile(_user);
},
style: ElevatedButton.styleFrom(
backgroundColor: theme.colorScheme.primary,
foregroundColor: theme.colorScheme.onPrimary,
padding: const EdgeInsets.symmetric(
horizontal: 20, vertical: 12),
),
child: _isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor:
AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: const Text('Enregistrer'),
),
],
),
],
),
),
);
}
}

View File

@@ -0,0 +1,716 @@
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
import 'package:flutter/material.dart';
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
import 'package:go_router/go_router.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/repositories/user_repository.dart';
import 'package:geosector_app/presentation/widgets/help_dialog.dart';
import 'package:geosector_app/presentation/widgets/profile_dialog.dart';
/// Widget qui fournit une navigation responsive pour l'application.
/// Affiche une sidebar en mode desktop et une bottomBar en mode mobile.
class ResponsiveNavigation extends StatefulWidget {
/// Le contenu principal à afficher
final Widget body;
/// Le titre de la page
final String title;
/// L'index de la page sélectionnée
final int selectedIndex;
/// Callback appelé lorsqu'un élément de navigation est sélectionné
final Function(int) onDestinationSelected;
/// Liste des destinations de navigation
final List<NavigationDestination> destinations;
/// Actions supplémentaires à afficher dans l'AppBar
final List<Widget>? additionalActions;
/// Indique si le bouton "Nouveau passage" doit être affiché
final bool showNewPassageButton;
/// Callback appelé lorsque le bouton "Nouveau passage" est pressé
final VoidCallback? onNewPassagePressed;
/// Clé de la boîte Hive pour sauvegarder les paramètres
final String settingsBoxKey;
/// Clé pour sauvegarder l'état de la sidebar
final String sidebarStateKey;
/// Widgets à afficher en bas de la sidebar
final List<Widget>? sidebarBottomItems;
/// Indique si l'utilisateur est un administrateur
final bool isAdmin;
/// Indique si l'AppBar doit être affiché
final bool showAppBar;
const ResponsiveNavigation({
Key? key,
required this.body,
required this.title,
required this.selectedIndex,
required this.onDestinationSelected,
required this.destinations,
this.additionalActions,
this.showNewPassageButton = true,
this.onNewPassagePressed,
this.settingsBoxKey = AppKeys.settingsBoxName,
this.sidebarStateKey = 'isSidebarMinimized',
this.sidebarBottomItems,
this.isAdmin = false,
this.showAppBar = true,
}) : super(key: key);
@override
State<ResponsiveNavigation> createState() => _ResponsiveNavigationState();
}
class _ResponsiveNavigationState extends State<ResponsiveNavigation> {
/// État de la barre latérale (minimisée ou non)
bool _isSidebarMinimized = false;
/// Référence à la boîte Hive pour les paramètres
late Box _settingsBox;
@override
void initState() {
super.initState();
_initSettings();
}
/// Initialiser la boîte de paramètres et charger les préférences
Future<void> _initSettings() async {
try {
// Ouvrir la boîte de paramètres si elle n'est pas déjà ouverte
if (!Hive.isBoxOpen(widget.settingsBoxKey)) {
_settingsBox = await Hive.openBox(widget.settingsBoxKey);
} else {
_settingsBox = Hive.box(widget.settingsBoxKey);
}
// Charger l'état de la barre latérale
final sidebarState = _settingsBox.get(widget.sidebarStateKey);
if (sidebarState != null && sidebarState is bool) {
setState(() {
_isSidebarMinimized = sidebarState;
});
}
} catch (e) {
debugPrint('Erreur lors du chargement des paramètres: $e');
}
}
/// Sauvegarder l'état de la barre latérale
void _saveSettings() {
try {
// Sauvegarder l'état de la barre latérale
_settingsBox.put(widget.sidebarStateKey, _isSidebarMinimized);
} catch (e) {
debugPrint('Erreur lors de la sauvegarde des paramètres: $e');
}
}
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
final isDesktop = size.width > 900;
return Scaffold(
appBar: widget.showAppBar
? AppBar(
title: Text(widget.title),
actions: _buildAppBarActions(context),
)
: null,
body: isDesktop ? _buildDesktopLayout() : _buildMobileLayout(),
bottomNavigationBar: (isDesktop) ? null : _buildBottomNavigationBar(),
);
}
/// Construction du layout pour les écrans de bureau (web)
Widget _buildDesktopLayout() {
return Row(
children: [
_buildSidebar(),
Expanded(
child: Container(
color: Colors
.transparent, // Fond transparent pour voir l'AdminBackground
child: widget.body,
),
),
],
);
}
/// Construction du layout pour les écrans mobiles
Widget _buildMobileLayout() {
return Container(
color: Colors.transparent, // Fond transparent pour voir l'AdminBackground
child: widget.body,
);
}
/// Construction des actions de l'AppBar
List<Widget> _buildAppBarActions(BuildContext context) {
List<Widget> actions = [];
// Ajouter les actions supplémentaires si elles existent
if (widget.additionalActions != null &&
widget.additionalActions!.isNotEmpty) {
actions.addAll(widget.additionalActions!);
} else if (widget.showNewPassageButton && widget.selectedIndex == 0) {
// Ajouter le bouton "Nouveau passage" en haut à droite pour la page d'accueil
actions.add(
TextButton.icon(
icon: const Icon(Icons.add_location_alt, color: Colors.white),
label: const Text('Nouveau passage',
style: TextStyle(color: Colors.white)),
onPressed: widget.onNewPassagePressed ??
() {
// Fonction par défaut si onNewPassagePressed n'est pas fourni
_showPassageForm(context);
},
style: TextButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.secondary,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
),
);
actions.add(const SizedBox(width: 16)); // Espacement à droite
}
return actions;
}
/// Construction de la barre de navigation inférieure pour mobile
Widget _buildBottomNavigationBar() {
final theme = Theme.of(context);
return NavigationBar(
selectedIndex: widget.selectedIndex,
onDestinationSelected: widget.onDestinationSelected,
backgroundColor: theme.colorScheme.surface,
elevation: 8,
destinations: widget.destinations,
);
}
/// Obtenir le nom complet de l'utilisateur (prénom + nom)
String _getFullUserName(BuildContext context) {
// Utiliser l'instance globale définie dans app.dart
final user = userRepository.currentUser;
if (user == null) return 'Utilisateur';
String fullName = '';
// Ajouter le prénom si disponible
if (user.firstName != null && user.firstName!.isNotEmpty) {
fullName += user.firstName!;
}
// Ajouter le nom
if (user.name != null && user.name!.isNotEmpty) {
// Ajouter un espace si le prénom est déjà présent
if (fullName.isNotEmpty) {
fullName += ' ';
}
fullName += user.name!;
}
// Si aucun nom n'a été trouvé, utiliser 'Utilisateur' par défaut
return fullName.isEmpty ? 'Utilisateur' : fullName;
}
/// Obtenir les initiales du prénom et du nom de l'utilisateur
String _getUserInitials(BuildContext context) {
// Utiliser l'instance globale définie dans app.dart
final user = userRepository.currentUser;
if (user == null) return 'U';
String initials = '';
// Ajouter l'initiale du prénom si disponible
if (user.firstName != null && user.firstName!.isNotEmpty) {
initials += user.firstName!.substring(0, 1).toUpperCase();
}
// Ajouter l'initiale du nom
if (user.name != null && user.name!.isNotEmpty) {
initials += user.name!.substring(0, 1).toUpperCase();
}
// Si aucune initiale n'a été trouvée, utiliser 'U' par défaut
return initials.isEmpty ? 'U' : initials;
}
/// Afficher le sectName entre parenthèses s'il existe
Widget _buildSectNameText(BuildContext context) {
final theme = Theme.of(context);
// Utiliser l'instance globale définie dans app.dart
final user = userRepository.currentUser;
// Si l'utilisateur n'a pas de sectName ou s'il est vide, retourner un widget vide
if (user == null || user.sectName == null || user.sectName!.isEmpty) {
return const SizedBox.shrink();
}
// Sinon, afficher le sectName entre parenthèses
return Text(
'(${user.sectName})',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
);
}
/// Construction de la barre latérale pour la version web
Widget _buildSidebar() {
final theme = Theme.of(context);
return Card(
margin: EdgeInsets.zero,
elevation: 4,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.zero,
),
child: Container(
width: _isSidebarMinimized ? 70 : 250,
color: theme.colorScheme.surface,
child: Column(
children: [
// Bouton pour minimiser/maximiser la barre latérale
Align(
alignment: _isSidebarMinimized
? Alignment.center
: Alignment.centerRight,
child: Padding(
padding:
EdgeInsets.only(top: 8, right: _isSidebarMinimized ? 0 : 8),
child: IconButton(
icon: Icon(_isSidebarMinimized
? Icons.chevron_right
: Icons.chevron_left),
onPressed: () {
setState(() {
_isSidebarMinimized = !_isSidebarMinimized;
_saveSettings(); // Sauvegarder l'état de la barre latérale
});
},
tooltip: _isSidebarMinimized ? 'Développer' : 'Réduire',
),
),
),
const SizedBox(height: 8),
if (!_isSidebarMinimized)
CircleAvatar(
radius: 40,
backgroundColor: theme.colorScheme.primary,
child: Text(
_getUserInitials(context),
style: TextStyle(
fontSize: 28,
color: theme.colorScheme.onPrimary,
),
),
),
const SizedBox(height: 8),
if (!_isSidebarMinimized) ...[
Text(
_getFullUserName(context),
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
// Afficher le sectName entre parenthèses s'il existe
_buildSectNameText(context),
Text(
userRepository.currentUser?.email ?? '',
style: theme.textTheme.bodySmall,
),
const SizedBox(height: 24),
] else
const SizedBox(height: 8),
const Divider(),
// Éléments de navigation
for (int i = 0; i < widget.destinations.length; i++)
_buildNavItem(
i, widget.destinations[i].label, widget.destinations[i].icon),
const Spacer(),
const Divider(),
// Éléments du bas de la sidebar
if (widget.sidebarBottomItems != null && !_isSidebarMinimized)
...widget.sidebarBottomItems!,
// Éléments par défaut du bas de la sidebar
if (!_isSidebarMinimized)
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
'Paramètres',
style: theme.textTheme.titleSmall?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
),
_SettingsItem(
icon: Icons.person,
title: 'Mon compte',
subtitle: null,
isSidebarMinimized: _isSidebarMinimized,
onTap: () {
// Afficher la boîte de dialogue de profil avec l'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
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),
_SettingsItem(
icon: Icons.help_outline,
title: 'Aide',
isSidebarMinimized: _isSidebarMinimized,
onTap: () {
// Afficher la boîte de dialogue d'aide avec le titre de la page courante
HelpDialog.show(context, widget.title);
},
),
],
),
),
);
}
/// Construction d'un élément de navigation pour la barre latérale
Widget _buildNavItem(int index, String title, Widget icon) {
final theme = Theme.of(context);
final isSelected = widget.selectedIndex == index;
final IconData? iconData = (icon is Icon) ? (icon as Icon).icon : null;
// Remplacer certains titres si l'interface est de type "user"
String displayTitle = title;
if (!widget.isAdmin) {
if (title == "Accueil") {
displayTitle = "Tableau de bord";
} else if (title == "Stats") {
displayTitle = "Statistiques";
}
}
if (_isSidebarMinimized) {
// Version minimisée - afficher uniquement l'icône
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Tooltip(
message: displayTitle,
child: InkWell(
onTap: () {
widget.onDestinationSelected(index);
},
child: Container(
width: 50,
height: 50,
decoration: BoxDecoration(
color: isSelected
? theme.colorScheme.primary.withOpacity(0.1)
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
child: iconData != null
? Icon(
iconData,
color: isSelected
? theme.colorScheme.primary
: theme.colorScheme.onSurface.withOpacity(0.6),
size: 24,
)
: icon,
),
),
),
);
} else {
// Version normale avec texte et icône
return ListTile(
leading: iconData != null
? Icon(
iconData,
color: isSelected
? theme.colorScheme.primary
: theme.colorScheme.onSurface.withOpacity(0.6),
)
: icon,
title: Text(
displayTitle,
style: TextStyle(
color: isSelected
? theme.colorScheme.primary
: theme.colorScheme.onSurface,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
),
),
tileColor:
isSelected ? theme.colorScheme.primary.withOpacity(0.1) : null,
onTap: () {
widget.onDestinationSelected(index);
},
);
}
}
/// Affiche le formulaire de passage
void _showPassageForm(BuildContext context) {
final theme = Theme.of(context);
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(
'Nouveau passage',
style: TextStyle(
color: theme.colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
decoration: InputDecoration(
labelText: 'Adresse',
prefixIcon: const Icon(Icons.location_on),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
const SizedBox(height: 16),
DropdownButtonFormField<int>(
decoration: InputDecoration(
labelText: 'Type de passage',
prefixIcon: const Icon(Icons.category),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
items: const [
DropdownMenuItem(
value: 1,
child: Text('Effectué'),
),
DropdownMenuItem(
value: 2,
child: Text('À finaliser'),
),
DropdownMenuItem(
value: 3,
child: Text('Refusé'),
),
DropdownMenuItem(
value: 4,
child: Text('Don'),
),
DropdownMenuItem(
value: 5,
child: Text('Lot'),
),
DropdownMenuItem(
value: 6,
child: Text('Maison vide'),
),
],
onChanged: (value) {},
),
const SizedBox(height: 16),
TextField(
decoration: InputDecoration(
labelText: 'Commentaire',
prefixIcon: const Icon(Icons.comment),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
maxLines: 3,
),
],
),
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: Text(
'Annuler',
style: TextStyle(
color: theme.colorScheme.error,
),
),
),
ElevatedButton(
onPressed: () {
// Enregistrer le passage
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Passage enregistré avec succès'),
backgroundColor: theme.colorScheme.primary,
),
);
},
style: ElevatedButton.styleFrom(
backgroundColor: theme.colorScheme.primary,
foregroundColor: theme.colorScheme.onPrimary,
),
child: const Text('Enregistrer'),
),
],
),
);
}
}
/// Widget pour les éléments de paramètres
class _SettingsItem extends StatelessWidget {
final IconData icon;
final String title;
final String? subtitle;
final Widget? trailing;
final VoidCallback onTap;
final bool isSidebarMinimized;
const _SettingsItem({
required this.icon,
required this.title,
this.subtitle,
this.trailing,
required this.onTap,
required this.isSidebarMinimized,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
if (isSidebarMinimized) {
// Version minimisée - afficher uniquement l'icône
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Tooltip(
message: title,
child: InkWell(
onTap: onTap,
child: Container(
width: 50,
height: 50,
decoration: BoxDecoration(
color: Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
icon,
color: theme.colorScheme.primary,
size: 24,
),
),
),
),
);
} else {
// Version normale avec texte et icône
return ListTile(
leading: Icon(
icon,
color: theme.colorScheme.primary,
),
title: Text(title),
subtitle: subtitle != null ? Text(subtitle!) : null,
trailing: trailing,
onTap: onTap,
);
}
}
}

View File

@@ -0,0 +1,218 @@
import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/data/models/passage_model.dart';
import 'package:geosector_app/core/data/models/sector_model.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/shared/app_theme.dart';
class SectorDistributionCard extends StatefulWidget {
final String title;
final double? height;
final EdgeInsetsGeometry? padding;
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),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
boxShadow: AppTheme.cardShadow,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
widget.title,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
if (isLoading)
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
else
IconButton(
icon: const Icon(Icons.refresh, size: 20),
onPressed: _loadSectorData,
tooltip: 'Rafraîchir',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
const SizedBox(height: AppTheme.spacingM),
Expanded(
child: isLoading
? const Center(child: CircularProgressIndicator())
: sectorStats.isEmpty
? const Center(
child: Text('Aucune donnée de secteur disponible'))
: ListView.builder(
itemCount: sectorStats.length,
itemBuilder: (context, index) {
final sector = sectorStats[index];
return _buildSectorItem(
context,
sector['name'],
sector['count'],
Color(sector['color']),
);
},
),
),
],
),
);
}
Widget _buildSectorItem(
BuildContext context,
String name,
int count,
Color color,
) {
final totalCount =
sectorStats.fold(0, (sum, item) => sum + (item['count'] as int));
final percentage = totalCount > 0 ? (count / totalCount) * 100 : 0;
return Padding(
padding: const EdgeInsets.only(bottom: AppTheme.spacingS),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
name,
style: const TextStyle(fontSize: 14),
overflow: TextOverflow.ellipsis,
),
),
Text(
'$count (${percentage.toInt()}%)',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
],
),
const SizedBox(height: 4),
LinearProgressIndicator(
value: percentage / 100,
backgroundColor: Colors.grey[200],
valueColor: AlwaysStoppedAnimation<Color>(color),
minHeight: 8,
borderRadius: BorderRadius.circular(4),
),
],
),
);
}
}

View File

@@ -0,0 +1,370 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.dart';
import 'package:geosector_app/core/data/models/user_model.dart';
import 'custom_text_field.dart';
class UserForm extends StatefulWidget {
final UserModel? user;
final Function(UserModel)? onSubmit;
final bool readOnly;
const UserForm({
Key? key,
this.user,
this.onSubmit,
this.readOnly = false,
}) : super(key: key);
@override
State<UserForm> createState() => _UserFormState();
}
class _UserFormState extends State<UserForm> {
final _formKey = GlobalKey<FormState>();
// Controllers
late final TextEditingController _usernameController;
late final TextEditingController _firstNameController;
late final TextEditingController _nameController;
late final TextEditingController _phoneController;
late final TextEditingController _mobileController;
late final TextEditingController _emailController;
late final TextEditingController _dateNaissanceController;
late final TextEditingController _dateEmbaucheController;
// Form values
int _fkTitre = 1; // 1 = M., 2 = Mme
DateTime? _dateNaissance;
DateTime? _dateEmbauche;
@override
void initState() {
super.initState();
// Initialize controllers with user data if available
final user = widget.user;
_usernameController = TextEditingController(text: user?.username ?? '');
_firstNameController = TextEditingController(text: user?.firstName ?? '');
_nameController = TextEditingController(text: user?.name ?? '');
_phoneController = TextEditingController(text: user?.phone ?? '');
_mobileController = TextEditingController(text: user?.mobile ?? '');
_emailController = TextEditingController(text: user?.email ?? '');
_dateNaissance = user?.dateNaissance;
_dateEmbauche = user?.dateEmbauche;
_dateNaissanceController = TextEditingController(
text: _dateNaissance != null
? DateFormat('dd/MM/yyyy').format(_dateNaissance!)
: '');
_dateEmbaucheController = TextEditingController(
text: _dateEmbauche != null
? DateFormat('dd/MM/yyyy').format(_dateEmbauche!)
: '');
_fkTitre = user?.fkTitre ?? 1;
}
@override
void dispose() {
_usernameController.dispose();
_firstNameController.dispose();
_nameController.dispose();
_phoneController.dispose();
_mobileController.dispose();
_emailController.dispose();
_dateNaissanceController.dispose();
_dateEmbaucheController.dispose();
super.dispose();
}
// Méthode simplifiée pour sélectionner une date
void _selectDate(BuildContext context, bool isDateNaissance) {
// Utiliser un bloc try-catch pour capturer toutes les erreurs possibles
try {
// Afficher le sélecteur de date sans spécifier de locale
showDatePicker(
context: context,
initialDate: DateTime.now(), // Toujours utiliser la date actuelle
firstDate: DateTime(1900),
lastDate: DateTime.now(),
// Ne pas spécifier de locale pour éviter les problèmes
).then((DateTime? picked) {
// Vérifier si une date a été sélectionnée
if (picked != null) {
setState(() {
// Mettre à jour la date et le texte du contrôleur
if (isDateNaissance) {
_dateNaissance = picked;
_dateNaissanceController.text =
DateFormat('dd/MM/yyyy').format(picked);
} else {
_dateEmbauche = picked;
_dateEmbaucheController.text =
DateFormat('dd/MM/yyyy').format(picked);
}
});
}
}).catchError((error) {
// Gérer les erreurs spécifiques au sélecteur de date
debugPrint('Erreur lors de la sélection de la date: $error');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur lors de la sélection de la date'),
backgroundColor: Colors.red,
),
);
});
} catch (e) {
// Gérer toutes les autres erreurs
debugPrint('Exception lors de l\'affichage du sélecteur de date: $e');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Impossible d\'afficher le sélecteur de date'),
backgroundColor: Colors.red,
),
);
}
}
void _submitForm() {
if (_formKey.currentState!.validate()) {
final user = widget.user?.copyWith(
firstName: _firstNameController.text,
name: _nameController.text,
phone: _phoneController.text,
mobile: _mobileController.text,
email: _emailController.text,
fkTitre: _fkTitre,
dateNaissance: _dateNaissance,
dateEmbauche: _dateEmbauche,
) ??
UserModel(
id: 0, // Sera remplacé par l'API
firstName: _firstNameController.text,
name: _nameController.text,
phone: _phoneController.text,
mobile: _mobileController.text,
email: _emailController.text,
fkTitre: _fkTitre,
dateNaissance: _dateNaissance,
dateEmbauche: _dateEmbauche,
role: 1, // Valeur par défaut
createdAt: DateTime.now(),
lastSyncedAt: DateTime.now(),
);
if (widget.onSubmit != null) {
widget.onSubmit!(user);
}
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Nom d'utilisateur (en lecture seule)
CustomTextField(
controller: _usernameController,
label: "Nom d'utilisateur",
readOnly: true, // Toujours en lecture seule
prefixIcon: Icons.account_circle,
),
const SizedBox(height: 16),
// Titre (M. ou Mme)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Titre",
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w500,
color: theme.colorScheme.onBackground,
),
),
const SizedBox(height: 8),
Row(
children: [
_buildRadioOption(
value: 1,
label: 'M.',
groupValue: _fkTitre,
onChanged: widget.readOnly
? null
: (value) {
setState(() {
_fkTitre = value!;
});
},
),
const SizedBox(width: 40),
_buildRadioOption(
value: 2,
label: 'Mme',
groupValue: _fkTitre,
onChanged: widget.readOnly
? null
: (value) {
setState(() {
_fkTitre = value!;
});
},
),
],
),
],
),
const SizedBox(height: 16),
// Prénom
CustomTextField(
controller: _firstNameController,
label: "Prénom",
readOnly: widget.readOnly,
validator: (value) {
if (value == null || value.isEmpty) {
return "Veuillez entrer le prénom";
}
return null;
},
),
const SizedBox(height: 16),
// Nom
CustomTextField(
controller: _nameController,
label: "Nom",
readOnly: widget.readOnly,
validator: (value) {
if (value == null || value.isEmpty) {
return "Veuillez entrer le nom";
}
return null;
},
),
const SizedBox(height: 16),
// Téléphone fixe
CustomTextField(
controller: _phoneController,
label: "Téléphone fixe",
keyboardType: TextInputType.phone,
readOnly: widget.readOnly,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
],
validator: (value) {
if (value != null && value.isNotEmpty && value.length < 10) {
return "Le numéro de téléphone doit contenir 10 chiffres";
}
return null;
},
),
const SizedBox(height: 16),
// Téléphone mobile
CustomTextField(
controller: _mobileController,
label: "Téléphone mobile",
keyboardType: TextInputType.phone,
readOnly: widget.readOnly,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
],
validator: (value) {
if (value != null && value.isNotEmpty && value.length < 10) {
return "Le numéro de mobile doit contenir 10 chiffres";
}
return null;
},
),
const SizedBox(height: 16),
// Email
CustomTextField(
controller: _emailController,
label: "Email",
keyboardType: TextInputType.emailAddress,
readOnly: widget.readOnly,
validator: (value) {
if (value == null || value.isEmpty) {
return "Veuillez entrer l'adresse email";
}
if (!value.contains('@') || !value.contains('.')) {
return "Veuillez entrer une adresse email valide";
}
return null;
},
),
const SizedBox(height: 16),
// Date de naissance
CustomTextField(
controller: _dateNaissanceController,
label: "Date de naissance",
readOnly: true,
onTap: widget.readOnly ? null : () => _selectDate(context, true),
suffixIcon: Icon(
Icons.calendar_today,
color: theme.colorScheme.primary,
),
),
const SizedBox(height: 16),
// Date d'embauche
CustomTextField(
controller: _dateEmbaucheController,
label: "Date d'embauche",
readOnly: true,
onTap: widget.readOnly ? null : () => _selectDate(context, false),
suffixIcon: Icon(
Icons.calendar_today,
color: theme.colorScheme.primary,
),
),
// Espace en bas du formulaire
const SizedBox(height: 16),
],
),
);
}
Widget _buildRadioOption({
required int value,
required String label,
required int groupValue,
required Function(int?)? onChanged,
}) {
final theme = Theme.of(context);
final isSelected = value == groupValue;
return Row(
children: [
Radio<int>(
value: value,
groupValue: groupValue,
onChanged: onChanged,
activeColor: const Color(0xFF20335E),
),
Text(
label,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onBackground,
fontWeight: FontWeight.w500,
),
),
],
);
}
}