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:
pierre
2025-08-07 11:01:45 +02:00
parent 3bbc599ab4
commit 1018b86537
620 changed files with 120502 additions and 91396 deletions

0
app/lib/presentation/widgets/amicale_form.dart Normal file → Executable file
View File

0
app/lib/presentation/widgets/amicale_row_widget.dart Normal file → Executable file
View File

0
app/lib/presentation/widgets/amicale_table_widget.dart Normal file → Executable file
View File

View 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
View File

View 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
View File

View 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(

View 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
View File

0
app/lib/presentation/widgets/charts/payment_data.dart Normal file → Executable file
View File

View 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(

View 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
View 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
View 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
View 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
View 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,

View 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
View File

56
app/lib/presentation/widgets/custom_text_field.dart Normal file → Executable file
View 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
View 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
View 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) {

View File

View 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
View 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
View 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
View 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!,

View 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
View 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
View 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
View 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,
);

View File

File diff suppressed because it is too large Load Diff

View 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,
),
],
),
],
),
),
);
}
}

View 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
View 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,
),
),

View 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
View 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

View 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 {
),
);
}
}
}

View 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
View File

0
app/lib/presentation/widgets/user_form_dialog.dart Normal file → Executable file
View File

View 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',
),
],
),
),
],
),
),
),
);
}
}