feat: Gestion des secteurs et migration v3.0.4+304
- Ajout système complet de gestion des secteurs avec contours géographiques - Import des contours départementaux depuis GeoJSON - API REST pour la gestion des secteurs (/api/sectors) - Service de géolocalisation pour déterminer les secteurs - Migration base de données avec tables x_departements_contours et sectors_adresses - Interface Flutter pour visualisation et gestion des secteurs - Ajout thème sombre dans l'application - Corrections diverses et optimisations 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
0
app/lib/presentation/widgets/amicale_form.dart
Normal file → Executable file
0
app/lib/presentation/widgets/amicale_form.dart
Normal file → Executable file
0
app/lib/presentation/widgets/amicale_row_widget.dart
Normal file → Executable file
0
app/lib/presentation/widgets/amicale_row_widget.dart
Normal file → Executable file
0
app/lib/presentation/widgets/amicale_table_widget.dart
Normal file → Executable file
0
app/lib/presentation/widgets/amicale_table_widget.dart
Normal file → Executable file
6
app/lib/presentation/widgets/charts/activity_chart.dart
Normal file → Executable file
6
app/lib/presentation/widgets/charts/activity_chart.dart
Normal file → Executable file
@@ -225,11 +225,13 @@ class _ActivityChartState extends State<ActivityChart>
|
||||
|
||||
// Vérifier si le passage est dans la période
|
||||
final passageDate = passage.passedAt;
|
||||
if (passageDate.isBefore(startDate) || passageDate.isAfter(endDate)) {
|
||||
if (passageDate == null ||
|
||||
passageDate.isBefore(startDate) ||
|
||||
passageDate.isAfter(endDate)) {
|
||||
shouldInclude = false;
|
||||
}
|
||||
|
||||
if (shouldInclude) {
|
||||
if (shouldInclude && passageDate != null) {
|
||||
final dateStr = DateFormat('yyyy-MM-dd').format(passageDate);
|
||||
if (dataByDate.containsKey(dateStr)) {
|
||||
dataByDate[dateStr]![passage.fkType] =
|
||||
|
||||
0
app/lib/presentation/widgets/charts/charts.dart
Normal file → Executable file
0
app/lib/presentation/widgets/charts/charts.dart
Normal file → Executable file
4
app/lib/presentation/widgets/charts/combined_chart.dart
Normal file → Executable file
4
app/lib/presentation/widgets/charts/combined_chart.dart
Normal file → Executable file
@@ -198,7 +198,7 @@ class CombinedChart extends StatelessWidget {
|
||||
reservedSize: 40,
|
||||
),
|
||||
),
|
||||
topTitles: AxisTitles(
|
||||
topTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
),
|
||||
@@ -214,7 +214,7 @@ class CombinedChart extends StatelessWidget {
|
||||
),
|
||||
borderData: FlBorderData(show: false),
|
||||
barGroups: _createBarGroups(allDates, passagesByType),
|
||||
extraLinesData: ExtraLinesData(
|
||||
extraLinesData: const ExtraLinesData(
|
||||
horizontalLines: [],
|
||||
verticalLines: [],
|
||||
extraLinesOnTop: true,
|
||||
|
||||
0
app/lib/presentation/widgets/charts/passage_data.dart
Normal file → Executable file
0
app/lib/presentation/widgets/charts/passage_data.dart
Normal file → Executable file
16
app/lib/presentation/widgets/charts/passage_pie_chart.dart
Normal file → Executable file
16
app/lib/presentation/widgets/charts/passage_pie_chart.dart
Normal file → Executable file
@@ -110,13 +110,15 @@ class _PassagePieChartState extends State<PassagePieChart>
|
||||
|
||||
_animationController.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(PassagePieChart oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
// Relancer l'animation si les paramètres importants ont changé
|
||||
final bool shouldResetAnimation = oldWidget.userId != widget.userId ||
|
||||
!listEquals(oldWidget.excludePassageTypes, widget.excludePassageTypes) ||
|
||||
!listEquals(
|
||||
oldWidget.excludePassageTypes, widget.excludePassageTypes) ||
|
||||
oldWidget.showAllPassages != widget.showAllPassages ||
|
||||
oldWidget.useValueListenable != widget.useValueListenable;
|
||||
|
||||
@@ -144,7 +146,8 @@ class _PassagePieChartState extends State<PassagePieChart>
|
||||
/// Construction du widget avec ValueListenableBuilder pour mise à jour automatique
|
||||
Widget _buildWithValueListenable() {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
||||
valueListenable:
|
||||
Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
||||
builder: (context, Box<PassageModel> passagesBox, child) {
|
||||
final chartData = _calculatePassageData(passagesBox);
|
||||
return _buildChart(chartData);
|
||||
@@ -192,7 +195,7 @@ class _PassagePieChartState extends State<PassagePieChart>
|
||||
|
||||
if (shouldInclude) {
|
||||
passagesByType[passage.fkType] =
|
||||
(passagesByType[passage.fkType] ?? 0) + 1;
|
||||
(passagesByType[passage.fkType] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,7 +207,8 @@ class _PassagePieChartState extends State<PassagePieChart>
|
||||
}
|
||||
|
||||
/// Prépare les données pour le graphique en camembert à partir d'une Map
|
||||
List<PassageChartData> _prepareChartDataFromMap(Map<int, int> passagesByType) {
|
||||
List<PassageChartData> _prepareChartDataFromMap(
|
||||
Map<int, int> passagesByType) {
|
||||
final List<PassageChartData> chartData = [];
|
||||
|
||||
// Créer les données du graphique
|
||||
@@ -247,12 +251,12 @@ class _PassagePieChartState extends State<PassagePieChart>
|
||||
|
||||
final explodeAnimation = CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Interval(0.7, 1.0, curve: Curves.elasticOut),
|
||||
curve: const Interval(0.7, 1.0, curve: Curves.elasticOut),
|
||||
);
|
||||
|
||||
final opacityAnimation = CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Interval(0.1, 0.5, curve: Curves.easeIn),
|
||||
curve: const Interval(0.1, 0.5, curve: Curves.easeIn),
|
||||
);
|
||||
|
||||
return AnimatedBuilder(
|
||||
|
||||
27
app/lib/presentation/widgets/charts/passage_summary_card.dart
Normal file → Executable file
27
app/lib/presentation/widgets/charts/passage_summary_card.dart
Normal file → Executable file
@@ -89,7 +89,8 @@ class PassageSummaryCard extends StatelessWidget {
|
||||
child: Icon(
|
||||
backgroundIcon,
|
||||
size: backgroundIconSize,
|
||||
color: (backgroundIconColor ?? AppTheme.primaryColor).withOpacity(backgroundIconOpacity),
|
||||
color: (backgroundIconColor ?? AppTheme.primaryColor)
|
||||
.withOpacity(backgroundIconOpacity),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -118,7 +119,7 @@ class PassageSummaryCard extends StatelessWidget {
|
||||
? _buildPassagesListWithValueListenable()
|
||||
: _buildPassagesListWithStaticData(),
|
||||
),
|
||||
|
||||
|
||||
// Séparateur vertical
|
||||
if (isDesktop) const VerticalDivider(width: 24),
|
||||
|
||||
@@ -157,7 +158,8 @@ class PassageSummaryCard extends StatelessWidget {
|
||||
/// Construction du titre avec ValueListenableBuilder
|
||||
Widget _buildTitleWithValueListenable() {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
||||
valueListenable:
|
||||
Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
||||
builder: (context, Box<PassageModel> passagesBox, child) {
|
||||
final totalUserPassages = _calculateUserPassagesCount(passagesBox);
|
||||
|
||||
@@ -181,7 +183,8 @@ class PassageSummaryCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
customTotalDisplay?.call(totalUserPassages) ?? totalUserPassages.toString(),
|
||||
customTotalDisplay?.call(totalUserPassages) ??
|
||||
totalUserPassages.toString(),
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -196,8 +199,9 @@ class PassageSummaryCard extends StatelessWidget {
|
||||
|
||||
/// Construction du titre avec données statiques
|
||||
Widget _buildTitleWithStaticData() {
|
||||
final totalPassages = passagesByType?.values.fold(0, (sum, count) => sum + count) ?? 0;
|
||||
|
||||
final totalPassages =
|
||||
passagesByType?.values.fold(0, (sum, count) => sum + count) ?? 0;
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
if (titleIcon != null) ...[
|
||||
@@ -232,7 +236,8 @@ class PassageSummaryCard extends StatelessWidget {
|
||||
/// Construction de la liste des passages avec ValueListenableBuilder
|
||||
Widget _buildPassagesListWithValueListenable() {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
||||
valueListenable:
|
||||
Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
||||
builder: (context, Box<PassageModel> passagesBox, child) {
|
||||
final passagesCounts = _calculatePassagesCounts(passagesBox);
|
||||
|
||||
@@ -293,7 +298,7 @@ class PassageSummaryCard extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -309,7 +314,7 @@ class PassageSummaryCard extends StatelessWidget {
|
||||
// Pour les utilisateurs : seulement leurs passages
|
||||
final currentUser = userRepository.getCurrentUser();
|
||||
final targetUserId = userId ?? currentUser?.id;
|
||||
|
||||
|
||||
if (targetUserId == null) return 0;
|
||||
|
||||
return passagesBox.values
|
||||
@@ -338,7 +343,7 @@ class PassageSummaryCard extends StatelessWidget {
|
||||
// Pour les utilisateurs : compter seulement leurs passages
|
||||
final currentUser = userRepository.getCurrentUser();
|
||||
final targetUserId = userId ?? currentUser?.id;
|
||||
|
||||
|
||||
if (targetUserId != null) {
|
||||
for (final passage in passagesBox.values) {
|
||||
if (passage.fkUser == targetUserId) {
|
||||
@@ -350,4 +355,4 @@ class PassageSummaryCard extends StatelessWidget {
|
||||
|
||||
return counts;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
0
app/lib/presentation/widgets/charts/passage_utils.dart
Normal file → Executable file
0
app/lib/presentation/widgets/charts/passage_utils.dart
Normal file → Executable file
0
app/lib/presentation/widgets/charts/payment_data.dart
Normal file → Executable file
0
app/lib/presentation/widgets/charts/payment_data.dart
Normal file → Executable file
158
app/lib/presentation/widgets/charts/payment_pie_chart.dart
Normal file → Executable file
158
app/lib/presentation/widgets/charts/payment_pie_chart.dart
Normal file → Executable file
@@ -102,15 +102,15 @@ class _PaymentPieChartState extends State<PaymentPieChart>
|
||||
} else if (!widget.useValueListenable) {
|
||||
// Pour les données statiques, comparer les éléments
|
||||
if (oldWidget.payments.length != widget.payments.length) {
|
||||
shouldResetAnimation = true;
|
||||
} else {
|
||||
shouldResetAnimation = true;
|
||||
} else {
|
||||
for (int i = 0; i < oldWidget.payments.length; i++) {
|
||||
if (i >= widget.payments.length) break;
|
||||
if (oldWidget.payments[i].amount != widget.payments[i].amount ||
|
||||
oldWidget.payments[i].title != widget.payments[i].title) {
|
||||
shouldResetAnimation = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,20 +131,21 @@ class _PaymentPieChartState extends State<PaymentPieChart>
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.useValueListenable) {
|
||||
return _buildWithValueListenable();
|
||||
} else {
|
||||
} else {
|
||||
return _buildWithStaticData();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Construction du widget avec ValueListenableBuilder pour mise à jour automatique
|
||||
Widget _buildWithValueListenable() {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
||||
valueListenable:
|
||||
Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
||||
builder: (context, Box<PassageModel> passagesBox, child) {
|
||||
final paymentData = _calculatePaymentData(passagesBox);
|
||||
return _buildChart(paymentData);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Construction du widget avec des données statiques
|
||||
@@ -153,85 +154,86 @@ class _PaymentPieChartState extends State<PaymentPieChart>
|
||||
}
|
||||
|
||||
/// Calcule les données de règlement depuis la Hive box
|
||||
List<PaymentData> _calculatePaymentData(Box<PassageModel> passagesBox) {
|
||||
try {
|
||||
final passages = passagesBox.values.toList();
|
||||
final currentUser = userRepository.getCurrentUser();
|
||||
final int? currentUserId = widget.userId ?? currentUser?.id;
|
||||
List<PaymentData> _calculatePaymentData(Box<PassageModel> passagesBox) {
|
||||
try {
|
||||
final passages = passagesBox.values.toList();
|
||||
final currentUser = userRepository.getCurrentUser();
|
||||
final int? currentUserId = widget.userId ?? currentUser?.id;
|
||||
|
||||
// Initialiser les montants par type de règlement
|
||||
final Map<int, double> paymentAmounts = {
|
||||
0: 0.0, // Pas de règlement
|
||||
1: 0.0, // Espèces
|
||||
2: 0.0, // Chèques
|
||||
3: 0.0, // CB
|
||||
};
|
||||
// Initialiser les montants par type de règlement
|
||||
final Map<int, double> paymentAmounts = {
|
||||
0: 0.0, // Pas de règlement
|
||||
1: 0.0, // Espèces
|
||||
2: 0.0, // Chèques
|
||||
3: 0.0, // CB
|
||||
};
|
||||
|
||||
// Parcourir les passages et calculer les montants par type de règlement
|
||||
for (final passage in passages) {
|
||||
// Vérifier si le passage appartient à l'utilisateur actuel
|
||||
if (currentUserId != null && passage.fkUser == currentUserId) {
|
||||
final int typeReglement = passage.fkTypeReglement;
|
||||
// Parcourir les passages et calculer les montants par type de règlement
|
||||
for (final passage in passages) {
|
||||
// Vérifier si le passage appartient à l'utilisateur actuel
|
||||
if (currentUserId != null && passage.fkUser == currentUserId) {
|
||||
final int typeReglement = passage.fkTypeReglement;
|
||||
|
||||
// Convertir la chaîne de montant en double
|
||||
double montant = 0.0;
|
||||
try {
|
||||
// Gérer les formats possibles (virgule ou point)
|
||||
String montantStr = passage.montant.replaceAll(',', '.');
|
||||
montant = double.tryParse(montantStr) ?? 0.0;
|
||||
} catch (e) {
|
||||
debugPrint('Erreur de conversion du montant: ${passage.montant}');
|
||||
}
|
||||
// Convertir la chaîne de montant en double
|
||||
double montant = 0.0;
|
||||
try {
|
||||
// Gérer les formats possibles (virgule ou point)
|
||||
String montantStr = passage.montant.replaceAll(',', '.');
|
||||
montant = double.tryParse(montantStr) ?? 0.0;
|
||||
} catch (e) {
|
||||
debugPrint('Erreur de conversion du montant: ${passage.montant}');
|
||||
}
|
||||
|
||||
// Ne compter que les passages avec un montant > 0
|
||||
if (montant > 0) {
|
||||
// Ajouter au montant total par type de règlement
|
||||
if (paymentAmounts.containsKey(typeReglement)) {
|
||||
paymentAmounts[typeReglement] =
|
||||
(paymentAmounts[typeReglement] ?? 0.0) + montant;
|
||||
} else {
|
||||
// Si le type n'est pas dans notre map, l'ajouter à la catégorie par défaut
|
||||
paymentAmounts[0] = (paymentAmounts[0] ?? 0.0) + montant;
|
||||
// Ne compter que les passages avec un montant > 0
|
||||
if (montant > 0) {
|
||||
// Ajouter au montant total par type de règlement
|
||||
if (paymentAmounts.containsKey(typeReglement)) {
|
||||
paymentAmounts[typeReglement] =
|
||||
(paymentAmounts[typeReglement] ?? 0.0) + montant;
|
||||
} else {
|
||||
// Si le type n'est pas dans notre map, l'ajouter à la catégorie par défaut
|
||||
paymentAmounts[0] = (paymentAmounts[0] ?? 0.0) + montant;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convertir le Map en List<PaymentData>
|
||||
final List<PaymentData> paymentDataList = [];
|
||||
// Convertir le Map en List<PaymentData>
|
||||
final List<PaymentData> paymentDataList = [];
|
||||
|
||||
paymentAmounts.forEach((typeReglement, montant) {
|
||||
if (montant > 0) { // Ne retourner que les types avec un montant > 0
|
||||
// Récupérer les informations depuis AppKeys.typesReglements
|
||||
final reglementInfo = AppKeys.typesReglements[typeReglement];
|
||||
|
||||
if (reglementInfo != null) {
|
||||
paymentDataList.add(PaymentData(
|
||||
typeId: typeReglement,
|
||||
title: reglementInfo['titre'] as String,
|
||||
amount: montant,
|
||||
color: Color(reglementInfo['couleur'] as int),
|
||||
icon: reglementInfo['icon_data'] as IconData,
|
||||
));
|
||||
} else {
|
||||
// Fallback pour les types non définis
|
||||
paymentDataList.add(PaymentData(
|
||||
typeId: typeReglement,
|
||||
title: 'Type inconnu',
|
||||
amount: montant,
|
||||
color: Colors.grey,
|
||||
icon: Icons.help_outline,
|
||||
));
|
||||
paymentAmounts.forEach((typeReglement, montant) {
|
||||
if (montant > 0) {
|
||||
// Ne retourner que les types avec un montant > 0
|
||||
// Récupérer les informations depuis AppKeys.typesReglements
|
||||
final reglementInfo = AppKeys.typesReglements[typeReglement];
|
||||
|
||||
if (reglementInfo != null) {
|
||||
paymentDataList.add(PaymentData(
|
||||
typeId: typeReglement,
|
||||
title: reglementInfo['titre'] as String,
|
||||
amount: montant,
|
||||
color: Color(reglementInfo['couleur'] as int),
|
||||
icon: reglementInfo['icon_data'] as IconData,
|
||||
));
|
||||
} else {
|
||||
// Fallback pour les types non définis
|
||||
paymentDataList.add(PaymentData(
|
||||
typeId: typeReglement,
|
||||
title: 'Type inconnu',
|
||||
amount: montant,
|
||||
color: Colors.grey,
|
||||
icon: Icons.help_outline,
|
||||
));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return paymentDataList;
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du calcul des données de règlement: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return paymentDataList;
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du calcul des données de règlement: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Construit le graphique avec les données fournies
|
||||
Widget _buildChart(List<PaymentData> paymentData) {
|
||||
@@ -256,12 +258,12 @@ paymentAmounts.forEach((typeReglement, montant) {
|
||||
|
||||
final explodeAnimation = CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Interval(0.7, 1.0, curve: Curves.elasticOut),
|
||||
curve: const Interval(0.7, 1.0, curve: Curves.elasticOut),
|
||||
);
|
||||
|
||||
final opacityAnimation = CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Interval(0.1, 0.5, curve: Curves.easeIn),
|
||||
curve: const Interval(0.1, 0.5, curve: Curves.easeIn),
|
||||
);
|
||||
|
||||
return AnimatedBuilder(
|
||||
|
||||
49
app/lib/presentation/widgets/charts/payment_summary_card.dart
Normal file → Executable file
49
app/lib/presentation/widgets/charts/payment_summary_card.dart
Normal file → Executable file
@@ -86,7 +86,8 @@ class PaymentSummaryCard extends StatelessWidget {
|
||||
child: Icon(
|
||||
backgroundIcon,
|
||||
size: backgroundIconSize,
|
||||
color: (backgroundIconColor ?? Colors.blue).withOpacity(backgroundIconOpacity),
|
||||
color: (backgroundIconColor ?? Colors.blue)
|
||||
.withOpacity(backgroundIconOpacity),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -115,7 +116,7 @@ class PaymentSummaryCard extends StatelessWidget {
|
||||
? _buildPaymentsListWithValueListenable()
|
||||
: _buildPaymentsListWithStaticData(),
|
||||
),
|
||||
|
||||
|
||||
// Séparateur vertical
|
||||
if (isDesktop) const VerticalDivider(width: 24),
|
||||
|
||||
@@ -126,7 +127,10 @@ class PaymentSummaryCard extends StatelessWidget {
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: PaymentPieChart(
|
||||
useValueListenable: useValueListenable,
|
||||
payments: useValueListenable ? [] : _convertMapToPaymentData(paymentsByType ?? {}),
|
||||
payments: useValueListenable
|
||||
? []
|
||||
: _convertMapToPaymentData(
|
||||
paymentsByType ?? {}),
|
||||
userId: showAllPayments ? null : userId,
|
||||
size: double.infinity,
|
||||
labelSize: 12,
|
||||
@@ -157,7 +161,8 @@ class PaymentSummaryCard extends StatelessWidget {
|
||||
/// Construction du titre avec ValueListenableBuilder
|
||||
Widget _buildTitleWithValueListenable() {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
||||
valueListenable:
|
||||
Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
||||
builder: (context, Box<PassageModel> passagesBox, child) {
|
||||
final paymentStats = _calculatePaymentStats(passagesBox);
|
||||
|
||||
@@ -181,8 +186,8 @@ class PaymentSummaryCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
customTotalDisplay?.call(paymentStats['totalAmount']) ??
|
||||
'${paymentStats['totalAmount'].toStringAsFixed(2)} €',
|
||||
customTotalDisplay?.call(paymentStats['totalAmount']) ??
|
||||
'${paymentStats['totalAmount'].toStringAsFixed(2)} €',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -197,8 +202,9 @@ class PaymentSummaryCard extends StatelessWidget {
|
||||
|
||||
/// Construction du titre avec données statiques
|
||||
Widget _buildTitleWithStaticData() {
|
||||
final totalAmount = paymentsByType?.values.fold(0.0, (sum, amount) => sum + amount) ?? 0.0;
|
||||
|
||||
final totalAmount =
|
||||
paymentsByType?.values.fold(0.0, (sum, amount) => sum + amount) ?? 0.0;
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
if (titleIcon != null) ...[
|
||||
@@ -219,7 +225,8 @@ class PaymentSummaryCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
customTotalDisplay?.call(totalAmount) ?? '${totalAmount.toStringAsFixed(2)} €',
|
||||
customTotalDisplay?.call(totalAmount) ??
|
||||
'${totalAmount.toStringAsFixed(2)} €',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -233,7 +240,8 @@ class PaymentSummaryCard extends StatelessWidget {
|
||||
/// Construction de la liste des règlements avec ValueListenableBuilder
|
||||
Widget _buildPaymentsListWithValueListenable() {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
||||
valueListenable:
|
||||
Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
||||
builder: (context, Box<PassageModel> passagesBox, child) {
|
||||
final paymentAmounts = _calculatePaymentAmounts(passagesBox);
|
||||
|
||||
@@ -294,7 +302,7 @@ class PaymentSummaryCard extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -330,7 +338,7 @@ class PaymentSummaryCard extends StatelessWidget {
|
||||
// Pour les utilisateurs : seulement leurs règlements
|
||||
final currentUser = userRepository.getCurrentUser();
|
||||
final targetUserId = userId ?? currentUser?.id;
|
||||
|
||||
|
||||
if (targetUserId == null) {
|
||||
return {'passagesCount': 0, 'totalAmount': 0.0};
|
||||
}
|
||||
@@ -388,7 +396,8 @@ class PaymentSummaryCard extends StatelessWidget {
|
||||
|
||||
if (montant > 0) {
|
||||
if (paymentAmounts.containsKey(typeReglement)) {
|
||||
paymentAmounts[typeReglement] = (paymentAmounts[typeReglement] ?? 0.0) + montant;
|
||||
paymentAmounts[typeReglement] =
|
||||
(paymentAmounts[typeReglement] ?? 0.0) + montant;
|
||||
} else {
|
||||
paymentAmounts[0] = (paymentAmounts[0] ?? 0.0) + montant;
|
||||
}
|
||||
@@ -398,7 +407,7 @@ class PaymentSummaryCard extends StatelessWidget {
|
||||
// Pour les utilisateurs : compter seulement leurs règlements
|
||||
final currentUser = userRepository.getCurrentUser();
|
||||
final targetUserId = userId ?? currentUser?.id;
|
||||
|
||||
|
||||
if (targetUserId != null) {
|
||||
for (final passage in passagesBox.values) {
|
||||
if (passage.fkUser == targetUserId) {
|
||||
@@ -415,7 +424,8 @@ class PaymentSummaryCard extends StatelessWidget {
|
||||
|
||||
if (montant > 0) {
|
||||
if (paymentAmounts.containsKey(typeReglement)) {
|
||||
paymentAmounts[typeReglement] = (paymentAmounts[typeReglement] ?? 0.0) + montant;
|
||||
paymentAmounts[typeReglement] =
|
||||
(paymentAmounts[typeReglement] ?? 0.0) + montant;
|
||||
} else {
|
||||
paymentAmounts[0] = (paymentAmounts[0] ?? 0.0) + montant;
|
||||
}
|
||||
@@ -433,10 +443,11 @@ class PaymentSummaryCard extends StatelessWidget {
|
||||
final List<PaymentData> paymentDataList = [];
|
||||
|
||||
paymentsMap.forEach((typeReglement, montant) {
|
||||
if (montant > 0) { // Ne retourner que les types avec un montant > 0
|
||||
if (montant > 0) {
|
||||
// Ne retourner que les types avec un montant > 0
|
||||
// Récupérer les informations depuis AppKeys.typesReglements
|
||||
final reglementInfo = AppKeys.typesReglements[typeReglement];
|
||||
|
||||
|
||||
if (reglementInfo != null) {
|
||||
paymentDataList.add(PaymentData(
|
||||
typeId: typeReglement,
|
||||
@@ -457,7 +468,7 @@ class PaymentSummaryCard extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
return paymentDataList;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
4
app/lib/presentation/widgets/chat/chat_input.dart
Normal file → Executable file
4
app/lib/presentation/widgets/chat/chat_input.dart
Normal file → Executable file
@@ -6,9 +6,9 @@ class ChatInput extends StatefulWidget {
|
||||
final Function(String) onMessageSent;
|
||||
|
||||
const ChatInput({
|
||||
Key? key,
|
||||
super.key,
|
||||
required this.onMessageSent,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
State<ChatInput> createState() => _ChatInputState();
|
||||
|
||||
4
app/lib/presentation/widgets/chat/chat_messages.dart
Normal file → Executable file
4
app/lib/presentation/widgets/chat/chat_messages.dart
Normal file → Executable file
@@ -8,11 +8,11 @@ class ChatMessages extends StatelessWidget {
|
||||
final Function(Map<String, dynamic>) onReply;
|
||||
|
||||
const ChatMessages({
|
||||
Key? key,
|
||||
super.key,
|
||||
required this.messages,
|
||||
required this.currentUserId,
|
||||
required this.onReply,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
6
app/lib/presentation/widgets/chat/chat_sidebar.dart
Normal file → Executable file
6
app/lib/presentation/widgets/chat/chat_sidebar.dart
Normal file → Executable file
@@ -11,14 +11,14 @@ class ChatSidebar extends StatelessWidget {
|
||||
final Function(bool) onToggleGroup;
|
||||
|
||||
const ChatSidebar({
|
||||
Key? key,
|
||||
super.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) {
|
||||
@@ -178,7 +178,7 @@ class ChatSidebar extends StatelessWidget {
|
||||
if (hasUnread)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
decoration: const BoxDecoration(
|
||||
color: AppTheme.primaryColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
|
||||
8
app/lib/presentation/widgets/clear_cache_dialog.dart
Normal file → Executable file
8
app/lib/presentation/widgets/clear_cache_dialog.dart
Normal file → Executable file
@@ -7,9 +7,9 @@ class ClearCacheDialog extends StatelessWidget {
|
||||
final VoidCallback? onClose;
|
||||
|
||||
const ClearCacheDialog({
|
||||
Key? key,
|
||||
super.key,
|
||||
this.onClose,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
/// Affiche le dialogue de nettoyage du cache
|
||||
static Future<void> show(BuildContext context,
|
||||
@@ -34,7 +34,7 @@ class ClearCacheDialog extends StatelessWidget {
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(
|
||||
const Icon(
|
||||
Icons.warning_amber_rounded,
|
||||
color: Colors.orange,
|
||||
size: 28,
|
||||
@@ -120,7 +120,7 @@ class ClearCacheDialog extends StatelessWidget {
|
||||
child: Center(
|
||||
child: Text(
|
||||
'$stepNumber',
|
||||
style: TextStyle(
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
|
||||
1
app/lib/presentation/widgets/connectivity_indicator.dart
Normal file → Executable file
1
app/lib/presentation/widgets/connectivity_indicator.dart
Normal file → Executable file
@@ -1,5 +1,4 @@
|
||||
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
|
||||
|
||||
|
||||
0
app/lib/presentation/widgets/custom_button.dart
Normal file → Executable file
0
app/lib/presentation/widgets/custom_button.dart
Normal file → Executable file
56
app/lib/presentation/widgets/custom_text_field.dart
Normal file → Executable file
56
app/lib/presentation/widgets/custom_text_field.dart
Normal file → Executable file
@@ -21,6 +21,11 @@ class CustomTextField extends StatelessWidget {
|
||||
final bool obscureText;
|
||||
final Function(String)? onChanged;
|
||||
final Function(String)? onFieldSubmitted;
|
||||
|
||||
// Nouvelles propriétés pour le formulaire de passage
|
||||
final TextAlign? textAlign;
|
||||
final bool showLabel;
|
||||
final EdgeInsets? contentPadding;
|
||||
|
||||
const CustomTextField({
|
||||
super.key,
|
||||
@@ -43,12 +48,60 @@ class CustomTextField extends StatelessWidget {
|
||||
this.obscureText = false,
|
||||
this.onChanged,
|
||||
this.onFieldSubmitted,
|
||||
// Nouvelles propriétés pour le formulaire de passage
|
||||
this.textAlign,
|
||||
this.showLabel = true,
|
||||
this.contentPadding,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
// Mode sans label externe (pour utilisation dans des sections avec titres flottants)
|
||||
if (!showLabel) {
|
||||
return TextFormField(
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
readOnly: readOnly,
|
||||
autofocus: autofocus,
|
||||
onTap: onTap,
|
||||
validator: validator,
|
||||
keyboardType: keyboardType,
|
||||
inputFormatters: inputFormatters,
|
||||
maxLines: maxLines,
|
||||
maxLength: maxLength,
|
||||
obscureText: obscureText,
|
||||
onChanged: onChanged,
|
||||
onFieldSubmitted: onFieldSubmitted,
|
||||
textAlign: textAlign ?? TextAlign.start,
|
||||
decoration: InputDecoration(
|
||||
labelText: isRequired ? "$label *" : label,
|
||||
hintText: hintText,
|
||||
helperText: helperText,
|
||||
prefixIcon: prefixIcon != null ? Icon(prefixIcon) : null,
|
||||
suffixIcon: suffixIcon,
|
||||
border: const OutlineInputBorder(),
|
||||
floatingLabelBehavior: FloatingLabelBehavior.always,
|
||||
contentPadding: contentPadding ?? const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
),
|
||||
buildCounter: maxLength != null
|
||||
? (context, {required currentLength, required isFocused, maxLength}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
'$currentLength/${maxLength ?? 0}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: currentLength > (maxLength ?? 0) * 0.8 ? theme.colorScheme.error : theme.colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
// Mode standard avec label externe
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -93,6 +146,7 @@ class CustomTextField extends StatelessWidget {
|
||||
obscureText: obscureText,
|
||||
onChanged: onChanged,
|
||||
onFieldSubmitted: onFieldSubmitted,
|
||||
textAlign: textAlign ?? TextAlign.start,
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
helperText: helperText,
|
||||
@@ -133,7 +187,7 @@ class CustomTextField extends StatelessWidget {
|
||||
),
|
||||
filled: true,
|
||||
fillColor: readOnly ? theme.colorScheme.surfaceContainerHighest.withOpacity(0.3) : theme.colorScheme.surface,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
contentPadding: contentPadding ?? const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
|
||||
257
app/lib/presentation/widgets/dashboard_app_bar.dart
Normal file → Executable file
257
app/lib/presentation/widgets/dashboard_app_bar.dart
Normal file → Executable file
@@ -3,7 +3,10 @@ import 'package:geosector_app/app.dart';
|
||||
import 'package:geosector_app/core/services/app_info_service.dart';
|
||||
import 'package:geosector_app/presentation/widgets/connectivity_indicator.dart';
|
||||
import 'package:geosector_app/presentation/widgets/user_form_dialog.dart';
|
||||
import 'package:geosector_app/presentation/widgets/passage_form_dialog.dart';
|
||||
import 'package:geosector_app/core/utils/api_exception.dart';
|
||||
import 'package:geosector_app/core/services/theme_service.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
/// AppBar personnalisée pour les tableaux de bord
|
||||
@@ -40,13 +43,23 @@ class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
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),
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AppBar(
|
||||
title: _buildTitle(context),
|
||||
backgroundColor: theme.colorScheme.primary,
|
||||
foregroundColor: theme.colorScheme.onPrimary,
|
||||
elevation: 4,
|
||||
leading: _buildLogo(),
|
||||
actions: _buildActions(context),
|
||||
),
|
||||
// Bordure colorée selon le rôle
|
||||
Container(
|
||||
height: 3,
|
||||
color: isAdmin ? Colors.red : Colors.green,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -98,19 +111,49 @@ class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
|
||||
actions.add(const SizedBox(width: 8));
|
||||
|
||||
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 "Nouveau passage" seulement si l'utilisateur n'est pas admin
|
||||
if (!isAdmin) {
|
||||
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: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (dialogContext) => PassageFormDialog(
|
||||
title: 'Nouveau passage',
|
||||
passageRepository: passageRepository,
|
||||
userRepository: userRepository,
|
||||
operationRepository: operationRepository,
|
||||
onSuccess: () {
|
||||
// Callback après création du passage
|
||||
if (onNewPassagePressed != null) {
|
||||
onNewPassagePressed!();
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
backgroundColor: Color(AppKeys.typesPassages[1]!['couleur1']
|
||||
as int), // Vert des passages effectués
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
);
|
||||
|
||||
actions.add(const SizedBox(width: 8));
|
||||
actions.add(const SizedBox(width: 8));
|
||||
}
|
||||
|
||||
// Ajouter le sélecteur de thème avec confirmation (désactivé temporairement)
|
||||
// TODO: Réactiver quand le thème sombre sera corrigé
|
||||
// actions.add(
|
||||
// _buildThemeSwitcherWithConfirmation(context),
|
||||
// );
|
||||
//
|
||||
// actions.add(const SizedBox(width: 8));
|
||||
|
||||
// Ajouter le bouton "Mon compte"
|
||||
actions.add(
|
||||
@@ -163,13 +206,17 @@ class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
IconButton(
|
||||
icon: const Icon(Icons.logout),
|
||||
tooltip: 'Déconnexion',
|
||||
style: IconButton.styleFrom(
|
||||
foregroundColor: Colors.red,
|
||||
),
|
||||
onPressed: onLogoutPressed ??
|
||||
() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Déconnexion'),
|
||||
content: const Text('Voulez-vous vraiment vous déconnecter ?'),
|
||||
content:
|
||||
const Text('Voulez-vous vraiment vous déconnecter ?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
@@ -186,10 +233,12 @@ class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
// Vérification supplémentaire et navigation forcée si nécessaire
|
||||
if (success && context.mounted) {
|
||||
// Attendre un court instant pour que les changements d'état se propagent
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
await Future.delayed(
|
||||
const Duration(milliseconds: 100));
|
||||
|
||||
// Navigation forcée vers la page d'accueil
|
||||
context.go('/');
|
||||
// Navigation vers splash avec paramètres pour redirection automatique
|
||||
final loginType = isAdmin ? 'admin' : 'user';
|
||||
context.go('/?action=login&type=$loginType');
|
||||
}
|
||||
},
|
||||
child: const Text('Déconnexion'),
|
||||
@@ -215,16 +264,164 @@ class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
|
||||
// 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!),
|
||||
],
|
||||
|
||||
// Utiliser LayoutBuilder pour détecter la largeur disponible
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Déterminer si on est sur mobile ou écran étroit
|
||||
final isNarrowScreen = constraints.maxWidth < 600;
|
||||
final isMobilePlatform = Theme.of(context).platform == TargetPlatform.android ||
|
||||
Theme.of(context).platform == TargetPlatform.iOS;
|
||||
|
||||
// Cacher le titre de page sur mobile ou écrans étroits
|
||||
if (isNarrowScreen || isMobilePlatform) {
|
||||
return Text(prefix);
|
||||
}
|
||||
|
||||
// Afficher le titre complet sur écrans larges (web desktop)
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(prefix),
|
||||
const Text(' - '),
|
||||
Text(pageTitle!),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Construction du sélecteur de thème avec confirmation
|
||||
Widget _buildThemeSwitcherWithConfirmation(BuildContext context) {
|
||||
return IconButton(
|
||||
icon: Icon(ThemeService.instance.themeModeIcon),
|
||||
tooltip:
|
||||
'Changer le thème (${ThemeService.instance.themeModeDescription})',
|
||||
onPressed: () async {
|
||||
final themeService = ThemeService.instance;
|
||||
final currentTheme = themeService.themeModeDescription;
|
||||
|
||||
// Déterminer le prochain thème
|
||||
String nextTheme;
|
||||
switch (themeService.themeMode) {
|
||||
case ThemeMode.light:
|
||||
nextTheme = 'Sombre';
|
||||
break;
|
||||
case ThemeMode.dark:
|
||||
nextTheme = 'Clair';
|
||||
break;
|
||||
case ThemeMode.system:
|
||||
nextTheme = themeService.isSystemDark ? 'Clair' : 'Sombre';
|
||||
break;
|
||||
}
|
||||
|
||||
// Afficher la confirmation
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Row(
|
||||
children: [
|
||||
Icon(Icons.palette_outlined),
|
||||
SizedBox(width: 8),
|
||||
Text('Changement de thème'),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Vous êtes actuellement sur le thème :'),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primaryContainer
|
||||
.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
themeService.themeModeIcon,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
currentTheme,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('Voulez-vous passer au thème $nextTheme ?'),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.errorContainer
|
||||
.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber, size: 16),
|
||||
SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Note: Vous devrez vous reconnecter après ce changement.',
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||
child: Text('Passer au thème $nextTheme'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
// Si confirmé, changer le thème
|
||||
if (confirmed == true) {
|
||||
await themeService.toggleTheme();
|
||||
|
||||
// Déconnecter l'utilisateur
|
||||
if (context.mounted) {
|
||||
final success = await userRepository.logout(context);
|
||||
if (success && context.mounted) {
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
// Rediriger vers splash avec paramètres pour revenir au même type de login
|
||||
final loginType = isAdmin ? 'admin' : 'user';
|
||||
context.go('/?action=login&type=$loginType');
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
|
||||
Size get preferredSize =>
|
||||
const Size.fromHeight(kToolbarHeight + 3); // +3 pour la bordure
|
||||
}
|
||||
|
||||
4
app/lib/presentation/widgets/dashboard_layout.dart
Normal file → Executable file
4
app/lib/presentation/widgets/dashboard_layout.dart
Normal file → Executable file
@@ -39,7 +39,7 @@ class DashboardLayout extends StatelessWidget {
|
||||
final VoidCallback? onLogoutPressed;
|
||||
|
||||
const DashboardLayout({
|
||||
Key? key,
|
||||
super.key,
|
||||
required this.body,
|
||||
required this.title,
|
||||
required this.selectedIndex,
|
||||
@@ -51,7 +51,7 @@ class DashboardLayout extends StatelessWidget {
|
||||
this.sidebarBottomItems,
|
||||
this.isAdmin = false,
|
||||
this.onLogoutPressed,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
0
app/lib/presentation/widgets/environment_info_widget.dart
Normal file → Executable file
0
app/lib/presentation/widgets/environment_info_widget.dart
Normal file → Executable file
76
app/lib/presentation/widgets/form_section.dart
Executable file
76
app/lib/presentation/widgets/form_section.dart
Executable file
@@ -0,0 +1,76 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Widget pour créer des sections de formulaire avec titre flottant
|
||||
/// Compatible avec le design du passage_form_dialog
|
||||
class FormSection extends StatelessWidget {
|
||||
final String title;
|
||||
final IconData? icon;
|
||||
final List<Widget> children;
|
||||
final EdgeInsets? padding;
|
||||
final EdgeInsets? margin;
|
||||
final bool showBorder;
|
||||
|
||||
const FormSection({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.icon,
|
||||
required this.children,
|
||||
this.padding,
|
||||
this.margin,
|
||||
this.showBorder = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Container(
|
||||
margin: margin ?? const EdgeInsets.only(top: 8),
|
||||
padding: padding ?? const EdgeInsets.fromLTRB(16, 20, 16, 16),
|
||||
decoration: showBorder ? BoxDecoration(
|
||||
border: Border.all(color: theme.colorScheme.outline),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
) : null,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: children,
|
||||
),
|
||||
),
|
||||
if (title.isNotEmpty)
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 16,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(
|
||||
icon,
|
||||
color: theme.colorScheme.primary,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
4
app/lib/presentation/widgets/help_dialog.dart
Normal file → Executable file
4
app/lib/presentation/widgets/help_dialog.dart
Normal file → Executable file
@@ -8,9 +8,9 @@ class HelpDialog extends StatelessWidget {
|
||||
final String currentPage;
|
||||
|
||||
const HelpDialog({
|
||||
Key? key,
|
||||
super.key,
|
||||
required this.currentPage,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
/// Affiche la boîte de dialogue d'aide
|
||||
static void show(BuildContext context, String currentPage) {
|
||||
|
||||
4
app/lib/presentation/widgets/hive_reset_dialog.dart
Normal file → Executable file
4
app/lib/presentation/widgets/hive_reset_dialog.dart
Normal file → Executable file
@@ -7,9 +7,9 @@ class HiveResetDialog extends StatelessWidget {
|
||||
final VoidCallback? onClose;
|
||||
|
||||
const HiveResetDialog({
|
||||
Key? key,
|
||||
super.key,
|
||||
this.onClose,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
/// Affiche le dialogue de réinitialisation Hive
|
||||
static Future<void> show(BuildContext context,
|
||||
|
||||
7
app/lib/presentation/widgets/loading_overlay.dart
Normal file → Executable file
7
app/lib/presentation/widgets/loading_overlay.dart
Normal file → Executable file
@@ -11,14 +11,14 @@ class LoadingOverlay extends StatelessWidget {
|
||||
final double strokeWidth;
|
||||
|
||||
const LoadingOverlay({
|
||||
Key? key,
|
||||
super.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) {
|
||||
@@ -36,7 +36,8 @@ class LoadingOverlay extends StatelessWidget {
|
||||
strokeWidth: strokeWidth,
|
||||
),
|
||||
),
|
||||
if (message != null) ...[ // Afficher le texte seulement si message n'est pas null
|
||||
if (message != null) ...[
|
||||
// Afficher le texte seulement si message n'est pas null
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
message!,
|
||||
|
||||
8
app/lib/presentation/widgets/loading_progress_overlay.dart
Normal file → Executable file
8
app/lib/presentation/widgets/loading_progress_overlay.dart
Normal file → Executable file
@@ -14,7 +14,7 @@ class LoadingProgressOverlay extends StatefulWidget {
|
||||
final bool showPercentage;
|
||||
|
||||
const LoadingProgressOverlay({
|
||||
Key? key,
|
||||
super.key,
|
||||
this.message,
|
||||
required this.progress,
|
||||
this.stepDescription,
|
||||
@@ -23,7 +23,7 @@ class LoadingProgressOverlay extends StatefulWidget {
|
||||
this.textColor = Colors.white,
|
||||
this.blurAmount = 5.0,
|
||||
this.showPercentage = true,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
State<LoadingProgressOverlay> createState() => _LoadingProgressOverlayState();
|
||||
@@ -82,12 +82,12 @@ class _LoadingProgressOverlayState extends State<LoadingProgressOverlay>
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.9),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Colors.black45,
|
||||
blurRadius: 15,
|
||||
spreadRadius: 5,
|
||||
offset: const Offset(0, 5),
|
||||
offset: Offset(0, 5),
|
||||
),
|
||||
],
|
||||
border: Border.all(
|
||||
|
||||
108
app/lib/presentation/widgets/mapbox_map.dart
Normal file → Executable file
108
app/lib/presentation/widgets/mapbox_map.dart
Normal file → Executable file
@@ -1,5 +1,9 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:flutter_map_cache/flutter_map_cache.dart';
|
||||
import 'package:http_cache_file_store/http_cache_file_store.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/core/services/api_service.dart'; // Import du service singleton
|
||||
@@ -15,12 +19,18 @@ class MapboxMap extends StatefulWidget {
|
||||
/// Niveau de zoom initial
|
||||
final double initialZoom;
|
||||
|
||||
/// Liste des marqueurs à afficher
|
||||
/// Liste des marqueurs à afficher (au-dessus de tout)
|
||||
final List<Marker>? markers;
|
||||
|
||||
/// Liste des marqueurs de labels à afficher (sous les marqueurs principaux)
|
||||
final List<Marker>? labelMarkers;
|
||||
|
||||
/// Liste des polygones à afficher
|
||||
final List<Polygon>? polygons;
|
||||
|
||||
/// Liste des polylines à afficher
|
||||
final List<Polyline>? polylines;
|
||||
|
||||
/// Contrôleur de carte externe (optionnel)
|
||||
final MapController? mapController;
|
||||
|
||||
@@ -34,16 +44,22 @@ class MapboxMap extends StatefulWidget {
|
||||
/// Si non spécifié, utilise le style par défaut 'mapbox/streets-v12'
|
||||
final String? mapStyle;
|
||||
|
||||
/// Désactive le drag de la carte
|
||||
final bool disableDrag;
|
||||
|
||||
const MapboxMap({
|
||||
super.key,
|
||||
this.initialPosition = const LatLng(48.1173, -1.6778), // Rennes par défaut
|
||||
this.initialZoom = 13.0,
|
||||
this.markers,
|
||||
this.labelMarkers,
|
||||
this.polygons,
|
||||
this.polylines,
|
||||
this.mapController,
|
||||
this.onMapEvent,
|
||||
this.showControls = true,
|
||||
this.mapStyle,
|
||||
this.disableDrag = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -57,11 +73,47 @@ class _MapboxMapState extends State<MapboxMap> {
|
||||
/// Niveau de zoom actuel
|
||||
double _currentZoom = 13.0;
|
||||
|
||||
/// Provider de cache pour les tuiles
|
||||
CachedTileProvider? _tileProvider;
|
||||
|
||||
/// Indique si le cache est initialisé
|
||||
bool _cacheInitialized = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_mapController = widget.mapController ?? MapController();
|
||||
_currentZoom = widget.initialZoom;
|
||||
_initializeCache();
|
||||
}
|
||||
|
||||
/// Initialise le cache des tuiles
|
||||
Future<void> _initializeCache() async {
|
||||
try {
|
||||
final dir = await getTemporaryDirectory();
|
||||
final cacheStore = FileCacheStore('${dir.path}${Platform.pathSeparator}MapboxTileCache');
|
||||
|
||||
_tileProvider = CachedTileProvider(
|
||||
store: cacheStore,
|
||||
// Configuration du cache
|
||||
// maxStale permet de servir des tuiles expirées jusqu'à 30 jours
|
||||
maxStale: const Duration(days: 30),
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_cacheInitialized = true;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de l\'initialisation du cache: $e');
|
||||
// En cas d'erreur, on continue sans cache
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_cacheInitialized = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -110,6 +162,42 @@ class _MapboxMapState extends State<MapboxMap> {
|
||||
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';
|
||||
|
||||
// Afficher un indicateur pendant l'initialisation du cache
|
||||
if (!_cacheInitialized) {
|
||||
return Stack(
|
||||
children: [
|
||||
// Carte sans cache en attendant
|
||||
_buildMapContent(urlTemplate, mapboxToken),
|
||||
// Indicateur discret
|
||||
const Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(8.0),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Text('Initialisation du cache...', style: TextStyle(fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return _buildMapContent(urlTemplate, mapboxToken);
|
||||
}
|
||||
|
||||
Widget _buildMapContent(String urlTemplate, String mapboxToken) {
|
||||
return Stack(
|
||||
children: [
|
||||
// Carte principale
|
||||
@@ -118,6 +206,12 @@ class _MapboxMapState extends State<MapboxMap> {
|
||||
options: MapOptions(
|
||||
initialCenter: widget.initialPosition,
|
||||
initialZoom: widget.initialZoom,
|
||||
interactionOptions: InteractionOptions(
|
||||
enableMultiFingerGestureRace: true,
|
||||
flags: widget.disableDrag
|
||||
? InteractiveFlag.all & ~InteractiveFlag.drag
|
||||
: InteractiveFlag.all,
|
||||
),
|
||||
onMapEvent: (event) {
|
||||
if (event is MapEventMove) {
|
||||
setState(() {
|
||||
@@ -141,12 +235,22 @@ class _MapboxMapState extends State<MapboxMap> {
|
||||
additionalOptions: {
|
||||
'accessToken': mapboxToken,
|
||||
},
|
||||
// Utilise le cache si disponible
|
||||
tileProvider: _cacheInitialized && _tileProvider != null
|
||||
? _tileProvider!
|
||||
: NetworkTileProvider(),
|
||||
),
|
||||
|
||||
// Polygones
|
||||
if (widget.polygons != null && widget.polygons!.isNotEmpty) PolygonLayer(polygons: widget.polygons!),
|
||||
|
||||
// Marqueurs de labels (sous les marqueurs principaux)
|
||||
if (widget.labelMarkers != null && widget.labelMarkers!.isNotEmpty) MarkerLayer(markers: widget.labelMarkers!),
|
||||
|
||||
// Marqueurs
|
||||
// Polylines
|
||||
if (widget.polylines != null && widget.polylines!.isNotEmpty) PolylineLayer(polylines: widget.polylines!),
|
||||
|
||||
// Marqueurs principaux (au-dessus de tout)
|
||||
if (widget.markers != null && widget.markers!.isNotEmpty) MarkerLayer(markers: widget.markers!),
|
||||
],
|
||||
),
|
||||
|
||||
12
app/lib/presentation/widgets/membre_row_widget.dart
Normal file → Executable file
12
app/lib/presentation/widgets/membre_row_widget.dart
Normal file → Executable file
@@ -5,6 +5,7 @@ class MembreRowWidget extends StatelessWidget {
|
||||
final MembreModel membre;
|
||||
final Function(MembreModel)? onEdit;
|
||||
final Function(MembreModel)? onDelete;
|
||||
final Function(MembreModel)? onResetPassword;
|
||||
final bool isAlternate;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@@ -13,6 +14,7 @@ class MembreRowWidget extends StatelessWidget {
|
||||
required this.membre,
|
||||
this.onEdit,
|
||||
this.onDelete,
|
||||
this.onResetPassword,
|
||||
this.isAlternate = false,
|
||||
this.onTap,
|
||||
});
|
||||
@@ -103,12 +105,20 @@ class MembreRowWidget extends StatelessWidget {
|
||||
),
|
||||
|
||||
// Actions
|
||||
if (onEdit != null || onDelete != null)
|
||||
if (onEdit != null || onDelete != null || onResetPassword != null)
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
// Bouton reset password (uniquement pour les membres actifs)
|
||||
if (onResetPassword != null && membre.isActive == true)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.lock_reset, size: 22),
|
||||
onPressed: () => onResetPassword!(membre),
|
||||
tooltip: 'Réinitialiser le mot de passe',
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
if (onDelete != null)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete, size: 22),
|
||||
|
||||
7
app/lib/presentation/widgets/membre_table_widget.dart
Normal file → Executable file
7
app/lib/presentation/widgets/membre_table_widget.dart
Normal file → Executable file
@@ -7,6 +7,7 @@ class MembreTableWidget extends StatelessWidget {
|
||||
final List<MembreModel> membres;
|
||||
final Function(MembreModel)? onEdit;
|
||||
final Function(MembreModel)? onDelete;
|
||||
final Function(MembreModel)? onResetPassword;
|
||||
final MembreRepository membreRepository;
|
||||
final bool showHeader;
|
||||
final double? height;
|
||||
@@ -20,6 +21,7 @@ class MembreTableWidget extends StatelessWidget {
|
||||
required this.membreRepository,
|
||||
this.onEdit,
|
||||
this.onDelete,
|
||||
this.onResetPassword,
|
||||
this.showHeader = true,
|
||||
this.height,
|
||||
this.padding,
|
||||
@@ -131,8 +133,8 @@ class MembreTableWidget extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
|
||||
// Actions (si onEdit ou onDelete sont fournis)
|
||||
if (onEdit != null || onDelete != null)
|
||||
// Actions (si onEdit, onDelete ou onResetPassword sont fournis)
|
||||
if (onEdit != null || onDelete != null || onResetPassword != null)
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Text(
|
||||
@@ -188,6 +190,7 @@ class MembreTableWidget extends StatelessWidget {
|
||||
membre: membre,
|
||||
onEdit: onEdit,
|
||||
onDelete: onDelete,
|
||||
onResetPassword: onResetPassword,
|
||||
isAlternate: index % 2 == 1,
|
||||
onTap: onEdit != null ? () => onEdit!(membre) : null,
|
||||
);
|
||||
|
||||
0
app/lib/presentation/widgets/operation_form_dialog.dart
Normal file → Executable file
0
app/lib/presentation/widgets/operation_form_dialog.dart
Normal file → Executable file
1058
app/lib/presentation/widgets/passage_form_dialog.dart
Executable file
1058
app/lib/presentation/widgets/passage_form_dialog.dart
Executable file
File diff suppressed because it is too large
Load Diff
242
app/lib/presentation/widgets/passage_form_modernized_example.dart
Executable file
242
app/lib/presentation/widgets/passage_form_modernized_example.dart
Executable file
@@ -0,0 +1,242 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/presentation/widgets/custom_text_field.dart';
|
||||
import 'package:geosector_app/presentation/widgets/form_section.dart';
|
||||
|
||||
/// Exemple d'utilisation modernisée du formulaire de passage
|
||||
/// utilisant CustomTextField et FormSection adaptés
|
||||
class PassageFormModernizedExample extends StatefulWidget {
|
||||
final bool readOnly;
|
||||
|
||||
const PassageFormModernizedExample({
|
||||
super.key,
|
||||
this.readOnly = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PassageFormModernizedExample> createState() => _PassageFormModernizedExampleState();
|
||||
}
|
||||
|
||||
class _PassageFormModernizedExampleState extends State<PassageFormModernizedExample> {
|
||||
// Controllers pour l'exemple
|
||||
final _numeroController = TextEditingController();
|
||||
final _rueBisController = TextEditingController();
|
||||
final _rueController = TextEditingController();
|
||||
final _villeController = TextEditingController();
|
||||
final _dateController = TextEditingController();
|
||||
final _timeController = TextEditingController();
|
||||
final _nameController = TextEditingController();
|
||||
final _emailController = TextEditingController();
|
||||
final _phoneController = TextEditingController();
|
||||
final _montantController = TextEditingController();
|
||||
final _remarqueController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_numeroController.dispose();
|
||||
_rueBisController.dispose();
|
||||
_rueController.dispose();
|
||||
_villeController.dispose();
|
||||
_dateController.dispose();
|
||||
_timeController.dispose();
|
||||
_nameController.dispose();
|
||||
_emailController.dispose();
|
||||
_phoneController.dispose();
|
||||
_montantController.dispose();
|
||||
_remarqueController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _selectDate() {
|
||||
// Logique de sélection de date
|
||||
}
|
||||
|
||||
void _selectTime() {
|
||||
// Logique de sélection d'heure
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Formulaire Modernisé')),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
// Section Date et Heure avec FormSection
|
||||
FormSection(
|
||||
title: 'Date et Heure de passage',
|
||||
icon: Icons.schedule,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: CustomTextField(
|
||||
controller: _dateController,
|
||||
label: "Date",
|
||||
isRequired: true,
|
||||
readOnly: true,
|
||||
showLabel: false,
|
||||
hintText: "DD/MM/YYYY",
|
||||
suffixIcon: const Icon(Icons.calendar_today),
|
||||
onTap: widget.readOnly ? null : _selectDate,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: CustomTextField(
|
||||
controller: _timeController,
|
||||
label: "Heure",
|
||||
isRequired: true,
|
||||
readOnly: true,
|
||||
showLabel: false,
|
||||
hintText: "HH:MM",
|
||||
suffixIcon: const Icon(Icons.access_time),
|
||||
onTap: widget.readOnly ? null : _selectTime,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Section Adresse
|
||||
FormSection(
|
||||
title: 'Adresse',
|
||||
icon: Icons.location_on,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: CustomTextField(
|
||||
controller: _numeroController,
|
||||
label: "Numéro",
|
||||
isRequired: true,
|
||||
showLabel: false,
|
||||
textAlign: TextAlign.right,
|
||||
keyboardType: TextInputType.number,
|
||||
readOnly: widget.readOnly,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: CustomTextField(
|
||||
controller: _rueBisController,
|
||||
label: "Bis/Ter",
|
||||
showLabel: false,
|
||||
readOnly: widget.readOnly,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
CustomTextField(
|
||||
controller: _rueController,
|
||||
label: "Rue",
|
||||
isRequired: true,
|
||||
showLabel: false,
|
||||
readOnly: widget.readOnly,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
CustomTextField(
|
||||
controller: _villeController,
|
||||
label: "Ville",
|
||||
isRequired: true,
|
||||
showLabel: false,
|
||||
readOnly: widget.readOnly,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Section Occupant
|
||||
FormSection(
|
||||
title: 'Occupant',
|
||||
icon: Icons.person,
|
||||
children: [
|
||||
CustomTextField(
|
||||
controller: _nameController,
|
||||
label: "Nom de l'occupant",
|
||||
isRequired: true,
|
||||
showLabel: false,
|
||||
readOnly: widget.readOnly,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: CustomTextField(
|
||||
controller: _emailController,
|
||||
label: "Email",
|
||||
showLabel: false,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
readOnly: widget.readOnly,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: CustomTextField(
|
||||
controller: _phoneController,
|
||||
label: "Téléphone",
|
||||
showLabel: false,
|
||||
keyboardType: TextInputType.phone,
|
||||
readOnly: widget.readOnly,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Section Règlement (sans bordure)
|
||||
FormSection(
|
||||
title: '',
|
||||
showBorder: true,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: CustomTextField(
|
||||
controller: _montantController,
|
||||
label: "Montant (€)",
|
||||
isRequired: true,
|
||||
showLabel: false,
|
||||
hintText: "0.00",
|
||||
textAlign: TextAlign.right,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
readOnly: widget.readOnly,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Theme.of(context).colorScheme.outline),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Text("Dropdown de type de règlement"),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
CustomTextField(
|
||||
controller: _remarqueController,
|
||||
label: "Remarque",
|
||||
showLabel: false,
|
||||
hintText: "Commentaire sur le passage...",
|
||||
maxLines: 2,
|
||||
readOnly: widget.readOnly,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
180
app/lib/presentation/widgets/passage_validation_helpers.dart
Executable file
180
app/lib/presentation/widgets/passage_validation_helpers.dart
Executable file
@@ -0,0 +1,180 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Helpers de validation pour le formulaire de passage
|
||||
class PassageValidationHelpers {
|
||||
|
||||
/// Validation pour numéro de rue
|
||||
static String? validateNumero(String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Le numéro est obligatoire';
|
||||
}
|
||||
|
||||
final numero = int.tryParse(value.trim());
|
||||
if (numero == null || numero <= 0) {
|
||||
return 'Numéro invalide';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Validation pour rue
|
||||
static String? validateRue(String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'La rue est obligatoire';
|
||||
}
|
||||
|
||||
if (value.trim().length < 3) {
|
||||
return 'La rue doit contenir au moins 3 caractères';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Validation pour ville
|
||||
static String? validateVille(String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'La ville est obligatoire';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Validation pour nom d'occupant (selon type de passage)
|
||||
static String? validateNomOccupant(String? value, int? passageType) {
|
||||
// Nom obligatoire seulement pour les passages effectués (type 1)
|
||||
if (passageType == 1) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Le nom est obligatoire pour les passages effectués';
|
||||
}
|
||||
|
||||
if (value.trim().length < 2) {
|
||||
return 'Le nom doit contenir au moins 2 caractères';
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Validation pour email
|
||||
static String? validateEmail(String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return null; // Email optionnel
|
||||
}
|
||||
|
||||
const emailRegex = r'^[^@]+@[^@]+\.[^@]+$';
|
||||
if (!RegExp(emailRegex).hasMatch(value.trim())) {
|
||||
return 'Format email invalide';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Validation pour téléphone
|
||||
static String? validatePhone(String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return null; // Téléphone optionnel
|
||||
}
|
||||
|
||||
// Enlever espaces et tirets
|
||||
final cleanPhone = value.replaceAll(RegExp(r'[\s\-\.]'), '');
|
||||
|
||||
// Vérifier format français basique
|
||||
if (cleanPhone.length < 10) {
|
||||
return 'Numéro trop court';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Validation pour montant (selon type de passage)
|
||||
static String? validateMontant(String? value, int? passageType) {
|
||||
// Montant obligatoire seulement pour types 1 (Effectué) et 5 (Lot)
|
||||
if (passageType == 1 || passageType == 5) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Le montant est obligatoire pour ce type';
|
||||
}
|
||||
|
||||
final montant = double.tryParse(value.replaceAll(',', '.'));
|
||||
if (montant == null) {
|
||||
return 'Montant invalide';
|
||||
}
|
||||
|
||||
if (montant <= 0) {
|
||||
return 'Le montant doit être supérieur à 0';
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Validation pour remarque
|
||||
static String? validateRemarque(String? value) {
|
||||
if (value != null && value.length > 500) {
|
||||
return 'La remarque ne peut pas dépasser 500 caractères';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Focus sur le premier champ en erreur avec scroll
|
||||
static void focusFirstError(BuildContext context, GlobalKey<FormState> formKey) {
|
||||
// Déclencher la validation
|
||||
if (!formKey.currentState!.validate()) {
|
||||
// Flutter met automatiquement le focus sur le premier champ en erreur
|
||||
// Mais on peut ajouter un scroll pour s'assurer que le champ est visible
|
||||
|
||||
// Attendre que le focus soit mis
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// Trouver le champ qui a le focus
|
||||
final FocusNode? focusedNode = FocusScope.of(context).focusedChild;
|
||||
if (focusedNode != null) {
|
||||
// Scroll vers le champ focusé
|
||||
Scrollable.ensureVisible(
|
||||
focusedNode.context!,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Validation globale du formulaire de passage
|
||||
static bool validatePassageForm({
|
||||
required GlobalKey<FormState> formKey,
|
||||
required BuildContext context,
|
||||
required int? selectedPassageType,
|
||||
required int fkTypeReglement,
|
||||
String? montant,
|
||||
String? nomOccupant,
|
||||
}) {
|
||||
// Validation des champs via les validators des TextFormField
|
||||
if (!formKey.currentState!.validate()) {
|
||||
// Le focus est automatiquement mis sur le premier champ en erreur
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validations spécifiques métier (comme dans l'original)
|
||||
if (selectedPassageType == 1) {
|
||||
if (nomOccupant == null || nomOccupant.trim().isEmpty) {
|
||||
// Ici on pourrait aussi mettre le focus sur le champ nom
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedPassageType == 1 || selectedPassageType == 5) {
|
||||
final montantValue = double.tryParse(montant?.replaceAll(',', '.') ?? '');
|
||||
if (montantValue == null || montantValue <= 0) {
|
||||
// Focus sur montant si erreur
|
||||
return false;
|
||||
}
|
||||
|
||||
if (fkTypeReglement < 1 || fkTypeReglement > 3) {
|
||||
// Focus sur dropdown type règlement
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
25
app/lib/presentation/widgets/passages/passage_form.dart
Normal file → Executable file
25
app/lib/presentation/widgets/passages/passage_form.dart
Normal file → Executable file
@@ -7,10 +7,10 @@ class PassageForm extends StatefulWidget {
|
||||
final Map<String, dynamic>? initialData;
|
||||
|
||||
const PassageForm({
|
||||
Key? key,
|
||||
super.key,
|
||||
this.onSubmit,
|
||||
this.initialData,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
State<PassageForm> createState() => _PassageFormState();
|
||||
@@ -123,7 +123,7 @@ class _PassageFormState extends State<PassageForm> {
|
||||
"Type d'habitat",
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: theme.colorScheme.onBackground,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
@@ -199,42 +199,39 @@ class _PassageFormState extends State<PassageForm> {
|
||||
"Montant reçu",
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: theme.colorScheme.onBackground,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextFormField(
|
||||
controller: _montantController,
|
||||
keyboardType:
|
||||
TextInputType.numberWithOptions(decimal: true),
|
||||
const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(
|
||||
RegExp(r'^\d+\.?\d{0,2}')),
|
||||
],
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: theme.colorScheme.onBackground,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: '0.00 €',
|
||||
hintStyle: theme.textTheme.bodyLarge?.copyWith(
|
||||
color:
|
||||
theme.colorScheme.onBackground.withOpacity(0.5),
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
fillColor: const Color(0xFFF4F5F6),
|
||||
filled: true,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(
|
||||
color:
|
||||
theme.colorScheme.onBackground.withOpacity(0.1),
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.1),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(
|
||||
color:
|
||||
theme.colorScheme.onBackground.withOpacity(0.1),
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.1),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
@@ -271,7 +268,7 @@ class _PassageFormState extends State<PassageForm> {
|
||||
"Type de règlement",
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: theme.colorScheme.onBackground,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
@@ -348,7 +345,7 @@ class _PassageFormState extends State<PassageForm> {
|
||||
Text(
|
||||
value,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onBackground,
|
||||
color: theme.colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
|
||||
155
app/lib/presentation/widgets/passages/passages_list_widget.dart
Normal file → Executable file
155
app/lib/presentation/widgets/passages/passages_list_widget.dart
Normal file → Executable file
@@ -1,6 +1,5 @@
|
||||
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
|
||||
@@ -110,14 +109,34 @@ class _PassagesListWidgetState extends State<PassagesListWidget> {
|
||||
// Liste filtrée avec gestion des erreurs
|
||||
List<Map<String, dynamic>> get _filteredPassages {
|
||||
try {
|
||||
// Si les filtres sont désactivés (showFilters: false), retourner directement les passages
|
||||
// car le filtrage est fait par le parent
|
||||
if (!widget.showFilters && !widget.showSearch) {
|
||||
var filtered = widget.passages;
|
||||
|
||||
// Appliquer uniquement le tri et la limitation si nécessaire
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Sinon, appliquer le filtrage interne (mode legacy)
|
||||
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'])) {
|
||||
@@ -369,19 +388,6 @@ class _PassagesListWidgetState extends State<PassagesListWidget> {
|
||||
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,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
@@ -521,7 +527,7 @@ class _PassagesListWidgetState extends State<PassagesListWidget> {
|
||||
padding: const EdgeInsets.only(top: 4.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
const Icon(
|
||||
Icons.error_outline,
|
||||
color: Colors.red,
|
||||
size: 16,
|
||||
@@ -690,44 +696,81 @@ class _PassagesListWidgetState extends State<PassagesListWidget> {
|
||||
),
|
||||
],
|
||||
),
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Header avec le nombre de passages trouvés
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primary.withOpacity(0.1),
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(12),
|
||||
topRight: Radius.circular(12),
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
itemCount: _filteredPassages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final passage = _filteredPassages[index];
|
||||
return _buildPassageCard(passage, theme, isDesktop);
|
||||
},
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.list_alt,
|
||||
size: 20,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${_filteredPassages.length} passage${_filteredPassages.length > 1 ? 's' : ''} trouvé${_filteredPassages.length > 1 ? 's' : ''}',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Contenu de la liste
|
||||
Expanded(
|
||||
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);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
12
app/lib/presentation/widgets/responsive_navigation.dart
Normal file → Executable file
12
app/lib/presentation/widgets/responsive_navigation.dart
Normal file → Executable file
@@ -1,6 +1,5 @@
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:flutter/material.dart';
|
||||
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/presentation/widgets/help_dialog.dart';
|
||||
@@ -48,7 +47,7 @@ class ResponsiveNavigation extends StatefulWidget {
|
||||
final bool showAppBar;
|
||||
|
||||
const ResponsiveNavigation({
|
||||
Key? key,
|
||||
super.key,
|
||||
required this.body,
|
||||
required this.title,
|
||||
required this.selectedIndex,
|
||||
@@ -62,7 +61,7 @@ class ResponsiveNavigation extends StatefulWidget {
|
||||
this.sidebarBottomItems,
|
||||
this.isAdmin = false,
|
||||
this.showAppBar = true,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
State<ResponsiveNavigation> createState() => _ResponsiveNavigationState();
|
||||
@@ -162,6 +161,7 @@ class _ResponsiveNavigationState extends State<ResponsiveNavigation> {
|
||||
onDestinationSelected: widget.onDestinationSelected,
|
||||
backgroundColor: theme.colorScheme.surface,
|
||||
elevation: 8,
|
||||
labelBehavior: NavigationDestinationLabelBehavior.alwaysHide,
|
||||
destinations: widget.destinations,
|
||||
);
|
||||
}
|
||||
@@ -344,7 +344,7 @@ class _ResponsiveNavigationState extends State<ResponsiveNavigation> {
|
||||
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;
|
||||
final IconData? iconData = (icon is Icon) ? (icon).icon : null;
|
||||
|
||||
// Remplacer certains titres si l'interface est de type "user"
|
||||
String displayTitle = title;
|
||||
@@ -430,10 +430,10 @@ class _SettingsItem extends StatelessWidget {
|
||||
const _SettingsItem({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
this.trailing,
|
||||
required this.onTap,
|
||||
required this.isSidebarMinimized,
|
||||
this.subtitle,
|
||||
this.trailing,
|
||||
});
|
||||
|
||||
@override
|
||||
|
||||
19
app/lib/presentation/widgets/sector_distribution_card.dart
Normal file → Executable file
19
app/lib/presentation/widgets/sector_distribution_card.dart
Normal file → Executable file
@@ -11,11 +11,11 @@ class SectorDistributionCard extends StatelessWidget {
|
||||
final EdgeInsetsGeometry? padding;
|
||||
|
||||
const SectorDistributionCard({
|
||||
Key? key,
|
||||
super.key,
|
||||
this.title = 'Répartition par secteur',
|
||||
this.height,
|
||||
this.padding,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -49,10 +49,12 @@ class SectorDistributionCard extends StatelessWidget {
|
||||
Widget _buildAutoRefreshContent() {
|
||||
// Écouter les changements des deux boîtes
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: Hive.box<SectorModel>(AppKeys.sectorsBoxName).listenable(),
|
||||
valueListenable:
|
||||
Hive.box<SectorModel>(AppKeys.sectorsBoxName).listenable(),
|
||||
builder: (context, Box<SectorModel> sectorsBox, child) {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
||||
valueListenable:
|
||||
Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
||||
builder: (context, Box<PassageModel> passagesBox, child) {
|
||||
return _buildContent(sectorsBox, passagesBox);
|
||||
},
|
||||
@@ -61,7 +63,8 @@ class SectorDistributionCard extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(Box<SectorModel> sectorsBox, Box<PassageModel> passagesBox) {
|
||||
Widget _buildContent(
|
||||
Box<SectorModel> sectorsBox, Box<PassageModel> passagesBox) {
|
||||
try {
|
||||
// Calculer les statistiques
|
||||
final sectorStats = _calculateSectorStats(sectorsBox, passagesBox);
|
||||
@@ -104,8 +107,8 @@ class SectorDistributionCard extends StatelessWidget {
|
||||
final Map<int, int> sectorCounts = {};
|
||||
|
||||
for (final passage in passages) {
|
||||
// Exclure les passages où fkType==2
|
||||
if (passage.fkSector != null && passage.fkType != 2) {
|
||||
// Exclure les passages où fkType==2 et ceux sans secteur
|
||||
if (passage.fkType != 2 && passage.fkSector != null) {
|
||||
sectorCounts[passage.fkSector!] =
|
||||
(sectorCounts[passage.fkSector!] ?? 0) + 1;
|
||||
}
|
||||
@@ -179,4 +182,4 @@ class SectorDistributionCard extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
232
app/lib/presentation/widgets/theme_switcher.dart
Executable file
232
app/lib/presentation/widgets/theme_switcher.dart
Executable file
@@ -0,0 +1,232 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/core/services/theme_service.dart';
|
||||
|
||||
/// Widget pour basculer entre les thèmes clair/sombre/automatique
|
||||
class ThemeSwitcher extends StatelessWidget {
|
||||
/// Style d'affichage du sélecteur
|
||||
final ThemeSwitcherStyle style;
|
||||
|
||||
/// Afficher le texte descriptif
|
||||
final bool showLabel;
|
||||
|
||||
/// Callback optionnel appelé après changement de thème
|
||||
final VoidCallback? onThemeChanged;
|
||||
|
||||
const ThemeSwitcher({
|
||||
super.key,
|
||||
this.style = ThemeSwitcherStyle.iconButton,
|
||||
this.showLabel = false,
|
||||
this.onThemeChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: ThemeService.instance,
|
||||
builder: (context, child) {
|
||||
switch (style) {
|
||||
case ThemeSwitcherStyle.iconButton:
|
||||
return _buildIconButton(context);
|
||||
case ThemeSwitcherStyle.dropdown:
|
||||
return _buildDropdown(context);
|
||||
case ThemeSwitcherStyle.segmentedButton:
|
||||
return _buildSegmentedButton(context);
|
||||
case ThemeSwitcherStyle.toggleButtons:
|
||||
return _buildToggleButtons(context);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Bouton icône simple (bascule entre clair/sombre)
|
||||
Widget _buildIconButton(BuildContext context) {
|
||||
final themeService = ThemeService.instance;
|
||||
|
||||
return IconButton(
|
||||
icon: Icon(themeService.themeModeIcon),
|
||||
tooltip: 'Changer le thème (${themeService.themeModeDescription})',
|
||||
onPressed: () async {
|
||||
await themeService.toggleTheme();
|
||||
onThemeChanged?.call();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Dropdown avec toutes les options
|
||||
Widget _buildDropdown(BuildContext context) {
|
||||
final themeService = ThemeService.instance;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return DropdownButton<ThemeMode>(
|
||||
value: themeService.themeMode,
|
||||
icon: Icon(Icons.arrow_drop_down, color: theme.colorScheme.onSurface),
|
||||
underline: Container(),
|
||||
items: [
|
||||
DropdownMenuItem(
|
||||
value: ThemeMode.system,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.brightness_auto, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
const Text('Automatique'),
|
||||
if (showLabel) ...[
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'(${themeService.isSystemDark ? 'sombre' : 'clair'})',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const DropdownMenuItem(
|
||||
value: ThemeMode.light,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.light_mode, size: 20),
|
||||
SizedBox(width: 8),
|
||||
Text('Clair'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const DropdownMenuItem(
|
||||
value: ThemeMode.dark,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.dark_mode, size: 20),
|
||||
SizedBox(width: 8),
|
||||
Text('Sombre'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
onChanged: (ThemeMode? mode) async {
|
||||
if (mode != null) {
|
||||
await themeService.setThemeMode(mode);
|
||||
onThemeChanged?.call();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Boutons segmentés (Material 3)
|
||||
Widget _buildSegmentedButton(BuildContext context) {
|
||||
final themeService = ThemeService.instance;
|
||||
|
||||
return SegmentedButton<ThemeMode>(
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: ThemeMode.light,
|
||||
icon: Icon(Icons.light_mode, size: 16),
|
||||
label: Text('Clair'),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: ThemeMode.system,
|
||||
icon: Icon(Icons.brightness_auto, size: 16),
|
||||
label: Text('Auto'),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: ThemeMode.dark,
|
||||
icon: Icon(Icons.dark_mode, size: 16),
|
||||
label: Text('Sombre'),
|
||||
),
|
||||
],
|
||||
selected: {themeService.themeMode},
|
||||
onSelectionChanged: (Set<ThemeMode> selection) async {
|
||||
if (selection.isNotEmpty) {
|
||||
await themeService.setThemeMode(selection.first);
|
||||
onThemeChanged?.call();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Boutons à bascule
|
||||
Widget _buildToggleButtons(BuildContext context) {
|
||||
final themeService = ThemeService.instance;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return ToggleButtons(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
constraints: const BoxConstraints(minHeight: 40, minWidth: 60),
|
||||
isSelected: [
|
||||
themeService.themeMode == ThemeMode.light,
|
||||
themeService.themeMode == ThemeMode.system,
|
||||
themeService.themeMode == ThemeMode.dark,
|
||||
],
|
||||
onPressed: (int index) async {
|
||||
final modes = [ThemeMode.light, ThemeMode.system, ThemeMode.dark];
|
||||
await themeService.setThemeMode(modes[index]);
|
||||
onThemeChanged?.call();
|
||||
},
|
||||
children: const [
|
||||
Icon(Icons.light_mode, size: 20),
|
||||
Icon(Icons.brightness_auto, size: 20),
|
||||
Icon(Icons.dark_mode, size: 20),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Widget d'information sur le thème actuel
|
||||
class ThemeInfo extends StatelessWidget {
|
||||
const ThemeInfo({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: ThemeService.instance,
|
||||
builder: (context, child) {
|
||||
final themeService = ThemeService.instance;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest.withOpacity(0.5),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.outline.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
themeService.themeModeIcon,
|
||||
size: 16,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
themeService.themeModeDescription,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Styles d'affichage pour le ThemeSwitcher
|
||||
enum ThemeSwitcherStyle {
|
||||
/// Bouton icône simple qui bascule entre clair/sombre
|
||||
iconButton,
|
||||
|
||||
/// Menu déroulant avec toutes les options
|
||||
dropdown,
|
||||
|
||||
/// Boutons segmentés (Material 3)
|
||||
segmentedButton,
|
||||
|
||||
/// Boutons à bascule
|
||||
toggleButtons,
|
||||
}
|
||||
0
app/lib/presentation/widgets/user_form.dart
Normal file → Executable file
0
app/lib/presentation/widgets/user_form.dart
Normal file → Executable file
0
app/lib/presentation/widgets/user_form_dialog.dart
Normal file → Executable file
0
app/lib/presentation/widgets/user_form_dialog.dart
Normal file → Executable file
327
app/lib/presentation/widgets/validation_example.dart
Executable file
327
app/lib/presentation/widgets/validation_example.dart
Executable file
@@ -0,0 +1,327 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/presentation/widgets/custom_text_field.dart';
|
||||
import 'package:geosector_app/presentation/widgets/form_section.dart';
|
||||
|
||||
/// Exemple de validation avec CustomTextField
|
||||
/// Montre comment les erreurs sont gérées automatiquement
|
||||
class ValidationExample extends StatefulWidget {
|
||||
const ValidationExample({super.key});
|
||||
|
||||
@override
|
||||
State<ValidationExample> createState() => _ValidationExampleState();
|
||||
}
|
||||
|
||||
class _ValidationExampleState extends State<ValidationExample> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
// Controllers
|
||||
final _numeroController = TextEditingController();
|
||||
final _rueController = TextEditingController();
|
||||
final _villeController = TextEditingController();
|
||||
final _nameController = TextEditingController();
|
||||
final _emailController = TextEditingController();
|
||||
final _montantController = TextEditingController();
|
||||
|
||||
// FocusNodes pour contrôler le focus
|
||||
final _numeroFocus = FocusNode();
|
||||
final _rueFocus = FocusNode();
|
||||
final _villeFocus = FocusNode();
|
||||
final _nameFocus = FocusNode();
|
||||
final _emailFocus = FocusNode();
|
||||
final _montantFocus = FocusNode();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_numeroController.dispose();
|
||||
_rueController.dispose();
|
||||
_villeController.dispose();
|
||||
_nameController.dispose();
|
||||
_emailController.dispose();
|
||||
_montantController.dispose();
|
||||
|
||||
_numeroFocus.dispose();
|
||||
_rueFocus.dispose();
|
||||
_villeFocus.dispose();
|
||||
_nameFocus.dispose();
|
||||
_emailFocus.dispose();
|
||||
_montantFocus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Validation du formulaire avec focus automatique sur erreur
|
||||
void _validateForm() {
|
||||
// Form.validate() fait automatiquement :
|
||||
// 1. Valide tous les champs
|
||||
// 2. Affiche les erreurs (bordures rouges)
|
||||
// 3. Met le focus sur le PREMIER champ en erreur
|
||||
if (_formKey.currentState!.validate()) {
|
||||
// Formulaire valide
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Formulaire valide !'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Des erreurs existent - le focus est déjà mis automatiquement
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Veuillez corriger les erreurs'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Validation personnalisée pour email
|
||||
String? _validateEmail(String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return null; // Email optionnel
|
||||
}
|
||||
|
||||
const emailRegex = r'^[^@]+@[^@]+\.[^@]+$';
|
||||
if (!RegExp(emailRegex).hasMatch(value)) {
|
||||
return 'Format email invalide';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Validation pour montant
|
||||
String? _validateMontant(String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Le montant est obligatoire';
|
||||
}
|
||||
|
||||
final montant = double.tryParse(value.replaceAll(',', '.'));
|
||||
if (montant == null) {
|
||||
return 'Montant invalide';
|
||||
}
|
||||
|
||||
if (montant <= 0) {
|
||||
return 'Le montant doit être supérieur à 0';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Exemple de Validation'),
|
||||
),
|
||||
body: Form( // ← Important : wrapper Form
|
||||
key: _formKey,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
// Section Adresse
|
||||
FormSection(
|
||||
title: 'Adresse',
|
||||
icon: Icons.location_on,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: CustomTextField(
|
||||
controller: _numeroController,
|
||||
focusNode: _numeroFocus,
|
||||
label: "Numéro",
|
||||
isRequired: true,
|
||||
showLabel: false,
|
||||
textAlign: TextAlign.right,
|
||||
keyboardType: TextInputType.number,
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Numéro obligatoire';
|
||||
}
|
||||
final numero = int.tryParse(value);
|
||||
if (numero == null || numero <= 0) {
|
||||
return 'Numéro invalide';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Expanded(
|
||||
flex: 1,
|
||||
child: CustomTextField(
|
||||
label: "Bis/Ter",
|
||||
showLabel: false,
|
||||
// Pas de validation - champ optionnel
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
CustomTextField(
|
||||
controller: _rueController,
|
||||
focusNode: _rueFocus,
|
||||
label: "Rue",
|
||||
isRequired: true,
|
||||
showLabel: false,
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'La rue est obligatoire';
|
||||
}
|
||||
if (value.trim().length < 3) {
|
||||
return 'La rue doit contenir au moins 3 caractères';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
CustomTextField(
|
||||
controller: _villeController,
|
||||
focusNode: _villeFocus,
|
||||
label: "Ville",
|
||||
isRequired: true,
|
||||
showLabel: false,
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'La ville est obligatoire';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Section Occupant
|
||||
FormSection(
|
||||
title: 'Occupant',
|
||||
icon: Icons.person,
|
||||
children: [
|
||||
CustomTextField(
|
||||
controller: _nameController,
|
||||
focusNode: _nameFocus,
|
||||
label: "Nom de l'occupant",
|
||||
isRequired: true,
|
||||
showLabel: false,
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Le nom est obligatoire pour les passages effectués';
|
||||
}
|
||||
if (value.trim().length < 2) {
|
||||
return 'Le nom doit contenir au moins 2 caractères';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
CustomTextField(
|
||||
controller: _emailController,
|
||||
focusNode: _emailFocus,
|
||||
label: "Email",
|
||||
showLabel: false,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
validator: _validateEmail,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Section Règlement
|
||||
FormSection(
|
||||
title: 'Règlement',
|
||||
icon: Icons.euro,
|
||||
children: [
|
||||
CustomTextField(
|
||||
controller: _montantController,
|
||||
focusNode: _montantFocus,
|
||||
label: "Montant (€)",
|
||||
isRequired: true,
|
||||
showLabel: false,
|
||||
hintText: "0.00",
|
||||
textAlign: TextAlign.right,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
validator: _validateMontant,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Boutons de test
|
||||
Column(
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
onPressed: _validateForm,
|
||||
icon: const Icon(Icons.check),
|
||||
label: const Text('Valider le formulaire'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||
foregroundColor: Colors.white,
|
||||
minimumSize: const Size(200, 48),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
// Effacer le formulaire
|
||||
_formKey.currentState?.reset();
|
||||
_numeroController.clear();
|
||||
_rueController.clear();
|
||||
_villeController.clear();
|
||||
_nameController.clear();
|
||||
_emailController.clear();
|
||||
_montantController.clear();
|
||||
},
|
||||
icon: const Icon(Icons.clear),
|
||||
label: const Text('Effacer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Info box
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primaryContainer.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.primary.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Test de validation',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'• Laissez des champs obligatoires vides et cliquez "Valider"\n'
|
||||
'• Les bordures deviennent rouges automatiquement\n'
|
||||
'• Le focus se met sur le premier champ en erreur\n'
|
||||
'• Les messages d\'erreur s\'affichent sous les champs',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user