feat: Version 3.5.2 - Configuration Stripe et gestion des immeubles

- Configuration complète Stripe pour les 3 environnements (DEV/REC/PROD)
  * DEV: Clés TEST Pierre (mode test)
  * REC: Clés TEST Client (mode test)
  * PROD: Clés LIVE Client (mode live)
- Ajout de la gestion des bases de données immeubles/bâtiments
  * Configuration buildings_database pour DEV/REC/PROD
  * Service BuildingService pour enrichissement des adresses
- Optimisations pages et améliorations ergonomie
- Mises à jour des dépendances Composer
- Nettoyage des fichiers obsolètes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
pierre
2025-11-09 18:26:27 +01:00
parent 21657a3820
commit 2f5946a184
812 changed files with 142105 additions and 25992 deletions

View File

@@ -10,7 +10,7 @@ class DotsPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.white.withValues(alpha: 0.5)
..color = Colors.white.withOpacity(0.5)
..style = PaintingStyle.fill;
final random = math.Random(42); // Seed fixe pour consistance

View File

@@ -12,6 +12,7 @@ import 'package:image_picker/image_picker.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'dart:io';
import 'package:geosector_app/presentation/widgets/loading_spin_overlay.dart';
import 'package:geosector_app/presentation/widgets/result_dialog.dart';
import 'custom_text_field.dart';
class AmicaleForm extends StatefulWidget {
@@ -196,6 +197,7 @@ class _AmicaleFormState extends State<AmicaleForm> {
// Afficher le loading
if (!context.mounted) return;
showDialog(
// ignore: use_build_context_synchronously
context: context,
barrierDismissible: false,
builder: (context) => const AlertDialog(
@@ -279,24 +281,13 @@ class _AmicaleFormState extends State<AmicaleForm> {
Future<void> _updateAmicale(AmicaleModel amicale) async {
if (!mounted) return;
// Afficher l'overlay de chargement
final overlay = LoadingSpinOverlayUtils.show(
context: context,
message: 'Mise à jour en cours...',
);
try {
// Afficher un indicateur de chargement
showDialog(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return const AlertDialog(
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Mise à jour en cours...'),
],
),
);
},
);
// Préparer les données pour l'API
final Map<String, dynamic> data = {
@@ -357,10 +348,8 @@ class _AmicaleFormState extends State<AmicaleForm> {
}
}
// Fermer l'indicateur de chargement
if (mounted && Navigator.of(context).canPop()) {
Navigator.of(context).pop();
}
// Masquer le loading
LoadingSpinOverlayUtils.hideSpecific(overlay);
if (!mounted) return;
@@ -370,46 +359,39 @@ class _AmicaleFormState extends State<AmicaleForm> {
widget.onSubmit!(amicale);
}
// Afficher un message de succès
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(widget.apiService != null ? 'Amicale mise à jour avec succès' : 'Modifications enregistrées localement'),
backgroundColor: Colors.green,
),
// Afficher le résultat de succès
await ResultDialog.show(
context: context,
success: true,
message: widget.apiService != null
? 'Amicale mise à jour avec succès'
: 'Modifications enregistrées localement',
);
// Fermer le formulaire après un délai pour que l'utilisateur voie le message
await Future.delayed(const Duration(milliseconds: 500));
// Fermer le formulaire
if (mounted && Navigator.of(context).canPop()) {
Navigator.of(context).pop();
}
} else {
// Afficher un message d'erreur
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(errorMessage ?? 'Erreur lors de la mise à jour'),
backgroundColor: Colors.red,
duration: const Duration(seconds: 4),
),
// Afficher le résultat d'erreur
await ResultDialog.show(
context: context,
success: false,
message: errorMessage ?? 'Erreur lors de la mise à jour',
);
}
} catch (e) {
debugPrint('❌ Erreur générale dans _updateAmicale: $e');
// Fermer l'indicateur de chargement si encore ouvert
if (mounted && Navigator.of(context).canPop()) {
Navigator.of(context).pop();
}
// Masquer le loading
LoadingSpinOverlayUtils.hideSpecific(overlay);
// Afficher un message d'erreur
// Afficher l'erreur
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur inattendue: ${e.toString()}'),
backgroundColor: Colors.red,
duration: const Duration(seconds: 4),
),
await ResultDialog.show(
context: context,
success: false,
message: 'Erreur inattendue: ${e.toString()}',
);
}
}
@@ -527,81 +509,114 @@ class _AmicaleFormState extends State<AmicaleForm> {
void _submitForm() {
debugPrint('🔧 _submitForm appelée');
if (_formKey.currentState!.validate()) {
debugPrint('🔧 Formulaire valide');
// Vérifier qu'au moins un numéro de téléphone est renseigné
if (_phoneController.text.isEmpty && _mobileController.text.isEmpty) {
debugPrint('⚠️ Aucun numéro de téléphone renseigné');
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Veuillez renseigner au moins un numéro de téléphone'),
backgroundColor: Colors.red,
if (!_formKey.currentState!.validate()) {
// Afficher une dialog si la validation échoue
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Row(
children: [
Icon(Icons.warning_amber_rounded, color: Colors.orange),
SizedBox(width: 8),
Text('Formulaire incomplet'),
],
),
);
return;
}
debugPrint('🔧 Création de l\'objet AmicaleModel...');
final amicale = widget.amicale?.copyWith(
name: _nameController.text,
adresse1: _adresse1Controller.text,
adresse2: _adresse2Controller.text,
codePostal: _codePostalController.text,
ville: _villeController.text,
fkRegion: _fkRegion,
libRegion: _libRegion,
phone: _phoneController.text,
mobile: _mobileController.text,
email: _emailController.text,
gpsLat: _gpsLatController.text,
gpsLng: _gpsLngController.text,
stripeId: _stripeIdController.text,
chkDemo: _chkDemo,
chkCopieMailRecu: _chkCopieMailRecu,
chkAcceptSms: _chkAcceptSms,
chkActive: _chkActive,
chkStripe: _chkStripe,
chkMdpManuel: _chkMdpManuel,
chkUsernameManuel: _chkUsernameManuel,
chkUserDeletePass: _chkUserDeletePass,
chkLotActif: _chkLotActif,
) ??
AmicaleModel(
id: 0, // Sera remplacé par l'API
name: _nameController.text,
adresse1: _adresse1Controller.text,
adresse2: _adresse2Controller.text,
codePostal: _codePostalController.text,
ville: _villeController.text,
fkRegion: _fkRegion,
libRegion: _libRegion,
phone: _phoneController.text,
mobile: _mobileController.text,
email: _emailController.text,
gpsLat: _gpsLatController.text,
gpsLng: _gpsLngController.text,
stripeId: _stripeIdController.text,
chkDemo: _chkDemo,
chkCopieMailRecu: _chkCopieMailRecu,
chkAcceptSms: _chkAcceptSms,
chkActive: _chkActive,
chkStripe: _chkStripe,
chkMdpManuel: _chkMdpManuel,
chkUsernameManuel: _chkUsernameManuel,
chkUserDeletePass: _chkUserDeletePass,
chkLotActif: _chkLotActif,
);
debugPrint('🔧 AmicaleModel créé: ${amicale.name}');
debugPrint('🔧 Appel de _updateAmicale...');
// Appeler l'API pour mettre à jour l'amicale
_updateAmicale(amicale);
} else {
debugPrint('❌ Formulaire invalide');
content: const Text('Veuillez vérifier tous les champs marqués en rouge avant d\'enregistrer'),
actions: [
ElevatedButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('OK'),
),
],
),
);
return;
}
debugPrint('🔧 Formulaire valide');
// Vérifier qu'au moins un numéro de téléphone est renseigné
if (_phoneController.text.isEmpty && _mobileController.text.isEmpty) {
debugPrint('⚠️ Aucun numéro de téléphone renseigné');
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Row(
children: [
Icon(Icons.warning_amber_rounded, color: Colors.orange),
SizedBox(width: 8),
Text('Formulaire incomplet'),
],
),
content: const Text('Veuillez renseigner au moins un numéro de téléphone'),
actions: [
ElevatedButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('OK'),
),
],
),
);
return;
}
debugPrint('🔧 Création de l\'objet AmicaleModel...');
final amicale = widget.amicale?.copyWith(
name: _nameController.text,
adresse1: _adresse1Controller.text,
adresse2: _adresse2Controller.text,
codePostal: _codePostalController.text,
ville: _villeController.text,
fkRegion: _fkRegion,
libRegion: _libRegion,
phone: _phoneController.text,
mobile: _mobileController.text,
email: _emailController.text,
gpsLat: _gpsLatController.text,
gpsLng: _gpsLngController.text,
stripeId: _stripeIdController.text,
chkDemo: _chkDemo,
chkCopieMailRecu: _chkCopieMailRecu,
chkAcceptSms: _chkAcceptSms,
chkActive: _chkActive,
chkStripe: _chkStripe,
chkMdpManuel: _chkMdpManuel,
chkUsernameManuel: _chkUsernameManuel,
chkUserDeletePass: _chkUserDeletePass,
chkLotActif: _chkLotActif,
) ??
AmicaleModel(
id: 0, // Sera remplacé par l'API
name: _nameController.text,
adresse1: _adresse1Controller.text,
adresse2: _adresse2Controller.text,
codePostal: _codePostalController.text,
ville: _villeController.text,
fkRegion: _fkRegion,
libRegion: _libRegion,
phone: _phoneController.text,
mobile: _mobileController.text,
email: _emailController.text,
gpsLat: _gpsLatController.text,
gpsLng: _gpsLngController.text,
stripeId: _stripeIdController.text,
chkDemo: _chkDemo,
chkCopieMailRecu: _chkCopieMailRecu,
chkAcceptSms: _chkAcceptSms,
chkActive: _chkActive,
chkStripe: _chkStripe,
chkMdpManuel: _chkMdpManuel,
chkUsernameManuel: _chkUsernameManuel,
chkUserDeletePass: _chkUserDeletePass,
chkLotActif: _chkLotActif,
);
debugPrint('🔧 AmicaleModel créé: ${amicale.name}');
debugPrint('🔧 Appel de _updateAmicale...');
// Appeler l'API pour mettre à jour l'amicale
_updateAmicale(amicale);
}
// Construire la section logo
@@ -618,7 +633,7 @@ class _AmicaleFormState extends State<AmicaleForm> {
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
color: Colors.black.withOpacity(0.1),
blurRadius: 4,
offset: const Offset(0, 2),
),
@@ -642,7 +657,7 @@ class _AmicaleFormState extends State<AmicaleForm> {
onTap: _selectImage,
child: Container(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.3),
color: Colors.black.withOpacity(0.3),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
@@ -822,7 +837,7 @@ class _AmicaleFormState extends State<AmicaleForm> {
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
color: Colors.black.withOpacity(0.1),
blurRadius: 4,
offset: const Offset(0, 2),
),
@@ -1234,10 +1249,10 @@ class _AmicaleFormState extends State<AmicaleForm> {
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: _stripeStatus?.statusColor.withValues(alpha: 0.1) ?? Colors.orange.withValues(alpha: 0.1),
color: _stripeStatus?.statusColor.withOpacity(0.1) ?? Colors.orange.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: _stripeStatus?.statusColor.withValues(alpha: 0.3) ?? Colors.orange.withValues(alpha: 0.3),
color: _stripeStatus?.statusColor.withOpacity(0.3) ?? Colors.orange.withOpacity(0.3),
),
),
child: Row(

View File

@@ -38,7 +38,7 @@ class AmicaleRowWidget extends StatelessWidget {
: theme.textTheme.bodyMedium;
// Couleur de fond en fonction du type de ligne
final backgroundColor = isHeader ? theme.colorScheme.primary.withValues(alpha: 0.1) : (isAlternate ? theme.colorScheme.surface : theme.colorScheme.surface);
final backgroundColor = isHeader ? theme.colorScheme.primary.withOpacity(0.1) : (isAlternate ? theme.colorScheme.surface : theme.colorScheme.surface);
return InkWell(
onTap: isHeader || onTap == null ? null : () => onTap!(amicale),
@@ -47,7 +47,7 @@ class AmicaleRowWidget extends StatelessWidget {
color: backgroundColor,
border: Border(
bottom: BorderSide(
color: theme.dividerColor.withValues(alpha: 0.3),
color: theme.dividerColor.withOpacity(0.3),
width: 1,
),
),

View File

@@ -134,7 +134,7 @@ class AmicaleTableWidget extends StatelessWidget {
bottomRight: Radius.circular(8),
),
border: Border.all(
color: theme.colorScheme.primary.withValues(alpha: 0.1),
color: theme.colorScheme.primary.withOpacity(0.1),
width: 1,
),
),
@@ -161,7 +161,7 @@ class AmicaleTableWidget extends StatelessWidget {
child: Text(
emptyMessage ?? 'Aucune amicale trouvée',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6),
),
),
),

View File

@@ -11,7 +11,7 @@ class DotsPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.white.withValues(alpha: 0.5)
..color = Colors.white.withOpacity(0.5)
..style = PaintingStyle.fill;
final random = math.Random(42); // Seed fixe pour consistance
@@ -206,7 +206,7 @@ class AppScaffold extends StatelessWidget {
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: theme.shadowColor.withValues(alpha: 0.1),
color: theme.shadowColor.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, 4),
),
@@ -233,7 +233,7 @@ class AppScaffold extends StatelessWidget {
Text(
message,
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
color: theme.colorScheme.onSurface.withOpacity(0.7),
),
textAlign: TextAlign.center,
),

View File

@@ -0,0 +1,379 @@
import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:go_router/go_router.dart';
import 'package:geosector_app/app.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/data/models/passage_model.dart';
import 'package:geosector_app/core/services/current_user_service.dart';
import 'package:geosector_app/core/theme/app_theme.dart';
import 'package:geosector_app/presentation/widgets/passage_form_dialog.dart';
/// Widget affichant 8 colonnes de statistiques de passages
class BtnPassages extends StatelessWidget {
final VoidCallback? onAddPassage;
/// Callback appelé lors du clic sur un type de passage
/// Si null, navigue vers /user/history (comportement par défaut)
/// Si fourni, appelle ce callback avec le typeId (ou null pour "Tous")
final Function(int? typeId)? onTypeSelected;
/// Type de passage actuellement sélectionné (pour l'indicateur visuel)
/// null = tous les passages
final int? selectedTypeId;
const BtnPassages({
super.key,
this.onAddPassage,
this.onTypeSelected,
this.selectedTypeId,
});
@override
Widget build(BuildContext context) {
// Récupérer l'utilisateur courant
final currentUser = userRepository.getCurrentUser();
final currentOpeUserId = currentUser?.opeUserId;
final currentOperation = userRepository.getCurrentOperation();
final isAdmin = CurrentUserService.instance.shouldShowAdminUI;
// Vérifier si le type Lot doit être affiché
final shouldShowLotType = _shouldShowLotType();
return SizedBox(
height: 80,
width: double.infinity,
child: ValueListenableBuilder<Box<PassageModel>>(
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
builder: (context, box, child) {
// Filtrer les passages de l'opération courante
final allPassages = box.values.where((p) {
if (currentOperation == null) return false;
if (p.fkOperation != currentOperation.id) return false;
// Mode Admin : afficher tous les passages de l'opération
if (isAdmin) return true;
// Mode Membre : logique spéciale pour type 2 (À finaliser) : afficher tous
if (p.fkType == 2) return true;
// Mode Membre : autres types : seulement les passages de l'utilisateur
return p.fkUser == currentOpeUserId;
}).toList();
// Calculer les statistiques par type
final Map<int, int> countsByType = {};
int totalPassages = 0;
for (final passage in allPassages) {
countsByType[passage.fkType] = (countsByType[passage.fkType] ?? 0) + 1;
totalPassages++;
}
return Row(
children: [
// Colonne 1 : Total (non cliquable)
Expanded(
child: _buildTotalColumn(context, totalPassages),
),
const SizedBox(width: 2),
// Colonnes 2-7 : Types de passages (cliquables)
...AppKeys.typesPassages.entries.expand((entry) {
final typeId = entry.key;
final typeInfo = entry.value;
// Exclure le type Lot (5) si chkLotActif = false
if (typeId == 5 && !shouldShowLotType) {
return <Widget>[];
}
final count = countsByType[typeId] ?? 0;
final titre = typeInfo['titre'] as String;
final couleur = Color(typeInfo['couleur2'] as int);
final iconData = typeInfo['icon_data'] as IconData;
return <Widget>[
Expanded(
child: _buildTypeColumn(
context,
typeId,
titre,
count,
couleur,
iconData,
),
),
const SizedBox(width: 2),
];
}),
// Colonne 8 : Bouton + (nouveau passage)
Expanded(
child: _buildAddColumn(context),
),
],
);
},
),
);
}
/// Colonne TOTAL (cliquable, affiche tous les passages)
Widget _buildTotalColumn(BuildContext context, int total) {
final bool isSelected = selectedTypeId == null;
return InkWell(
onTap: () async {
if (onTypeSelected != null) {
// Mode callback : appeler le callback avec null (tous les passages)
onTypeSelected!(null);
} else {
// Mode navigation : sauvegarder dans Hive et naviguer
try {
if (!Hive.isBoxOpen(AppKeys.settingsBoxName)) {
await Hive.openBox(AppKeys.settingsBoxName);
}
final settingsBox = Hive.box(AppKeys.settingsBoxName);
await settingsBox.delete('history_selectedTypeId');
debugPrint('BtnPassages: Filtre type réinitialisé (tous les passages)');
} catch (e) {
debugPrint('Erreur réinitialisation filtre: $e');
}
// Navigation vers /history avec GoRouter (détection automatique admin/user)
if (context.mounted) {
final isAdmin = CurrentUserService.instance.shouldShowAdminUI;
context.go(isAdmin ? '/admin/history' : '/user/history');
}
}
},
child: Container(
height: 80,
decoration: BoxDecoration(
color: Colors.grey[200],
border: Border.all(
color: Colors.grey[400]!,
width: isSelected ? 5 : 1,
),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(AppTheme.borderRadiusMedium),
bottomLeft: Radius.circular(AppTheme.borderRadiusMedium),
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(
Icons.route,
size: 20,
color: Colors.black54,
),
const SizedBox(height: 2),
Text(
total.toString(),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
const SizedBox(height: 2),
Text(
total > 1 ? 'passages' : 'passage',
style: TextStyle(
fontSize: 10,
color: Colors.grey[700],
),
textAlign: TextAlign.center,
),
],
),
),
);
}
/// Colonne TYPE DE PASSAGE (cliquable, navigue vers /history avec filtre)
Widget _buildTypeColumn(
BuildContext context,
int typeId,
String titre,
int count,
Color couleur,
IconData iconData,
) {
final bool isSelected = selectedTypeId == typeId;
return InkWell(
onTap: () async {
if (onTypeSelected != null) {
// Mode callback : appeler le callback avec le typeId
onTypeSelected!(typeId);
} else {
// Mode navigation : sauvegarder dans Hive et naviguer
try {
if (!Hive.isBoxOpen(AppKeys.settingsBoxName)) {
await Hive.openBox(AppKeys.settingsBoxName);
}
final settingsBox = Hive.box(AppKeys.settingsBoxName);
await settingsBox.put('history_selectedTypeId', typeId);
debugPrint('BtnPassages: Type $typeId sauvegardé dans Hive');
} catch (e) {
debugPrint('Erreur sauvegarde type: $e');
}
// Navigation vers /history avec GoRouter (détection automatique admin/user)
if (context.mounted) {
final isAdmin = CurrentUserService.instance.shouldShowAdminUI;
context.go(isAdmin ? '/admin/history' : '/user/history');
}
}
},
child: Container(
height: 80,
decoration: BoxDecoration(
color: couleur.withOpacity(0.1),
border: Border.all(
color: couleur,
width: isSelected ? 5 : 1,
),
borderRadius: BorderRadius.circular(4),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(
iconData,
size: 20,
color: couleur,
),
const SizedBox(height: 2),
Text(
count.toString(),
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: couleur,
),
),
const SizedBox(height: 2),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Text(
titre,
style: TextStyle(
fontSize: 10,
color: couleur,
),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
);
}
/// Colonne NOUVEAU PASSAGE (bouton +, fond vert)
Widget _buildAddColumn(BuildContext context) {
return InkWell(
onTap: () {
if (onAddPassage != null) {
onAddPassage!();
} else {
// Par défaut, ouvrir le dialogue de création
_showPassageFormDialog(context);
}
},
child: Container(
height: 80,
decoration: BoxDecoration(
color: AppTheme.buttonSuccessColor.withOpacity(0.1),
border: Border.all(
color: AppTheme.buttonSuccessColor,
width: 1,
),
borderRadius: const BorderRadius.only(
topRight: Radius.circular(AppTheme.borderRadiusMedium),
bottomRight: Radius.circular(AppTheme.borderRadiusMedium),
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(
Icons.add_circle_outline,
size: 24,
color: AppTheme.buttonSuccessColor,
),
const SizedBox(height: 2),
Text(
'Nouveau',
style: TextStyle(
fontSize: 10,
color: AppTheme.buttonSuccessColor,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
),
],
),
),
);
}
/// Vérifier si le type Lot doit être affiché
bool _shouldShowLotType() {
final currentUser = userRepository.getCurrentUser();
if (currentUser != null && currentUser.fkEntite != null) {
final userAmicale = amicaleRepository.getAmicaleById(currentUser.fkEntite!);
if (userAmicale != null) {
return userAmicale.chkLotActif;
}
}
return true; // Par défaut, on affiche
}
/// Afficher le dialogue de création de passage
Future<void> _showPassageFormDialog(BuildContext context) async {
await showDialog<PassageModel>(
context: context,
builder: (context) => PassageFormDialog(
title: 'Nouveau passage',
readOnly: false,
passageRepository: passageRepository,
userRepository: userRepository,
operationRepository: operationRepository,
amicaleRepository: amicaleRepository,
onSuccess: () {
debugPrint('BtnPassages: Passage créé avec succès');
},
),
);
}
}

View File

@@ -6,7 +6,6 @@ import 'package:syncfusion_flutter_charts/charts.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/data/models/passage_model.dart';
import 'package:geosector_app/core/data/models/user_sector_model.dart';
import 'package:geosector_app/core/services/current_user_service.dart';
import 'package:go_router/go_router.dart';
@@ -190,7 +189,8 @@ class _ActivityChartState extends State<ActivityChart>
}
/// Calcule les données d'activité depuis la Hive box
List<ActivityData> _calculateActivityData(Box<PassageModel> passagesBox, int daysToShow) {
List<ActivityData> _calculateActivityData(
Box<PassageModel> passagesBox, int daysToShow) {
try {
final passages = passagesBox.values.toList();
final currentUser = userRepository.getCurrentUser();
@@ -200,7 +200,8 @@ class _ActivityChartState extends State<ActivityChart>
if (!widget.showAllPassages && currentUser != null) {
final userSectors = userRepository.getUserSectors();
userSectorIds = userSectors.map((sector) => sector.id).toSet();
debugPrint('ActivityChart: Mode USER - Secteurs assignés: $userSectorIds');
debugPrint(
'ActivityChart: Mode USER - Secteurs assignés: $userSectorIds');
} else {
debugPrint('ActivityChart: Mode ADMIN - Tous les passages');
}
@@ -209,7 +210,8 @@ class _ActivityChartState extends State<ActivityChart>
final endDate = DateTime.now();
final startDate = endDate.subtract(Duration(days: daysToShow - 1));
debugPrint('ActivityChart: Période du ${DateFormat('yyyy-MM-dd').format(startDate)} au ${DateFormat('yyyy-MM-dd').format(endDate)}');
debugPrint(
'ActivityChart: Période du ${DateFormat('yyyy-MM-dd').format(startDate)} au ${DateFormat('yyyy-MM-dd').format(endDate)}');
debugPrint('ActivityChart: Nombre total de passages: ${passages.length}');
// Préparer les données par date
@@ -232,29 +234,25 @@ class _ActivityChartState extends State<ActivityChart>
for (final passage in passages) {
// Appliquer les filtres
bool shouldInclude = true;
String excludeReason = '';
// Filtrer par secteurs assignés si nécessaire (pour les users)
if (userSectorIds != null && !userSectorIds.contains(passage.fkSector)) {
if (userSectorIds != null &&
!userSectorIds.contains(passage.fkSector)) {
shouldInclude = false;
excludeReason = 'Secteur ${passage.fkSector} non assigné';
}
// Exclure les passages de type 2 (À finaliser) avec nbPassages = 0
if (shouldInclude && passage.fkType == 2 && passage.nbPassages == 0) {
shouldInclude = false;
excludeReason = 'Type 2 avec nbPassages=0';
}
// Vérifier si le passage est dans la période
final passageDate = passage.passedAt;
if (shouldInclude && (passageDate == null ||
passageDate.isBefore(startDate) ||
passageDate.isAfter(endDate))) {
if (shouldInclude &&
(passageDate == null ||
passageDate.isBefore(startDate) ||
passageDate.isAfter(endDate))) {
shouldInclude = false;
excludeReason = passageDate == null
? 'Date null'
: 'Hors période (${DateFormat('yyyy-MM-dd').format(passageDate)})';
}
if (shouldInclude && passageDate != null) {
@@ -264,12 +262,16 @@ class _ActivityChartState extends State<ActivityChart>
(dataByDate[dateStr]![passage.fkType] ?? 0) + 1;
includedCount++;
}
} else if (!shouldInclude && userSectorIds != null) {
debugPrint('ActivityChart: Passage #${passage.id} exclu - $excludeReason (type=${passage.fkType}, secteur=${passage.fkSector}, date=${passageDate != null ? DateFormat('yyyy-MM-dd').format(passageDate) : 'null'})');
}
// Debug désactivé pour éviter la pollution de la console avec les passages type 2 sans date
// else if (!shouldInclude && userSectorIds != null) {
// debugPrint(
// 'ActivityChart: Passage #${passage.id} exclu - $excludeReason (type=${passage.fkType}, secteur=${passage.fkSector}, date=${passageDate != null ? DateFormat('yyyy-MM-dd').format(passageDate) : 'null'})');
// }
}
debugPrint('ActivityChart: Passages inclus dans le graphique: $includedCount');
debugPrint(
'ActivityChart: Passages inclus dans le graphique: $includedCount');
// Convertir en liste d'ActivityData
final List<ActivityData> chartData = [];
@@ -520,9 +522,11 @@ class _ActivityChartState extends State<ActivityChart>
markerSettings: const MarkerSettings(isVisible: false),
animationDuration: 1500,
// Ajouter le callback de clic uniquement depuis home_page
onPointTap: widget.showPeriodButtons ? (ChartPointDetails details) {
_handlePointTap(details, typeId);
} : null,
onPointTap: widget.showPeriodButtons
? (ChartPointDetails details) {
_handlePointTap(details, typeId);
}
: null,
),
);
}
@@ -537,11 +541,6 @@ class _ActivityChartState extends State<ActivityChart>
// Récupérer les données du point cliqué
final passageBox = Hive.box<PassageModel>(AppKeys.passagesBoxName);
final passages = passageBox.values.toList();
// Calculer la date de début (nombre de jours en arrière)
final endDate = DateTime.now();
final startDate = endDate.subtract(Duration(days: _selectedDays - 1));
// Créer les données d'activité
final chartData = _calculateActivityData(passageBox, _selectedDays);
@@ -562,11 +561,13 @@ class _ActivityChartState extends State<ActivityChart>
settingsBox.put('history_selectedTypeId', typeId);
// Date de début : début de la journée cliquée
final startDateTime = DateTime(clickedDate.year, clickedDate.month, clickedDate.day, 0, 0, 0);
final startDateTime =
DateTime(clickedDate.year, clickedDate.month, clickedDate.day, 0, 0, 0);
settingsBox.put('history_startDate', startDateTime.millisecondsSinceEpoch);
// Date de fin : fin de la journée cliquée
final endDateTime = DateTime(clickedDate.year, clickedDate.month, clickedDate.day, 23, 59, 59);
final endDateTime = DateTime(
clickedDate.year, clickedDate.month, clickedDate.day, 23, 59, 59);
settingsBox.put('history_endDate', endDateTime.millisecondsSinceEpoch);
// Naviguer vers la page historique
@@ -592,7 +593,7 @@ class _ActivityChartState extends State<ActivityChart>
decoration: BoxDecoration(
color: isSelected
? Theme.of(context).colorScheme.primary
: Colors.grey.withValues(alpha: 0.1),
: Colors.grey.withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: isSelected

View File

@@ -1,10 +1,6 @@
/// Bibliothèque de widgets de graphiques pour l'application GeoSector
library geosector_charts;
export 'payment_data.dart';
export 'payment_summary_card.dart';
export 'passage_data.dart';
export 'passage_utils.dart';
export 'passage_summary_card.dart';
export 'activity_chart.dart';
export 'combined_chart.dart';

View File

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

View File

@@ -184,7 +184,7 @@ class _PassageSummaryCardState extends State<PassageSummaryCard>
widget.backgroundIcon,
size: widget.backgroundIconSize,
color: (widget.backgroundIconColor ?? AppTheme.primaryColor)
.withValues(alpha: widget.backgroundIconOpacity),
.withOpacity(widget.backgroundIconOpacity),
),
),
),

View File

@@ -161,7 +161,7 @@ class _PaymentSummaryCardState extends State<PaymentSummaryCard>
widget.backgroundIcon,
size: widget.backgroundIconSize,
color: (widget.backgroundIconColor ?? Colors.blue)
.withValues(alpha: widget.backgroundIconOpacity),
.withOpacity(widget.backgroundIconOpacity),
),
),
),
@@ -422,10 +422,10 @@ class _PaymentSummaryCardState extends State<PaymentSummaryCard>
// En mode user, filtrer uniquement les passages créés par l'utilisateur (fkUser)
final currentUser = userRepository.getCurrentUser();
final int? filterUserId = widget.showAllPayments ? null : currentUser?.id;
final int? filterUserId = widget.showAllPayments ? null : currentUser?.opeUserId;
for (final passage in passagesBox.values) {
// En mode user, ne compter que les passages de l'utilisateur
// En mode user, ne compter que les passages de l'utilisateur (comparer avec ope_users.id)
if (filterUserId != null && passage.fkUser != filterUserId) {
continue;
}

View File

@@ -35,7 +35,7 @@ class _ChatInputState extends State<ChatInput> {
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
color: Colors.black.withOpacity(0.05),
blurRadius: 5,
offset: const Offset(0, -2),
),
@@ -195,7 +195,7 @@ class _ChatInputState extends State<ChatInput> {
width: 56,
height: 56,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
color: color.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Icon(

View File

@@ -87,7 +87,7 @@ class ChatMessages extends StatelessWidget {
CircleAvatar(
radius: 16,
backgroundColor:
AppTheme.primaryColor.withValues(alpha: 0.2),
AppTheme.primaryColor.withOpacity(0.2),
backgroundImage: message['avatar'] != null
? AssetImage(message['avatar'] as String)
: null,
@@ -141,7 +141,7 @@ class ChatMessages extends StatelessWidget {
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
color: Colors.black.withOpacity(0.05),
blurRadius: 3,
offset: const Offset(0, 1),
),

View File

@@ -31,7 +31,7 @@ class ChatSidebar extends StatelessWidget {
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
color: Colors.black.withOpacity(0.05),
blurRadius: 5,
offset: const Offset(0, 2),
),
@@ -114,9 +114,9 @@ class ChatSidebar extends StatelessWidget {
return ListTile(
selected: isSelected,
selectedTileColor: Colors.blue.withValues(alpha: 0.1),
selectedTileColor: Colors.blue.withOpacity(0.1),
leading: CircleAvatar(
backgroundColor: AppTheme.primaryColor.withValues(alpha: 0.2),
backgroundColor: AppTheme.primaryColor.withOpacity(0.2),
backgroundImage: contact['avatar'] != null
? AssetImage(contact['avatar'] as String)
: null,

View File

@@ -78,7 +78,7 @@ class ClearCacheDialog extends StatelessWidget {
'Note : Cette opération est nécessaire en raison d\'une mise à jour de la structure des données. Toutes vos données seront récupérées depuis le serveur après reconnexion.',
style: theme.textTheme.bodySmall?.copyWith(
fontStyle: FontStyle.italic,
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
color: theme.colorScheme.onSurface.withOpacity(0.7),
),
),
],

View File

@@ -4,6 +4,7 @@ import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/data/models/pending_request.dart';
import 'package:geosector_app/core/services/api_service.dart';
/// Widget qui affiche l'état de la connexion Internet et le nombre de requêtes en attente
class ConnectivityIndicator extends StatefulWidget {
@@ -105,10 +106,10 @@ class _ConnectivityIndicatorState extends State<ConnectivityIndicator>
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
margin: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration(
color: theme.colorScheme.error.withValues(alpha: 0.1),
color: theme.colorScheme.error.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: theme.colorScheme.error.withValues(alpha: 0.3),
color: theme.colorScheme.error.withOpacity(0.3),
),
),
child: Row(
@@ -187,38 +188,41 @@ class _ConnectivityIndicatorState extends State<ConnectivityIndicator>
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
decoration: BoxDecoration(
color: pendingCount > 0
? Colors.orange.withValues(alpha: 0.1 * _animation.value)
: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: pendingCount > 0
? Colors.orange.withValues(alpha: 0.3 * _animation.value)
: color.withValues(alpha: 0.3),
return GestureDetector(
onTap: pendingCount > 0 ? () => _showPendingRequestsDialog(context) : null,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
decoration: BoxDecoration(
color: pendingCount > 0
? Colors.orange.withOpacity(0.1 * _animation.value)
: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: pendingCount > 0
? Colors.orange.withOpacity(0.3 * _animation.value)
: color.withOpacity(0.3),
),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
pendingCount > 0 ? Icons.sync : icon,
color: pendingCount > 0 ? Colors.orange : color,
size: 14,
),
const SizedBox(width: 4),
Text(
pendingCount > 0
? '$pendingCount en attente'
: connectionType,
style: theme.textTheme.bodySmall?.copyWith(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
pendingCount > 0 ? Icons.sync : icon,
color: pendingCount > 0 ? Colors.orange : color,
fontWeight: FontWeight.bold,
size: 14,
),
),
],
const SizedBox(width: 4),
Text(
pendingCount > 0
? '$pendingCount en attente'
: connectionType,
style: theme.textTheme.bodySmall?.copyWith(
color: pendingCount > 0 ? Colors.orange : color,
fontWeight: FontWeight.bold,
),
),
],
),
),
);
},
@@ -238,10 +242,10 @@ class _ConnectivityIndicatorState extends State<ConnectivityIndicator>
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
margin: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration(
color: theme.colorScheme.error.withValues(alpha: 0.1),
color: theme.colorScheme.error.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: theme.colorScheme.error.withValues(alpha: 0.3),
color: theme.colorScheme.error.withOpacity(0.3),
),
),
child: Row(
@@ -270,10 +274,10 @@ class _ConnectivityIndicatorState extends State<ConnectivityIndicator>
return Container(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: color.withValues(alpha: 0.3),
color: color.withOpacity(0.3),
),
),
child: Row(
@@ -346,4 +350,335 @@ class _ConnectivityIndicatorState extends State<ConnectivityIndicator>
return theme.colorScheme.error;
}
}
/// Affiche une boîte de dialogue pour gérer les requêtes en attente
void _showPendingRequestsDialog(BuildContext context) {
final box = Hive.box<PendingRequest>(AppKeys.pendingRequestsBoxName);
showDialog(
context: context,
builder: (dialogContext) => ValueListenableBuilder<Box<PendingRequest>>(
valueListenable: box.listenable(),
builder: (context, box, _) {
final requests = box.values.toList()
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
// Si plus de requêtes, fermer la dialog
if (requests.isEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (dialogContext.mounted) {
Navigator.of(dialogContext).pop();
}
});
}
return AlertDialog(
title: Row(
children: [
const Icon(Icons.sync_problem, color: Colors.orange),
const SizedBox(width: 8),
Text('Requêtes en attente (${requests.length})'),
],
),
content: SizedBox(
width: double.maxFinite,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Actions globales
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
ElevatedButton.icon(
onPressed: () async {
// Réessayer toutes les requêtes
Navigator.of(dialogContext).pop();
await ApiService.instance.processPendingRequests();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Traitement des requêtes en cours...'),
backgroundColor: Colors.blue,
),
);
}
},
icon: const Icon(Icons.refresh),
label: const Text('Tout réessayer'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
),
),
ElevatedButton.icon(
onPressed: () async {
// Confirmer avant de tout supprimer
final confirmed = await showDialog<bool>(
context: dialogContext,
builder: (confirmContext) => AlertDialog(
title: const Text('Confirmation'),
content: const Text(
'Êtes-vous sûr de vouloir supprimer toutes les requêtes en attente ?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(confirmContext).pop(false),
child: const Text('Annuler'),
),
TextButton(
onPressed: () => Navigator.of(confirmContext).pop(true),
child: const Text('Supprimer'),
),
],
),
);
if (confirmed == true) {
await box.clear();
if (dialogContext.mounted) {
Navigator.of(dialogContext).pop();
}
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Toutes les requêtes ont été supprimées'),
backgroundColor: Colors.green,
),
);
}
}
},
icon: const Icon(Icons.delete_sweep),
label: const Text('Tout supprimer'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
),
],
),
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 8),
// Liste des requêtes
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: requests.length,
itemBuilder: (context, index) {
final request = requests[index];
final hasConflict = request.metadata?['hasConflict'] == true;
final hasErrors = request.retryCount >= 5;
return Card(
color: hasConflict
? Colors.red.shade50
: hasErrors
? Colors.orange.shade50
: null,
child: ListTile(
leading: Icon(
hasConflict
? Icons.error
: hasErrors
? Icons.warning
: Icons.sync,
color: hasConflict
? Colors.red
: hasErrors
? Colors.orange
: Colors.blue,
),
title: Text('${request.method} ${request.path}'),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Créé: ${_formatDate(request.createdAt)}',
style: const TextStyle(fontSize: 11),
),
if (request.retryCount > 0)
Text(
'Tentatives: ${request.retryCount}',
style: const TextStyle(fontSize: 11),
),
if (hasConflict)
const Text(
'CONFLIT (409)',
style: TextStyle(
fontSize: 11,
color: Colors.red,
fontWeight: FontWeight.bold,
),
),
if (hasErrors)
const Text(
'ÉCHEC (5 tentatives)',
style: TextStyle(
fontSize: 11,
color: Colors.orange,
fontWeight: FontWeight.bold,
),
),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Bouton détails
IconButton(
icon: const Icon(Icons.info_outline, size: 20),
tooltip: 'Détails',
onPressed: () => _showRequestDetails(dialogContext, request),
),
// Bouton réessayer
if (hasConflict || hasErrors)
IconButton(
icon: const Icon(Icons.refresh, size: 20),
tooltip: 'Réessayer',
color: Colors.blue,
onPressed: () async {
await ApiService.instance.resolveConflictByRetry(request.id);
if (dialogContext.mounted) {
Navigator.of(dialogContext).pop();
}
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Requête marquée pour réessai'),
backgroundColor: Colors.blue,
),
);
}
},
),
// Bouton supprimer
IconButton(
icon: const Icon(Icons.delete_outline, size: 20),
tooltip: 'Supprimer',
color: Colors.red,
onPressed: () async {
if (hasConflict) {
await ApiService.instance.resolveConflictByDeletion(request.id);
} else {
await box.delete(request.key);
}
// La dialog se ferme automatiquement via ValueListenableBuilder si box vide
},
),
],
),
),
);
},
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('Fermer'),
),
],
);
},
),
);
}
/// Affiche les détails d'une requête
void _showRequestDetails(BuildContext context, PendingRequest request) {
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Détails de la requête'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildDetailRow('Méthode', request.method),
_buildDetailRow('Chemin', request.path),
_buildDetailRow('Créé le', _formatDate(request.createdAt)),
_buildDetailRow('Tentatives', request.retryCount.toString()),
if (request.tempId != null)
_buildDetailRow('ID temporaire', request.tempId!),
if (request.errorMessage != null)
_buildDetailRow('Erreur', request.errorMessage!, isError: true),
if (request.metadata != null && request.metadata!.isNotEmpty)
_buildDetailRow('Métadonnées', request.metadata.toString()),
if (request.data != null) ...[
const SizedBox(height: 8),
const Text(
'Données:',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(4),
),
child: Text(
request.data.toString(),
style: const TextStyle(fontSize: 11, fontFamily: 'monospace'),
),
),
],
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('Fermer'),
),
],
),
);
}
Widget _buildDetailRow(String label, String value, {bool isError = false}) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 100,
child: Text(
'$label:',
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
Expanded(
child: Text(
value,
style: TextStyle(
color: isError ? Colors.red : null,
),
),
),
],
),
);
}
String _formatDate(DateTime date) {
final now = DateTime.now();
final diff = now.difference(date);
if (diff.inMinutes < 1) {
return 'Il y a quelques secondes';
} else if (diff.inHours < 1) {
return 'Il y a ${diff.inMinutes} min';
} else if (diff.inDays < 1) {
return 'Il y a ${diff.inHours} h';
} else {
return 'Il y a ${diff.inDays} jour${diff.inDays > 1 ? 's' : ''}';
}
}
}

View File

@@ -95,7 +95,7 @@ class CustomTextField extends StatelessWidget {
child: Text(
'$currentLength/${maxLength ?? 0}',
style: theme.textTheme.bodySmall?.copyWith(
color: currentLength > (maxLength ?? 0) * 0.8 ? theme.colorScheme.error : theme.colorScheme.onSurface.withValues(alpha: 0.6),
color: currentLength > (maxLength ?? 0) * 0.8 ? theme.colorScheme.error : theme.colorScheme.onSurface.withOpacity(0.6),
),
),
);
@@ -165,7 +165,7 @@ class CustomTextField extends StatelessWidget {
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: theme.colorScheme.outline.withValues(alpha: 0.5),
color: theme.colorScheme.outline.withOpacity(0.5),
),
),
focusedBorder: OutlineInputBorder(
@@ -190,7 +190,7 @@ class CustomTextField extends StatelessWidget {
),
),
filled: true,
fillColor: readOnly ? theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.3) : theme.colorScheme.surface,
fillColor: readOnly ? theme.colorScheme.surfaceContainerHighest.withOpacity(0.3) : theme.colorScheme.surface,
contentPadding: contentPadding ?? const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
@@ -203,7 +203,7 @@ class CustomTextField extends StatelessWidget {
child: Text(
'$currentLength/${maxLength ?? 0}',
style: theme.textTheme.bodySmall?.copyWith(
color: currentLength > (maxLength ?? 0) * 0.8 ? theme.colorScheme.error : theme.colorScheme.onSurface.withValues(alpha: 0.6),
color: currentLength > (maxLength ?? 0) * 0.8 ? theme.colorScheme.error : theme.colorScheme.onSurface.withOpacity(0.6),
),
),
);

View File

@@ -7,6 +7,8 @@ import 'package:geosector_app/core/services/current_amicale_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/core/utils/api_exception.dart';
import 'package:geosector_app/presentation/widgets/loading_spin_overlay.dart';
import 'package:geosector_app/presentation/widgets/result_dialog.dart';
import 'package:go_router/go_router.dart';
/// AppBar personnalisée pour les tableaux de bord
@@ -184,19 +186,45 @@ class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget {
readOnly: false,
showRoleSelector: false,
onSubmit: (updatedUser, {String? password}) async {
// Afficher le loading
final overlay = LoadingSpinOverlayUtils.show(
context: context,
message: 'Mise à jour du profil...',
);
try {
// Sauvegarder les modifications de l'utilisateur
// Note: password est ignoré ici car l'utilisateur normal ne peut pas changer son mot de passe
await userRepository.updateUser(updatedUser);
// Masquer le loading
LoadingSpinOverlayUtils.hideSpecific(overlay);
if (context.mounted) {
Navigator.of(context).pop();
ApiException.showSuccess(context, 'Profil mis à jour');
// Afficher le résultat de succès
await ResultDialog.show(
context: context,
success: true,
message: 'Profil mis à jour',
);
if (context.mounted) {
Navigator.of(context).pop();
}
}
} catch (e) {
debugPrint('❌ Erreur mise à jour de votre profil: $e');
// Masquer le loading
LoadingSpinOverlayUtils.hideSpecific(overlay);
if (context.mounted) {
ApiException.showError(context, e);
// Afficher l'erreur
await ResultDialog.show(
context: context,
success: false,
message: ApiException.fromError(e).message,
);
}
}
},

View File

@@ -0,0 +1,648 @@
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/data/models/passage_model.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/utils/api_exception.dart';
import 'package:geosector_app/core/services/current_amicale_service.dart';
import 'package:geosector_app/presentation/widgets/passage_form_dialog.dart';
import 'package:geosector_app/app.dart';
/// Dialogue pour afficher les passages groupés d'un immeuble (fkHabitat=2)
class GroupedPassagesDialog extends StatelessWidget {
final PassageModel referencePassage;
final bool isAdmin;
const GroupedPassagesDialog({
super.key,
required this.referencePassage,
this.isAdmin = false,
});
@override
Widget build(BuildContext context) {
// Construire l'adresse complète
final String adresse =
'${referencePassage.numero} ${referencePassage.rueBis} ${referencePassage.rue}'
.trim();
final String ville = referencePassage.ville;
final String residence = referencePassage.residence;
// Calculer les dimensions
final screenWidth = MediaQuery.of(context).size.width;
final screenHeight = MediaQuery.of(context).size.height;
final dialogWidth = kIsWeb
? 600.0 // Web : largeur fixe plus large
: screenWidth * 0.9; // Mobile : 90% largeur
final dialogHeight = screenHeight * 0.8; // 80% hauteur max
// Vérifier si l'utilisateur peut supprimer
bool canDelete = isAdmin;
if (!isAdmin) {
try {
final amicale = CurrentAmicaleService.instance.currentAmicale;
if (amicale != null) {
canDelete = amicale.chkUserDeletePass == true;
}
} catch (e) {
debugPrint('Erreur lors de la vérification des permissions: $e');
}
}
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Container(
width: dialogWidth,
constraints: BoxConstraints(
maxHeight: dialogHeight,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// En-tête avec adresse, ville, résidence et bouton X
_buildHeader(context, adresse, ville, residence),
const Divider(height: 1),
// Liste des passages avec ValueListenableBuilder
Flexible(
child: ValueListenableBuilder<Box<PassageModel>>(
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName)
.listenable(),
builder: (context, box, child) {
// Filtrer les passages de la même adresse
final passages = _filterPassagesByAddress(box);
if (passages.isEmpty) {
return const Center(
child: Padding(
padding: EdgeInsets.all(24.0),
child: Text('Aucun passage trouvé'),
),
);
}
return ListView.separated(
shrinkWrap: true,
itemCount: passages.length,
separatorBuilder: (context, index) => const Divider(height: 1),
itemBuilder: (context, index) {
final passage = passages[index];
return _buildPassageItem(context, passage, canDelete);
},
);
},
),
),
],
),
),
);
}
/// Construire l'en-tête avec adresse, ville, résidence et boutons
Widget _buildHeader(
BuildContext context, String adresse, String ville, String residence) {
return Container(
padding: const EdgeInsets.all(16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Adresse
if (adresse.isNotEmpty)
Text(
adresse,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
// Ville
if (ville.isNotEmpty) ...[
const SizedBox(height: 4),
Row(
children: [
Icon(Icons.location_city, size: 16, color: Colors.grey[600]),
const SizedBox(width: 4),
Text(
ville,
style: TextStyle(
fontSize: 14,
color: Colors.grey[700],
),
),
],
),
],
// Résidence
if (residence.isNotEmpty) ...[
const SizedBox(height: 4),
Row(
children: [
Icon(Icons.apartment, size: 16, color: Colors.grey[600]),
const SizedBox(width: 4),
Text(
residence,
style: TextStyle(
fontSize: 14,
color: Colors.grey[700],
),
),
],
),
],
],
),
),
// Bouton + pour ajouter un passage
IconButton(
onPressed: () => _showAddPassageDialog(context),
icon: const Icon(Icons.add_circle, size: 28),
tooltip: 'Ajouter un passage',
color: Colors.green,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
const SizedBox(width: 8),
// Bouton X pour fermer
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close),
tooltip: 'Fermer',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
);
}
/// Construire une ligne de passage
Widget _buildPassageItem(
BuildContext context, PassageModel passage, bool canDelete) {
final int type = passage.fkType;
// Récupérer la couleur2 du type
final Color typeColor =
Color(AppKeys.typesPassages[type]?['couleur2'] ?? 0xFF9E9E9E);
// Niveau + Appt
final String location = [
if (passage.niveau.isNotEmpty) 'Niv. ${passage.niveau}',
if (passage.appt.isNotEmpty) 'Appt ${passage.appt}',
].join(', ');
// Calculer le montant et vérifier s'il est payé
final amount = _parseAmount(passage.montant);
final isPaid = amount > 0;
final formattedAmount = '${amount.toStringAsFixed(2).replaceAll('.', ',')}';
return ListTile(
dense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
onTap: () => _showEditDialog(context, passage),
leading: Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: typeColor,
shape: BoxShape.circle,
),
),
title: Row(
children: [
// Nom
if (passage.name.isNotEmpty)
Flexible(
child: Text(
passage.name,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
),
overflow: TextOverflow.ellipsis,
),
)
else
Text(
'Sans nom',
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
fontStyle: FontStyle.italic,
),
),
],
),
subtitle: location.isNotEmpty || (isPaid && (type == 1 || type == 5))
? _buildSubtitle(context, location, passage, isPaid, type, formattedAmount)
: null,
trailing: _buildTrailing(context, passage, canDelete),
);
}
/// Construire la ligne 2 (subtitle) avec Niveau/Appt + Badge montant
Widget _buildSubtitle(
BuildContext context,
String location,
PassageModel passage,
bool isPaid,
int type,
String formattedAmount,
) {
return Row(
children: [
// Niveau + Appt
if (location.isNotEmpty)
Text(
location,
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
const Spacer(),
// Badge montant (si > 0 et type 1 ou 5)
if (isPaid && (type == 1 || type == 5)) ...[
// Récupérer le type de règlement
Builder(
builder: (context) {
final typeReglement = passage.fkTypeReglement;
final reglementInfo = AppKeys.typesReglements[typeReglement];
final reglementIcon = reglementInfo?['icon_data'] as IconData? ?? Icons.help_outline;
final reglementColor = Color(reglementInfo?['couleur'] as int? ?? 0xFF9E9E9E);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
decoration: BoxDecoration(
color: reglementColor.withOpacity(0.15),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: reglementColor.withOpacity(0.4),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
reglementIcon,
size: 12,
color: reglementColor,
),
const SizedBox(width: 4),
Text(
formattedAmount,
style: TextStyle(
color: reglementColor,
fontWeight: FontWeight.bold,
fontSize: 11,
),
),
],
),
);
},
),
],
],
);
}
/// Construire le trailing avec icône remarque et bouton delete (ligne 1)
Widget? _buildTrailing(
BuildContext context,
PassageModel passage,
bool canDelete,
) {
final List<Widget> trailingWidgets = [];
// Icône remarque (si passage.remarque non vide)
if (passage.remarque.isNotEmpty) {
trailingWidgets.add(
Icon(
Icons.comment_outlined,
size: 16,
color: Colors.orange[700],
),
);
}
// Bouton delete
if (canDelete) {
if (trailingWidgets.isNotEmpty) {
trailingWidgets.add(const SizedBox(width: 8));
}
trailingWidgets.add(
IconButton(
onPressed: () => _showDeleteDialog(context, passage),
icon: const Icon(Icons.delete, size: 20),
tooltip: 'Supprimer',
padding: const EdgeInsets.all(8),
constraints: const BoxConstraints(),
color: Colors.red,
),
);
}
// Retourner null si aucun widget, sinon Row
if (trailingWidgets.isEmpty) return null;
if (trailingWidgets.length == 1) return trailingWidgets.first;
return Row(
mainAxisSize: MainAxisSize.min,
children: trailingWidgets,
);
}
/// Parser le montant depuis String vers double
double _parseAmount(String montantStr) {
if (montantStr.isEmpty) return 0.0;
try {
final cleaned = montantStr.replaceAll(',', '.');
return double.tryParse(cleaned) ?? 0.0;
} catch (e) {
return 0.0;
}
}
/// Filtrer les passages par adresse et trier par niveau + appt
List<PassageModel> _filterPassagesByAddress(Box<PassageModel> box) {
// Clé d'adresse du passage de référence
final referenceKey =
'${referencePassage.numero}|${referencePassage.rueBis}|${referencePassage.rue}|${referencePassage.ville}';
// Filtrer les passages de la même adresse
final passages = box.values.where((p) {
final key = '${p.numero}|${p.rueBis}|${p.rue}|${p.ville}';
return key == referenceKey && p.fkHabitat == 2;
}).toList();
// Trier par niveau puis appt
passages.sort((a, b) {
// Convertir niveau en int pour tri numérique
final nivA = int.tryParse(a.niveau) ?? 0;
final nivB = int.tryParse(b.niveau) ?? 0;
if (nivA != nivB) {
return nivA.compareTo(nivB);
}
// Si même niveau, trier par appt
final apptA = a.appt.toLowerCase();
final apptB = b.appt.toLowerCase();
return apptA.compareTo(apptB);
});
return passages;
}
/// Afficher le dialogue de modification
void _showEditDialog(BuildContext context, PassageModel passage) {
showDialog(
context: context,
builder: (BuildContext dialogContext) {
return PassageFormDialog(
passage: passage,
title: 'Modifier le passage',
passageRepository: passageRepository,
userRepository: userRepository,
operationRepository: operationRepository,
amicaleRepository: amicaleRepository,
// Pas de callback onSuccess - ValueListenableBuilder gère la réactivité
);
},
);
}
/// Afficher le dialogue d'ajout d'un passage pré-rempli
void _showAddPassageDialog(BuildContext context) {
// Créer un passage temporaire pré-rempli avec les infos de l'immeuble
final newPassage = PassageModel(
id: 0, // Nouveau passage
fkOperation: referencePassage.fkOperation,
fkSector: referencePassage.fkSector,
fkUser: referencePassage.fkUser,
fkType: 2, // Type "À finaliser" par défaut
fkAdresse: referencePassage.fkAdresse,
passedAt: DateTime.now(),
numero: referencePassage.numero,
rue: referencePassage.rue,
rueBis: referencePassage.rueBis,
ville: referencePassage.ville,
residence: referencePassage.residence,
fkHabitat: 2, // Appartement
appt: '', // Vide pour saisie
niveau: '', // Vide pour saisie
gpsLat: referencePassage.gpsLat,
gpsLng: referencePassage.gpsLng,
nomRecu: '',
remarque: '',
montant: '0.00',
fkTypeReglement: 4,
emailErreur: '',
nbPassages: 1,
name: '',
email: '',
phone: '',
stripePaymentId: null,
lastSyncedAt: DateTime.now(),
isActive: true,
isSynced: false,
);
showDialog(
context: context,
builder: (BuildContext dialogContext) {
return PassageFormDialog(
passage: newPassage,
title: 'Nouveau passage dans l\'immeuble',
passageRepository: passageRepository,
userRepository: userRepository,
operationRepository: operationRepository,
amicaleRepository: amicaleRepository,
// Pas de callback onSuccess - ValueListenableBuilder gère la réactivité
);
},
);
}
/// Afficher le dialogue de suppression
void _showDeleteDialog(BuildContext context, PassageModel passage) {
// Réutiliser le même système de confirmation que PassageMapDialog
final TextEditingController confirmController = TextEditingController();
final String streetNumber = passage.numero;
final String fullAddress =
'${passage.numero} ${passage.rueBis} ${passage.rue}'.trim();
showDialog(
context: context,
barrierDismissible: false,
builder: (BuildContext dialogContext) {
return AlertDialog(
title: const Row(
children: [
Icon(Icons.warning, color: Colors.red, size: 28),
SizedBox(width: 8),
Text('Confirmation de suppression'),
],
),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'ATTENTION : Cette action est irréversible !',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.red,
fontSize: 16,
),
),
const SizedBox(height: 16),
Text(
'Vous êtes sur le point de supprimer définitivement le passage :',
style: TextStyle(color: Colors.grey[800]),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey[300]!),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
fullAddress.isEmpty ? 'Adresse inconnue' : fullAddress,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
if (passage.niveau.isNotEmpty || passage.appt.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
[
if (passage.niveau.isNotEmpty) 'Niveau ${passage.niveau}',
if (passage.appt.isNotEmpty) 'Appt ${passage.appt}',
].join(', '),
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
],
if (passage.name.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
passage.name,
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
],
],
),
),
const SizedBox(height: 20),
const Text(
'Pour confirmer la suppression, veuillez saisir le numéro de rue de ce passage :',
style: TextStyle(fontWeight: FontWeight.w500),
),
const SizedBox(height: 12),
TextField(
controller: confirmController,
decoration: InputDecoration(
labelText: 'Numéro de rue',
hintText: streetNumber.isNotEmpty
? 'Ex: $streetNumber'
: 'Saisir le numéro',
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.home),
),
keyboardType: TextInputType.text,
textCapitalization: TextCapitalization.characters,
),
],
),
),
actions: [
TextButton(
onPressed: () {
confirmController.dispose();
Navigator.of(dialogContext).pop();
},
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () async {
// Vérifier que le numéro saisi correspond
final enteredNumber = confirmController.text.trim();
if (enteredNumber.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Veuillez saisir le numéro de rue'),
backgroundColor: Colors.orange,
),
);
return;
}
if (streetNumber.isNotEmpty &&
enteredNumber.toUpperCase() != streetNumber.toUpperCase()) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Le numéro de rue ne correspond pas'),
backgroundColor: Colors.red,
),
);
return;
}
// Fermer le dialog
confirmController.dispose();
Navigator.of(dialogContext).pop();
// Effectuer la suppression
await _deletePassage(context, passage);
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
child: const Text('Supprimer définitivement'),
),
],
);
},
);
}
/// Supprimer un passage
Future<void> _deletePassage(BuildContext context, PassageModel passage) async {
try {
// Appeler le repository pour supprimer via l'API
final success = await passageRepository.deletePassageViaApi(passage.id);
if (success && context.mounted) {
ApiException.showSuccess(context, 'Passage supprimé avec succès');
// Pas de callback - ValueListenableBuilder rafraîchit automatiquement
} else if (context.mounted) {
ApiException.showError(
context, Exception('Erreur lors de la suppression'));
}
} catch (e) {
debugPrint('Erreur suppression passage: $e');
if (context.mounted) {
ApiException.showError(context, e);
}
}
}
}

View File

@@ -87,7 +87,7 @@ class HiveResetDialog extends StatelessWidget {
'Note : Si vous aviez des modifications non synchronisées, elles ont été perdues. Nous vous recommandons de synchroniser régulièrement vos données.',
style: theme.textTheme.bodySmall?.copyWith(
fontStyle: FontStyle.italic,
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
color: theme.colorScheme.onSurface.withOpacity(0.7),
),
),
],

View File

@@ -19,7 +19,7 @@ class LoadingSpinOverlay extends StatefulWidget {
this.spinnerColor = Colors.blue,
this.textColor = Colors.white,
this.blurAmount = 8.0,
this.spinnerSize = 50.0,
this.spinnerSize = 64.0,
this.showCard = true,
});
@@ -95,11 +95,11 @@ class _LoadingSpinOverlayState extends State<LoadingSpinOverlay>
maxWidth: 280,
),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.92), // Semi-transparent
color: Colors.white.withOpacity(0.92), // Semi-transparent
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.15),
color: Colors.black.withOpacity(0.15),
blurRadius: 20,
spreadRadius: 2,
offset: const Offset(0, 8),
@@ -114,7 +114,7 @@ class _LoadingSpinOverlayState extends State<LoadingSpinOverlay>
width: widget.spinnerSize,
height: widget.spinnerSize,
child: CircularProgressIndicator(
strokeWidth: 3,
strokeWidth: 4.5,
valueColor: AlwaysStoppedAnimation<Color>(widget.spinnerColor),
),
),
@@ -145,7 +145,7 @@ class _LoadingSpinOverlayState extends State<LoadingSpinOverlay>
width: widget.spinnerSize,
height: widget.spinnerSize,
child: CircularProgressIndicator(
strokeWidth: 3,
strokeWidth: 4.5,
valueColor: AlwaysStoppedAnimation<Color>(widget.spinnerColor),
),
),

View File

@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_map_cache/flutter_map_cache.dart';
import 'package:http_cache_hive_store/http_cache_hive_store.dart'; // Mise à jour v2.0.0 (06/10/2025)
import 'package:dio_cache_interceptor_hive_store/dio_cache_interceptor_hive_store.dart';
import 'package:path_provider/path_provider.dart';
import 'package:latlong2/latlong.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
@@ -160,7 +160,7 @@ class _MapboxMapState extends State<MapboxMap> {
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.2),
color: Colors.black.withOpacity(0.2),
blurRadius: 6,
offset: const Offset(0, 3),
),
@@ -198,12 +198,6 @@ class _MapboxMapState extends State<MapboxMap> {
// Format: mapbox.streets pour les rues, mapbox.satellite pour satellite
urlTemplate = 'https://api.tiles.mapbox.com/v4/mapbox.streets/{z}/{x}/{y}@2x.png?access_token=$mapboxToken';
}
// Debug pour vérifier la configuration
debugPrint('MapboxMap: Plateforme: ${kIsWeb ? "Web" : "Mobile"}');
debugPrint('MapboxMap: Environnement: $environment');
debugPrint('MapboxMap: Token: ${mapboxToken.substring(0, 10)}...'); // Afficher seulement le début du token
debugPrint('MapboxMap: URL Template: ${urlTemplate.substring(0, 50)}...');
}
// Afficher un indicateur pendant l'initialisation du cache
@@ -260,10 +254,8 @@ class _MapboxMapState extends State<MapboxMap> {
),
onMapEvent: (event) {
if (event is MapEventMove) {
setState(() {
// Dans flutter_map 8.1.1, nous devons utiliser le contrôleur pour obtenir le zoom actuel
_currentZoom = _mapController.camera.zoom;
});
// Mise à jour du zoom sans rebuild (la variable n'est pas utilisée dans le UI)
_currentZoom = _mapController.camera.zoom;
}
// Appeler le callback externe si fourni
@@ -276,7 +268,7 @@ class _MapboxMapState extends State<MapboxMap> {
// Tuiles de la carte (Mapbox)
TileLayer(
urlTemplate: urlTemplate,
userAgentPackageName: 'app.geosector.fr',
userAgentPackageName: 'app3.geosector.fr',
maxNativeZoom: 19,
maxZoom: 20,
minZoom: 7,

File diff suppressed because it is too large Load Diff

View File

@@ -27,13 +27,13 @@ class MembreRowWidget extends StatelessWidget {
// Couleur de fond alternée
final backgroundColor = isAlternate
? theme.colorScheme.primary.withValues(alpha: 0.05)
? theme.colorScheme.primary.withOpacity(0.05)
: Colors.transparent;
return InkWell(
// Envelopper le contenu dans un InkWell
onTap: onTap, // Utiliser le callback onTap
hoverColor: theme.colorScheme.primary.withValues(alpha: 0.15),
hoverColor: theme.colorScheme.primary.withOpacity(0.15),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0),
decoration: BoxDecoration(

View File

@@ -43,7 +43,7 @@ class MembreTableWidget extends StatelessWidget {
borderRadius: BorderRadius.circular(8.0),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
@@ -58,7 +58,7 @@ class MembreTableWidget extends StatelessWidget {
padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 16.0),
margin: const EdgeInsets.only(bottom: 16.0),
decoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(alpha: 0.1),
color: theme.colorScheme.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(4.0),
),
child: Row(
@@ -189,7 +189,7 @@ class MembreTableWidget extends StatelessWidget {
child: Text(
emptyMessage ?? 'Aucun membre trouvé',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6),
),
),
);
@@ -199,7 +199,7 @@ class MembreTableWidget extends StatelessWidget {
return ListView.separated(
itemCount: membres.length,
separatorBuilder: (context, index) => Divider(
color: Theme.of(context).dividerColor.withValues(alpha: 0.3),
color: Theme.of(context).dividerColor.withOpacity(0.3),
height: 1,
),
itemBuilder: (context, index) {

View File

@@ -7,7 +7,7 @@ import 'package:uuid/uuid.dart';
/// Widget de test pour vérifier le fonctionnement de la file d'attente offline
/// À utiliser uniquement en développement
class OfflineTestButton extends StatefulWidget {
const OfflineTestButton({Key? key}) : super(key: key);
const OfflineTestButton({super.key});
@override
State<OfflineTestButton> createState() => _OfflineTestButtonState();

View File

@@ -5,6 +5,8 @@ import 'package:geosector_app/core/repositories/operation_repository.dart';
import 'package:geosector_app/core/repositories/user_repository.dart';
import 'package:geosector_app/core/utils/api_exception.dart';
import 'package:geosector_app/presentation/widgets/custom_text_field.dart';
import 'package:geosector_app/presentation/widgets/result_dialog.dart';
import 'package:geosector_app/presentation/widgets/loading_spin_overlay.dart';
class OperationFormDialog extends StatefulWidget {
final OperationModel? operation;
@@ -140,6 +142,12 @@ class _OperationFormDialogState extends State<OperationFormDialog> {
_isSubmitting = true;
});
// Afficher l'overlay de chargement
final overlay = LoadingSpinOverlayUtils.show(
context: context,
message: 'Enregistrement en cours...',
);
try {
// Récupérer l'utilisateur actuel pour le fkEntite
final currentUser = widget.userRepository.getCurrentUser();
@@ -173,45 +181,58 @@ class _OperationFormDialogState extends State<OperationFormDialog> {
final success = await widget.operationRepository.saveOperationFromModel(operationData);
if (success && mounted) {
debugPrint('=== SUCCÈS - AUTO-FERMETURE ===');
debugPrint('=== context.mounted: ${context.mounted} ===');
debugPrint('=== SUCCÈS ===');
// Délai pour laisser le temps à Hive de se synchroniser
Future.delayed(const Duration(milliseconds: 200), () {
if (mounted) {
debugPrint('=== FERMETURE DIFFÉRÉE ===');
// Masquer le loading
LoadingSpinOverlayUtils.hideSpecific(overlay);
// Auto-fermeture de la dialog
try {
debugPrint('=== AVANT Navigator.pop() ===');
Navigator.of(context).pop();
debugPrint('=== APRÈS Navigator.pop() ===');
} catch (e) {
debugPrint('=== ERREUR Navigator.pop(): $e ===');
}
// Afficher le résultat de succès
await ResultDialog.show(
context: context,
success: true,
message: widget.operation == null
? "Nouvelle opération créée avec succès"
: "Opération modifiée avec succès",
);
// Notifier la page parente pour setState()
debugPrint('=== AVANT onSuccess?.call() ===');
// Auto-fermeture de la dialog
if (mounted) {
debugPrint('=== FERMETURE DIALOG ===');
try {
Navigator.of(context).pop();
widget.onSuccess?.call();
debugPrint('=== APRÈS onSuccess?.call() ===');
// Message de succès
Future.delayed(const Duration(milliseconds: 100), () {
if (mounted) {
debugPrint('=== AFFICHAGE MESSAGE SUCCÈS ===');
ApiException.showSuccess(context, widget.operation == null ? "Nouvelle opération créée avec succès" : "Opération modifiée avec succès");
}
});
} catch (e) {
debugPrint('=== ERREUR Navigator.pop(): $e ===');
}
});
}
} else if (mounted) {
debugPrint('=== ÉCHEC - AFFICHAGE ERREUR ===');
ApiException.showError(context, Exception(widget.operation == null ? "Échec de la création de l'opération" : "Échec de la mise à jour de l'opération"));
debugPrint('=== ÉCHEC ===');
// Masquer le loading
LoadingSpinOverlayUtils.hideSpecific(overlay);
// Afficher l'erreur
await ResultDialog.show(
context: context,
success: false,
message: widget.operation == null
? "Échec de la création de l'opération"
: "Échec de la mise à jour de l'opération",
);
}
} catch (e) {
debugPrint('=== ERREUR dans _handleSubmit: $e ===');
// Masquer le loading
LoadingSpinOverlayUtils.hideSpecific(overlay);
// Afficher l'erreur
if (mounted) {
ApiException.showError(context, e);
await ResultDialog.show(
context: context,
success: false,
message: ApiException.fromError(e).message,
);
}
} finally {
// Réinitialiser l'état de soumission seulement si le widget est encore monté
@@ -310,9 +331,9 @@ class _OperationFormDialogState extends State<OperationFormDialog> {
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: theme.colorScheme.outline.withValues(alpha: 0.5)),
border: Border.all(color: theme.colorScheme.outline.withOpacity(0.5)),
borderRadius: BorderRadius.circular(8),
color: theme.colorScheme.surface.withValues(alpha: 0.3),
color: theme.colorScheme.surface.withOpacity(0.3),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -422,10 +443,10 @@ class _OperationFormDialogState extends State<OperationFormDialog> {
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.secondaryContainer.withValues(alpha: 0.3),
color: theme.colorScheme.secondaryContainer.withOpacity(0.3),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: theme.colorScheme.outline.withValues(alpha: 0.3),
color: theme.colorScheme.outline.withOpacity(0.3),
),
),
child: Row(

View File

@@ -11,11 +11,15 @@ import 'package:geosector_app/core/repositories/amicale_repository.dart';
import 'package:geosector_app/core/services/current_user_service.dart';
import 'package:geosector_app/core/services/current_amicale_service.dart';
import 'package:geosector_app/core/services/stripe_tap_to_pay_service.dart';
import 'package:geosector_app/core/services/device_info_service.dart';
import 'package:geosector_app/core/services/stripe_connect_service.dart';
import 'package:geosector_app/core/services/api_service.dart';
import 'package:geosector_app/core/utils/api_exception.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/presentation/widgets/custom_text_field.dart';
import 'package:geosector_app/presentation/widgets/form_section.dart';
import 'package:geosector_app/presentation/widgets/result_dialog.dart';
import 'package:geosector_app/presentation/widgets/loading_spin_overlay.dart';
import 'package:geosector_app/presentation/widgets/payment_method_selection_dialog.dart';
class PassageFormDialog extends StatefulWidget {
final PassageModel? passage;
@@ -75,6 +79,10 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
// Variable pour Tap to Pay
String? _stripePaymentIntentId;
// État d'expansion des sections
bool _isAddressSectionExpanded = true;
bool _isDateTimeSectionExpanded = false; // Toujours fermée par défaut
// Boîte Hive pour mémoriser la dernière adresse
late Box _settingsBox;
@@ -183,6 +191,9 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
_fkHabitat = passage?.fkHabitat ?? 1;
_fkTypeReglement = passage?.fkTypeReglement ?? 4;
// Section Adresse : ouverte si nouveau passage, fermée si modification
_isAddressSectionExpanded = passage == null;
debugPrint('Initialisation des controllers...');
// S'assurer que toutes les valeurs null deviennent des chaînes vides
@@ -308,14 +319,12 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
_fkTypeReglement = 4; // Non renseigné
}
// Si c'est un nouveau passage et qu'on change de type, réinitialiser la date à maintenant
if (widget.passage == null) {
_passedAt = DateTime.now();
_dateController.text =
'${_passedAt.day.toString().padLeft(2, '0')}/${_passedAt.month.toString().padLeft(2, '0')}/${_passedAt.year}';
_timeController.text =
'${_passedAt.hour.toString().padLeft(2, '0')}:${_passedAt.minute.toString().padLeft(2, '0')}';
}
// Toujours mettre à jour la date et l'heure à maintenant lors de la sélection du type
_passedAt = DateTime.now();
_dateController.text =
'${_passedAt.day.toString().padLeft(2, '0')}/${_passedAt.month.toString().padLeft(2, '0')}/${_passedAt.year}';
_timeController.text =
'${_passedAt.hour.toString().padLeft(2, '0')}:${_passedAt.minute.toString().padLeft(2, '0')}';
});
}
@@ -334,10 +343,18 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
}
Future<void> _savePassage() async {
if (_isSubmitting) return;
setState(() {
_isSubmitting = true;
});
// Afficher l'overlay de chargement
final overlay = LoadingSpinOverlayUtils.show(
context: context,
message: 'Enregistrement en cours...',
);
try {
final currentUser = widget.userRepository.getCurrentUser();
if (currentUser == null) {
@@ -352,7 +369,7 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
// Déterminer les valeurs de montant et type de règlement selon le type de passage
final String finalMontant =
(_selectedPassageType == 1 || _selectedPassageType == 5)
? _montantController.text.trim()
? _montantController.text.trim().replaceAll(',', '.')
: '0';
// Déterminer le type de règlement final selon le type de passage
final int finalTypeReglement;
@@ -437,8 +454,8 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
// Sauvegarder le passage d'abord
PassageModel? savedPassage;
if (widget.passage == null) {
// Création d'un nouveau passage
if (widget.passage == null || widget.passage!.id == 0) {
// Création d'un nouveau passage (passage null OU id=0)
savedPassage = await widget.passageRepository.createPassageWithReturn(passageData);
} else {
// Mise à jour d'un passage existant
@@ -449,107 +466,114 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
}
if (savedPassage == null) {
throw Exception(widget.passage == null
throw Exception(widget.passage == null || widget.passage!.id == 0
? "Échec de la création du passage"
: "Échec de la mise à jour du passage");
}
// Garantir le type non-nullable après la vérification
final confirmedPassage = savedPassage;
// Mémoriser l'adresse pour la prochaine création de passage
await _saveLastPassageAddress();
// Propager la résidence aux autres passages de l'immeuble si nécessaire
if (_fkHabitat == 2 && _residenceController.text.trim().isNotEmpty) {
await _propagateResidenceToBuilding(confirmedPassage);
}
// Vérifier si paiement CB nécessaire APRÈS la sauvegarde
if (finalTypeReglement == 3 &&
(_selectedPassageType == 1 || _selectedPassageType == 5)) {
final montant = double.tryParse(finalMontant.replaceAll(',', '.')) ?? 0;
if (montant > 0 && mounted) {
// Vérifier si le device supporte Tap to Pay
if (DeviceInfoService.instance.canUseTapToPay()) {
// Lancer le flow Tap to Pay avec l'ID du passage sauvegardé
final paymentSuccess = await _attemptTapToPayWithPassage(savedPassage, montant);
// Vérifier si l'amicale a Stripe activé
final amicale = CurrentAmicaleService.instance.currentAmicale;
final stripeEnabled = amicale?.chkStripe == true &&
amicale?.stripeId != null &&
amicale!.stripeId.isNotEmpty;
if (!paymentSuccess) {
// Si le paiement échoue, on pourrait marquer le passage comme "À finaliser"
// ou le supprimer selon la logique métier
debugPrint('⚠️ Paiement échoué pour le passage ${savedPassage.id}');
// Optionnel : mettre à jour le passage en type "À finaliser" (7)
if (stripeEnabled) {
// Masquer le loading avant d'afficher le dialog de sélection
LoadingSpinOverlayUtils.hideSpecific(overlay);
// Afficher le dialog de sélection de méthode de paiement
if (mounted) {
final habitantName = _nameController.text.trim();
await PaymentMethodSelectionDialog.show(
context: context,
passage: confirmedPassage,
amount: montant,
habitantName: habitantName.isNotEmpty ? habitantName : 'Client',
stripeConnectService: StripeConnectService(
apiService: ApiService.instance,
),
passageRepository: widget.passageRepository,
onTapToPaySelected: () async {
// Lancer le flow Tap to Pay
final paymentSuccess = await _attemptTapToPayWithPassage(confirmedPassage, montant);
if (!paymentSuccess) {
debugPrint('⚠️ Paiement Tap to Pay échoué pour le passage ${confirmedPassage.id}');
}
},
);
// Fermer le formulaire après le choix de paiement
if (mounted) {
Navigator.of(context, rootNavigator: false).pop();
widget.onSuccess?.call();
}
}
} else {
// Le device ne supporte pas Tap to Pay (Web ou device non compatible)
// Stripe non activé pour cette amicale
LoadingSpinOverlayUtils.hideSpecific(overlay);
if (mounted) {
// Déterminer le message d'avertissement approprié
String warningMessage;
if (kIsWeb) {
warningMessage = "Passage enregistré avec succès.\n\n Note : Le paiement sans contact n'est pas disponible sur navigateur web. Utilisez l'application mobile native pour cette fonctionnalité.";
} else {
// Vérifier pourquoi le device n'est pas compatible
final deviceInfo = DeviceInfoService.instance.getStoredDeviceInfo();
final nfcCapable = deviceInfo['device_nfc_capable'] == true;
final stripeCertified = deviceInfo['device_stripe_certified'] == true;
final batteryLevel = deviceInfo['battery_level'] as int?;
final platform = deviceInfo['platform'];
await ResultDialog.show(
context: context,
success: true,
message: "Passage enregistré avec succès.\n\n Note : Les paiements par carte ne sont pas activés pour votre amicale. Contactez l'administrateur pour activer Stripe.",
);
if (!nfcCapable) {
warningMessage = "Passage enregistré avec succès.\n\n Note : Votre appareil n'a pas de NFC activé ou disponible pour les paiements sans contact.";
} else if (!stripeCertified) {
if (platform == 'iOS') {
warningMessage = "Passage enregistré avec succès.\n\n Note : Votre iPhone n'est pas compatible. Tap to Pay nécessite un iPhone XS ou plus récent avec iOS 16.4+.";
} else {
warningMessage = "Passage enregistré avec succès.\n\n Note : Votre appareil Android n'est pas certifié par Stripe pour les paiements sans contact en France.";
}
} else if (batteryLevel != null && batteryLevel < 10) {
warningMessage = "Passage enregistré avec succès.\n\n Note : Batterie trop faible ($batteryLevel%). Minimum 10% requis pour les paiements sans contact.";
} else {
warningMessage = "Passage enregistré avec succès.\n\n Note : Votre appareil ne peut pas utiliser le paiement sans contact actuellement.";
}
if (mounted) {
Navigator.of(context, rootNavigator: false).pop();
widget.onSuccess?.call();
}
// Fermer le dialog et afficher le message de succès avec avertissement
Future.delayed(const Duration(milliseconds: 200), () {
if (mounted) {
Navigator.of(context, rootNavigator: false).pop();
widget.onSuccess?.call();
Future.delayed(const Duration(milliseconds: 100), () {
if (mounted) {
// Afficher un SnackBar orange pour l'avertissement
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(warningMessage),
backgroundColor: Colors.orange,
duration: const Duration(seconds: 5),
),
);
}
});
}
});
}
}
}
} else {
// Pas de paiement CB, fermer le dialog avec succès
// Pas de paiement CB, afficher le succès
LoadingSpinOverlayUtils.hideSpecific(overlay);
if (mounted) {
Future.delayed(const Duration(milliseconds: 200), () {
if (mounted) {
Navigator.of(context, rootNavigator: false).pop();
widget.onSuccess?.call();
Future.delayed(const Duration(milliseconds: 100), () {
if (mounted) {
ApiException.showSuccess(
context,
widget.passage == null
? "Nouveau passage créé avec succès"
: "Passage modifié avec succès",
);
}
});
}
});
await ResultDialog.show(
context: context,
success: true,
message: widget.passage == null || widget.passage!.id == 0
? "Nouveau passage créé avec succès"
: "Passage modifié avec succès",
);
if (mounted) {
Navigator.of(context, rootNavigator: false).pop();
widget.onSuccess?.call();
}
}
}
} catch (e) {
// Masquer le loading
LoadingSpinOverlayUtils.hideSpecific(overlay);
// Afficher l'erreur
if (mounted) {
ApiException.showError(context, e);
await ResultDialog.show(
context: context,
success: false,
message: ApiException.fromError(e).message,
);
}
} finally {
if (mounted) {
@@ -578,6 +602,45 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
}
}
/// Propager la résidence aux autres passages de l'immeuble (fkType=2, même adresse, résidence vide)
Future<void> _propagateResidenceToBuilding(PassageModel savedPassage) async {
try {
final passagesBox = Hive.box<PassageModel>(AppKeys.passagesBoxName);
final residence = _residenceController.text.trim();
// Clé d'adresse du passage sauvegardé
final addressKey = '${savedPassage.numero}|${savedPassage.rueBis}|${savedPassage.rue}|${savedPassage.ville}';
int updatedCount = 0;
// Parcourir tous les passages
for (int i = 0; i < passagesBox.length; i++) {
final passage = passagesBox.getAt(i);
if (passage != null) {
// Vérifier les critères
final passageAddressKey = '${passage.numero}|${passage.rueBis}|${passage.rue}|${passage.ville}';
if (passage.id != savedPassage.id && // Pas le passage actuel
passage.fkHabitat == 2 && // Appartement
passageAddressKey == addressKey && // Même adresse
passage.residence.trim().isEmpty) { // Résidence vide
// Mettre à jour la résidence dans Hive
final updatedPassage = passage.copyWith(residence: residence);
await passagesBox.put(passage.key, updatedPassage);
updatedCount++;
}
}
}
if (updatedCount > 0) {
debugPrint('✅ Résidence propagée à $updatedCount passage(s) de l\'immeuble');
}
} catch (e) {
debugPrint('⚠️ Erreur lors de la propagation de la résidence: $e');
}
}
Widget _buildPassageTypeSelection() {
final theme = Theme.of(context);
@@ -643,7 +706,7 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Color(typeData['couleur2'] as int? ?? 0xFF000000)
.withValues(alpha: 0.15),
.withOpacity(0.15),
border: Border.all(
color: Color(typeData['couleur2'] as int? ?? 0xFF000000),
width: isSelected ? 3 : 2,
@@ -654,7 +717,7 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
BoxShadow(
color: Color(typeData['couleur2'] as int? ??
0xFF000000)
.withValues(alpha: 0.2),
.withOpacity(0.2),
blurRadius: 8,
offset: const Offset(0, 2),
)
@@ -709,122 +772,222 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Section Date et Heure
FormSection(
title: 'Date et Heure de passage',
icon: Icons.schedule,
children: [
// Layout responsive : 1 ligne desktop, 2 lignes mobile
_isMobile(context)
? Column(
children: [
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(height: 16),
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,
),
],
// Section Date et Heure (rétractable)
Theme(
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
key: ValueKey(_isDateTimeSectionExpanded),
initiallyExpanded: _isDateTimeSectionExpanded,
onExpansionChanged: (expanded) {
setState(() {
_isDateTimeSectionExpanded = expanded;
});
},
leading: Icon(
Icons.schedule,
color: Theme.of(context).colorScheme.primary,
),
title: _isDateTimeSectionExpanded
? Text(
'Date et Heure de passage',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
)
: Row(
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
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,
Text(
'Date et Heure de passage',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
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: 4),
Text(
'${_dateController.text} à ${_timeController.text}',
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
),
],
),
],
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
child: Column(
children: [
// Layout responsive : 1 ligne desktop, 2 lignes mobile
_isMobile(context)
? Column(
children: [
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(height: 16),
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,
),
],
)
: 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,
validator: _validateNumero,
// Section Adresse (rétractable)
Theme(
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
key: ValueKey(_isAddressSectionExpanded),
initiallyExpanded: _isAddressSectionExpanded,
onExpansionChanged: (expanded) {
setState(() {
_isAddressSectionExpanded = expanded;
});
},
leading: Icon(
Icons.location_on,
color: Theme.of(context).colorScheme.primary,
),
title: _isAddressSectionExpanded
? Text(
'Adresse',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Adresse',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(height: 4),
Text(
'${_numeroController.text} ${_rueBisController.text} ${_rueController.text}, ${_villeController.text}'.trim().replaceAll(RegExp(r'\s+'), ' '),
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
child: Column(
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,
validator: _validateNumero,
),
),
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,
validator: _validateRue,
),
const SizedBox(height: 16),
CustomTextField(
controller: _villeController,
label: "Ville",
isRequired: true,
showLabel: false,
readOnly: widget.readOnly,
validator: _validateVille,
),
],
),
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,
validator: _validateRue,
),
const SizedBox(height: 16),
CustomTextField(
controller: _villeController,
label: "Ville",
isRequired: true,
showLabel: false,
readOnly: widget.readOnly,
validator: _validateVille,
),
],
),
],
),
),
const SizedBox(height: 24),
@@ -1014,7 +1177,7 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
const SizedBox(width: 12),
Expanded(
child: DropdownButtonFormField<int>(
initialValue: _fkTypeReglement,
value: _fkTypeReglement,
decoration: const InputDecoration(
labelText: "Type de règlement *",
border: OutlineInputBorder(),
@@ -1149,7 +1312,7 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
? Color(AppKeys.typesPassages[_selectedPassageType]!['couleur2']
as int? ??
0xFF000000)
.withValues(alpha: 0.1)
.withOpacity(0.1)
: null,
borderRadius: BorderRadius.circular(8),
),
@@ -1319,7 +1482,7 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
: theme.colorScheme.primary;
return AppBar(
backgroundColor: typeColor.withValues(alpha: 0.1),
backgroundColor: typeColor.withOpacity(0.1),
elevation: 0,
leading: IconButton(
icon: Icon(Icons.close, color: typeColor),
@@ -1413,20 +1576,16 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
// Si paiement réussi, afficher le message de succès et fermer
if (result == true && mounted) {
Future.delayed(const Duration(milliseconds: 200), () {
if (mounted) {
Navigator.of(context, rootNavigator: false).pop();
widget.onSuccess?.call();
Future.delayed(const Duration(milliseconds: 100), () {
if (mounted) {
ApiException.showSuccess(
context,
"Paiement effectué avec succès",
);
}
});
}
});
await ResultDialog.show(
context: context,
success: true,
message: "Paiement effectué avec succès",
);
if (mounted) {
Navigator.of(context, rootNavigator: false).pop();
widget.onSuccess?.call();
}
return true;
}
@@ -1434,7 +1593,11 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
} catch (e) {
debugPrint('Erreur Tap to Pay: $e');
if (mounted) {
ApiException.showError(context, e);
await ResultDialog.show(
context: context,
success: false,
message: ApiException.fromError(e).message,
);
}
return false;
}
@@ -1453,35 +1616,28 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
return Scaffold(
appBar: _buildMobileAppBar(),
body: SafeArea(
child: Padding(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _buildContent(),
),
// Contenu du formulaire
if (!_showForm) ...[
_buildPassageTypeSelection(),
] else ...[
_buildPassageForm(),
],
// Boutons en bas du scroll
if (_showForm && _selectedPassageType != null) ...[
const SizedBox(height: 32),
_buildFooterButtons(),
const SizedBox(height: 16), // Padding supplémentaire pour le confort
],
],
),
),
),
bottomNavigationBar: _showForm && _selectedPassageType != null
? SafeArea(
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 4,
offset: const Offset(0, -2),
),
],
),
child: _buildFooterButtons(),
),
)
: null,
);
} else {
// Mode Dialog pour desktop/tablette

View File

@@ -77,6 +77,29 @@ class PassageMapDialog extends StatelessWidget {
// Ville
if (passage.ville.isNotEmpty)
_buildInfoRow(Icons.location_city, 'Ville', passage.ville),
// Type d'habitat
if (passage.fkHabitat == 1)
_buildInfoRow(Icons.home, 'Habitat', 'Maison')
else if (passage.fkHabitat == 2) ...[
_buildInfoRow(
Icons.home,
'Habitat',
'Appartement${passage.niveau.isNotEmpty || passage.appt.isNotEmpty ? ' (' : ''}${passage.niveau.isNotEmpty ? 'Niveau ${passage.niveau}' : ''}${passage.niveau.isNotEmpty && passage.appt.isNotEmpty ? ', ' : ''}${passage.appt.isNotEmpty ? 'Appt ${passage.appt}' : ''}${passage.niveau.isNotEmpty || passage.appt.isNotEmpty ? ')' : ''}',
),
],
// Résidence
if (passage.residence.isNotEmpty)
_buildInfoRow(Icons.apartment, 'Résidence', passage.residence),
// Nom
if (passage.name.isNotEmpty)
_buildInfoRow(Icons.person, 'Nom', passage.name),
// Remarque
if (passage.remarque.isNotEmpty)
_buildInfoRow(Icons.note, 'Remarque', passage.remarque),
],
),
actions: [

View File

@@ -218,21 +218,21 @@ class _PassageFormState extends State<PassageForm> {
decoration: InputDecoration(
hintText: '0.00 €',
hintStyle: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 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.onSurface.withValues(alpha: 0.1),
color: theme.colorScheme.onSurface.withOpacity(0.1),
width: 1,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: theme.colorScheme.onSurface.withValues(alpha: 0.1),
color: theme.colorScheme.onSurface.withOpacity(0.1),
width: 1,
),
),
@@ -360,10 +360,10 @@ class _PassageFormState extends State<PassageForm> {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 5),
decoration: BoxDecoration(
color: const Color(0xFFF4F5F6).withValues(alpha: 0.85),
color: const Color(0xFFF4F5F6).withOpacity(0.85),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: const Color(0xFF20335E).withValues(alpha: 0.1),
color: const Color(0xFF20335E).withOpacity(0.1),
width: 1,
),
),

View File

@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:intl/intl.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/data/models/payment_link_result.dart';
import 'package:geosector_app/presentation/widgets/qr_code_payment_dialog.dart';
/// Un widget réutilisable pour afficher une liste de passages (affichage pur)
class PassagesListWidget extends StatelessWidget {
@@ -35,6 +37,9 @@ class PassagesListWidget extends StatelessWidget {
/// Callback appelé lorsque le bouton d'ajout est cliqué
final VoidCallback? onAddPassage;
/// Type de passage filtré (optionnel, pour affichage dans le titre)
final String? filteredPassageType;
const PassagesListWidget({
super.key,
required this.passages,
@@ -47,6 +52,7 @@ class PassagesListWidget extends StatelessWidget {
this.onDetailsView,
this.onPassageDelete,
this.onAddPassage,
this.filteredPassageType,
});
@override
@@ -81,7 +87,7 @@ class PassagesListWidget extends StatelessWidget {
padding: const EdgeInsets.all(16.0),
decoration: BoxDecoration(
color: Color.alphaBlend(
theme.colorScheme.primary.withValues(alpha: 0.1),
theme.colorScheme.primary.withOpacity(0.1),
theme.colorScheme.surface,
),
),
@@ -91,13 +97,13 @@ class PassagesListWidget extends StatelessWidget {
Row(
children: [
Icon(
Icons.list_alt,
Icons.route,
size: 20,
color: theme.colorScheme.primary,
),
const SizedBox(width: 8),
Text(
'${passages.length} passage${passages.length > 1 ? 's' : ''}',
_buildPassageCountText(),
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
@@ -200,23 +206,37 @@ class PassagesListWidget extends StatelessWidget {
'icon_data': Icons.help_outline,
};
// Récupérer nbPassages pour le type 2
final nbPassages = passage['nb_passages'] as int? ?? passage['nbPassages'] as int? ?? 0;
// Récupérer la couleur de fond selon le type et nbPassages
Color backgroundColor;
Color iconColor;
bool useOutlinedIcon = false;
if (typeId == 2) {
// Type 2 (À finaliser) : adapter la couleur selon nbPassages
final nbPassages = passage['nbPassages'] as int? ?? passage['nb_passages'] as int? ?? 0;
if (nbPassages == 0) {
backgroundColor = Color(typeInfo['couleur1'] as int? ?? 0xFFFFFFFF);
iconColor = Color(typeInfo['couleur2'] as int? ?? 0xFFFFB978);
useOutlinedIcon = true; // Utiliser l'icône outlined pour la visibilité
} else if (nbPassages == 1) {
backgroundColor = Color(typeInfo['couleur2'] as int? ?? 0xFFF7A278);
backgroundColor = Color(typeInfo['couleur2'] as int? ?? 0xFFFFB978);
iconColor = backgroundColor;
useOutlinedIcon = false;
} else {
// nbPassages > 1
backgroundColor = Color(typeInfo['couleur3'] as int? ?? 0xFFE65100);
backgroundColor = Color(typeInfo['couleur3'] as int? ?? 0xFFE66F00);
iconColor = backgroundColor;
useOutlinedIcon = false;
}
} else {
// Autres types : utiliser couleur2 par défaut
backgroundColor = Color(typeInfo['couleur2'] as int? ?? 0xFF9E9E9E);
iconColor = backgroundColor;
useOutlinedIcon = false;
}
final typeIcon = typeInfo['icon_data'] as IconData? ?? Icons.help_outline;
// Informations du passage
@@ -291,13 +311,13 @@ class PassagesListWidget extends StatelessWidget {
height: 50,
width: 50,
decoration: BoxDecoration(
color: backgroundColor.withValues(alpha: 0.5),
color: backgroundColor.withOpacity(0.5),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
typeIcon,
useOutlinedIcon ? Icons.refresh_outlined : typeIcon,
size: 28,
color: backgroundColor.withValues(alpha: 1.0),
color: iconColor.withOpacity(1.0),
),
),
const SizedBox(width: 12),
@@ -308,23 +328,47 @@ class PassagesListWidget extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Ligne 1 : Date (si définie) + Actions à droite
// Ligne 1 : Date (si définie) + Nom + Actions à droite
Row(
children: [
// Date (si définie)
if (formattedDate != null)
Expanded(
child: Text(
formattedDate,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
)
else
const Spacer(),
// Date et nom
Expanded(
child: Row(
children: [
// Date (si définie)
if (formattedDate != null)
Text(
formattedDate,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
),
// Nom du passage (si défini)
if (passage['name'] != null &&
(passage['name'] as String).trim().isNotEmpty) ...[
if (formattedDate != null)
Text(
' - ',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
Flexible(
child: Text(
passage['name'] as String,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
),
],
],
),
),
// Actions
if (showActions) ...[
@@ -343,7 +387,7 @@ class PassagesListWidget extends StatelessWidget {
),
const SizedBox(height: 4),
// Ligne 2 : Adresse courte + Badge montant à droite
// Ligne 2 : Adresse courte + Icônes + Badge montant à droite
Row(
children: [
// Adresse courte
@@ -359,6 +403,76 @@ class PassagesListWidget extends StatelessWidget {
),
const SizedBox(width: 8),
// Icône remarque (si présente)
if (passage['remarque'] != null &&
(passage['remarque'] as String).trim().isNotEmpty)
Padding(
padding: const EdgeInsets.only(right: 8),
child: Tooltip(
message: passage['remarque'],
preferBelow: false,
child: Icon(
Icons.comment_outlined,
size: 16,
color: theme.colorScheme.primary.withOpacity(0.7),
),
),
),
// Icône email (si présent)
if (passage['email'] != null &&
(passage['email'] as String).trim().isNotEmpty)
Padding(
padding: const EdgeInsets.only(right: 8),
child: InkWell(
onTap: () {
final email = passage['email'] as String;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Email: $email'),
duration: const Duration(seconds: 3),
action: SnackBarAction(
label: 'OK',
onPressed: () {},
),
),
);
},
borderRadius: BorderRadius.circular(4),
child: Tooltip(
message: passage['email'],
preferBelow: false,
child: Padding(
padding: const EdgeInsets.all(4),
child: Icon(
Icons.alternate_email,
size: 16,
color: (passage['emailErreur'] != null &&
(passage['emailErreur'] as String).trim().isNotEmpty)
? Colors.red.withOpacity(0.7)
: Colors.blue.withOpacity(0.7),
),
),
),
),
),
// Icône reçu (si présent)
if (passage['nomRecu'] != null &&
(passage['nomRecu'] as String).trim().isNotEmpty)
Padding(
padding: const EdgeInsets.only(right: 8),
child: Tooltip(
message: 'Reçu disponible',
preferBelow: false,
child: Icon(
Icons.receipt_outlined,
size: 16,
color: theme.colorScheme.secondary.withOpacity(0.7),
),
),
),
// Badge montant (si > 0 et type 1 ou 5)
if (isPaid && (typeId == 1 || typeId == 5))
Builder(
@@ -368,17 +482,18 @@ class PassagesListWidget extends StatelessWidget {
passage['payment'] as int? ??
4; // 4 = Non renseigné par défaut
// Récupérer l'icône du type de règlement
// Récupérer l'icône ET la couleur du type de règlement
final reglementInfo = AppKeys.typesReglements[typeReglement];
final reglementIcon = reglementInfo?['icon_data'] as IconData? ?? Icons.help_outline;
final reglementColor = Color(reglementInfo?['couleur'] as int? ?? 0xFF9E9E9E); // Gris par défaut
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.green.withValues(alpha: 0.15),
color: reglementColor.withOpacity(0.15),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Colors.green.withValues(alpha: 0.4),
color: reglementColor.withOpacity(0.4),
),
),
child: Row(
@@ -387,13 +502,13 @@ class PassagesListWidget extends StatelessWidget {
Icon(
reglementIcon,
size: 12,
color: Colors.green.shade700,
color: reglementColor,
),
const SizedBox(width: 4),
Text(
formattedAmount,
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.green.shade700,
color: reglementColor,
fontWeight: FontWeight.bold,
fontSize: 11,
),
@@ -403,6 +518,29 @@ class PassagesListWidget extends StatelessWidget {
);
},
),
// Icône QR Code (si Payment Link généré)
if (passage['stripe_payment_link_id'] != null &&
(passage['stripe_payment_link_id'] as String).trim().isNotEmpty)
Padding(
padding: const EdgeInsets.only(left: 4),
child: InkWell(
onTap: () => _showQRCodeDialog(context, passage),
borderRadius: BorderRadius.circular(4),
child: Tooltip(
message: 'Afficher le QR Code',
preferBelow: false,
child: Padding(
padding: const EdgeInsets.all(4),
child: Icon(
Icons.qr_code_2,
size: 16,
color: Colors.blue.withOpacity(0.7),
),
),
),
),
),
],
),
],
@@ -414,4 +552,71 @@ class PassagesListWidget extends StatelessWidget {
),
);
}
/// Construit le texte du nombre de passages avec le type si filtré
String _buildPassageCountText() {
final count = passages.length;
final baseText = '$count passage${count > 1 ? 's' : ''}';
// Si un type de passage est filtré et différent de "Tous les types"
if (filteredPassageType != null && filteredPassageType!.isNotEmpty) {
final typeLowerCase = filteredPassageType!.toLowerCase();
// Gérer le pluriel selon le type
String typeWithPlural;
if (count > 1) {
// Gestion des pluriels spécifiques
if (typeLowerCase == 'à finaliser') {
typeWithPlural = 'à finaliser'; // Invariable
} else if (typeLowerCase.endsWith('é')) {
typeWithPlural = '${typeLowerCase}s'; // effectué → effectués, refusé → refusés
} else if (typeLowerCase == 'maison vide') {
typeWithPlural = 'maisons vides';
} else {
typeWithPlural = '${typeLowerCase}s'; // don → dons, lot → lots
}
} else {
typeWithPlural = typeLowerCase;
}
return '$count passage${count > 1 ? 's' : ''} $typeWithPlural';
}
return baseText;
}
/// Afficher le QR Code pour un passage avec Payment Link
void _showQRCodeDialog(BuildContext context, Map<String, dynamic> passage) {
final paymentLinkUrl = passage['stripe_payment_link_url'] as String?;
final paymentLinkId = passage['stripe_payment_link_id'] as String?;
if (paymentLinkUrl == null || paymentLinkUrl.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('URL du QR Code non disponible'),
duration: Duration(seconds: 2),
),
);
return;
}
// Récupérer le montant du passage
final montantStr = passage['montant'] as String? ?? '0';
final montant = double.tryParse(montantStr.replaceAll(',', '.')) ?? 0;
final amountInCents = (montant * 100).round();
// Créer un PaymentLinkResult avec les données du passage
final paymentLink = PaymentLinkResult(
paymentLinkId: paymentLinkId ?? '',
url: paymentLinkUrl,
amount: amountInCents,
passageId: passage['id'] as int?,
);
// Afficher le QR Code
QRCodePaymentDialog.show(
context: context,
paymentLink: paymentLink,
);
}
}

View File

@@ -0,0 +1,353 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/core/data/models/passage_model.dart';
import 'package:geosector_app/core/services/stripe_connect_service.dart';
import 'package:geosector_app/core/services/device_info_service.dart';
import 'package:geosector_app/core/utils/api_exception.dart';
import 'package:geosector_app/core/repositories/passage_repository.dart';
import 'package:geosector_app/presentation/widgets/qr_code_payment_dialog.dart';
/// Dialog de sélection de la méthode de paiement CB
/// Affiche les options QR Code et/ou Tap to Pay selon la compatibilité
class PaymentMethodSelectionDialog extends StatelessWidget {
final PassageModel passage;
final double amount;
final String habitantName;
final StripeConnectService stripeConnectService;
final PassageRepository? passageRepository;
final VoidCallback? onTapToPaySelected;
const PaymentMethodSelectionDialog({
super.key,
required this.passage,
required this.amount,
required this.habitantName,
required this.stripeConnectService,
this.passageRepository,
this.onTapToPaySelected,
});
@override
Widget build(BuildContext context) {
final canUseTapToPay = DeviceInfoService.instance.canUseTapToPay();
final amountEuros = amount.toStringAsFixed(2);
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Container(
constraints: const BoxConstraints(maxWidth: 450),
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// En-tête
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Règlement CB',
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
),
],
),
const SizedBox(height: 24),
// Informations du paiement
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.blue.shade200),
),
child: Column(
children: [
Row(
children: [
const Icon(Icons.person, color: Colors.blue),
const SizedBox(width: 12),
Expanded(
child: Text(
habitantName,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
),
],
),
const Divider(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.euro,
color: Colors.blue,
size: 28,
),
const SizedBox(width: 8),
Text(
amountEuros,
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: Colors.blue,
),
),
],
),
],
),
),
const SizedBox(height: 32),
// Titre section méthodes
const Text(
'Sélectionnez une méthode de paiement :',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 16),
// Bouton QR Code
_buildPaymentButton(
context: context,
icon: Icons.qr_code_2,
label: 'Paiement par QR Code',
description: 'Le client scanne le code avec son téléphone',
onPressed: () => _handleQRCodePayment(context),
color: Colors.blue,
),
if (canUseTapToPay) ...[
const SizedBox(height: 12),
// Bouton Tap to Pay
_buildPaymentButton(
context: context,
icon: Icons.contactless,
label: 'Tap to Pay',
description: 'Paiement sans contact sur cet appareil',
onPressed: () {
Navigator.of(context).pop();
onTapToPaySelected?.call();
},
color: Colors.green,
),
],
const SizedBox(height: 24),
// Logo Stripe
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.security,
color: Colors.grey.shade600,
size: 16,
),
const SizedBox(width: 8),
Text(
'Paiements sécurisés par Stripe',
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
fontStyle: FontStyle.italic,
),
),
],
),
],
),
),
);
}
Widget _buildPaymentButton({
required BuildContext context,
required IconData icon,
required String label,
required String description,
required VoidCallback onPressed,
required Color color,
}) {
return InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(12),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
border: Border.all(color: color.withOpacity(0.3), width: 2),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withOpacity(0.2),
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, color: color, size: 32),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: color,
),
),
const SizedBox(height: 4),
Text(
description,
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade700,
),
),
],
),
),
Icon(Icons.arrow_forward_ios, color: color, size: 20),
],
),
),
);
}
/// Gérer le paiement par QR Code
Future<void> _handleQRCodePayment(BuildContext context) async {
// Sauvegarder le navigator avant de fermer les dialogs
final navigator = Navigator.of(context);
try {
// Afficher un loader
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const Center(
child: CircularProgressIndicator(),
),
);
// Créer le Payment Link
final amountInCents = (amount * 100).round();
debugPrint('🔵 Création Payment Link : ${amountInCents} cents, passage ${passage.id}');
final paymentLink = await stripeConnectService.createPaymentLink(
amountInCents: amountInCents,
passageId: passage.id,
description: 'Calendrier pompiers - ${habitantName}',
metadata: {
'passage_id': passage.id.toString(),
'habitant_name': habitantName,
'adresse': '${passage.numero} ${passage.rue}, ${passage.ville}',
},
);
debugPrint('🔵 Payment Link reçu : ${paymentLink != null ? "OK" : "NULL"}');
if (paymentLink != null) {
debugPrint(' URL: ${paymentLink.url}');
debugPrint(' ID: ${paymentLink.paymentLinkId}');
}
// Fermer le loader
navigator.pop();
debugPrint('🔵 Loader fermé');
if (paymentLink == null) {
throw Exception('Impossible de créer le lien de paiement');
}
// Sauvegarder l'URL du Payment Link dans le passage
if (passageRepository != null) {
try {
debugPrint('🔵 Sauvegarde de l\'URL du Payment Link dans le passage...');
final updatedPassage = passage.copyWith(
stripePaymentLinkUrl: paymentLink.url,
);
await passageRepository!.updatePassage(updatedPassage);
debugPrint('✅ URL du Payment Link sauvegardée');
} catch (e) {
debugPrint('⚠️ Erreur lors de la sauvegarde de l\'URL: $e');
// On continue quand même, ce n'est pas bloquant
}
}
// Fermer le dialog de sélection
navigator.pop();
debugPrint('🔵 Dialog de sélection fermé');
// Attendre un frame pour que les dialogs soient bien fermés
await Future.delayed(const Duration(milliseconds: 100));
// Afficher le QR Code avec le navigator root
debugPrint('🔵 Ouverture dialog QR Code...');
await showDialog(
context: navigator.context,
barrierDismissible: true,
builder: (context) => QRCodePaymentDialog(
paymentLink: paymentLink,
),
);
debugPrint('🔵 Dialog QR Code affiché');
} catch (e, stack) {
debugPrint('❌ Erreur dans _handleQRCodePayment: $e');
debugPrint(' Stack: $stack');
// Fermer le loader si encore ouvert
try {
navigator.pop();
} catch (_) {}
// Afficher l'erreur
if (context.mounted) {
ApiException.showError(context, e);
}
}
}
/// Afficher le dialog de sélection de méthode de paiement
static Future<void> show({
required BuildContext context,
required PassageModel passage,
required double amount,
required String habitantName,
required StripeConnectService stripeConnectService,
PassageRepository? passageRepository,
VoidCallback? onTapToPaySelected,
}) {
return showDialog(
context: context,
barrierDismissible: true,
builder: (context) => PaymentMethodSelectionDialog(
passage: passage,
amount: amount,
habitantName: habitantName,
stripeConnectService: stripeConnectService,
passageRepository: passageRepository,
onTapToPaySelected: onTapToPaySelected,
),
);
}
}

View File

@@ -0,0 +1,182 @@
import 'package:flutter/material.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:geosector_app/core/data/models/payment_link_result.dart';
/// Dialog qui affiche un QR code pour le paiement Stripe
class QRCodePaymentDialog extends StatelessWidget {
final PaymentLinkResult paymentLink;
final VoidCallback? onClose;
const QRCodePaymentDialog({
super.key,
required this.paymentLink,
this.onClose,
});
@override
Widget build(BuildContext context) {
final amountEuros = (paymentLink.amount / 100).toStringAsFixed(2);
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Container(
constraints: const BoxConstraints(maxWidth: 400),
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Titre
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Paiement par QR Code',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () {
Navigator.of(context).pop();
onClose?.call();
},
),
],
),
const SizedBox(height: 24),
// Montant
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.euro,
color: Colors.blue,
size: 32,
),
const SizedBox(width: 8),
Text(
amountEuros,
style: const TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
color: Colors.blue,
),
),
],
),
),
const SizedBox(height: 24),
// QR Code
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: Colors.grey.shade300, width: 2),
borderRadius: BorderRadius.circular(12),
),
child: QrImageView(
data: paymentLink.url,
version: QrVersions.auto,
size: 250,
backgroundColor: Colors.white,
errorCorrectionLevel: QrErrorCorrectLevel.H,
),
),
const SizedBox(height: 24),
// Instructions
const Text(
'Scannez ce QR code avec votre téléphone',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
Text(
'Vous serez redirigé vers une page de paiement sécurisée Stripe',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade600,
),
),
const SizedBox(height: 24),
// Logo Stripe
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.security,
color: Colors.green.shade600,
size: 20,
),
const SizedBox(width: 8),
Text(
'Paiement sécurisé par Stripe',
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
fontStyle: FontStyle.italic,
),
),
],
),
const SizedBox(height: 16),
// Bouton Fermer
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
onClose?.call();
},
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: const Text(
'Fermer',
style: TextStyle(fontSize: 16),
),
),
),
],
),
),
);
}
/// Afficher le dialog de paiement par QR code
static Future<void> show({
required BuildContext context,
required PaymentLinkResult paymentLink,
VoidCallback? onClose,
}) {
return showDialog(
context: context,
barrierDismissible: true,
builder: (context) => QRCodePaymentDialog(
paymentLink: paymentLink,
onClose: onClose,
),
);
}
}

View File

@@ -165,7 +165,7 @@ class _ResponsiveNavigationState extends State<ResponsiveNavigation> {
data: theme.copyWith(
colorScheme: theme.colorScheme.copyWith(
onSecondaryContainer: selectedColor, // Couleur de l'icône sélectionnée
secondaryContainer: selectedColor.withValues(alpha: 0.15), // Couleur de fond de l'indicateur
secondaryContainer: selectedColor.withOpacity(0.15), // Couleur de fond de l'indicateur
),
),
child: NavigationBar(
@@ -360,7 +360,7 @@ class _ResponsiveNavigationState extends State<ResponsiveNavigation> {
// Définir les couleurs selon le rôle (admin = rouge, user = vert)
final Color selectedColor = widget.isAdmin ? Colors.red : Colors.green;
final Color unselectedColor = theme.colorScheme.onSurface.withValues(alpha: 0.6);
final Color unselectedColor = theme.colorScheme.onSurface.withOpacity(0.6);
// Gérer le cas où l'icône est un BadgedIcon ou autre widget composite
Widget iconWidget;
@@ -402,7 +402,7 @@ class _ResponsiveNavigationState extends State<ResponsiveNavigation> {
height: 50,
decoration: BoxDecoration(
color: isSelected
? selectedColor.withValues(alpha: 0.1)
? selectedColor.withOpacity(0.1)
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
@@ -423,7 +423,7 @@ class _ResponsiveNavigationState extends State<ResponsiveNavigation> {
),
),
tileColor:
isSelected ? selectedColor.withValues(alpha: 0.1) : null,
isSelected ? selectedColor.withOpacity(0.1) : null,
onTap: () {
widget.onDestinationSelected(index);
},

View File

@@ -0,0 +1,197 @@
import 'package:flutter/material.dart';
import 'dart:ui';
/// Dialog de résultat centré avec animation
/// Affiche un résultat de succès ou d'erreur de manière élégante
class ResultDialog extends StatefulWidget {
final bool success;
final String message;
final Duration? autoDismiss;
const ResultDialog({
super.key,
required this.success,
required this.message,
this.autoDismiss,
});
/// Affiche un dialog de résultat centré
///
/// [success] : true pour succès, false pour erreur
/// [message] : Message à afficher
/// [autoDismiss] : Durée avant fermeture automatique (optionnel, uniquement pour succès)
static Future<void> show({
required BuildContext context,
required bool success,
required String message,
Duration? autoDismiss,
}) async {
return showDialog(
context: context,
barrierDismissible: false,
barrierColor: Colors.black54,
builder: (context) => ResultDialog(
success: success,
message: message,
autoDismiss: success ? (autoDismiss ?? const Duration(seconds: 2)) : null,
),
);
}
@override
State<ResultDialog> createState() => _ResultDialogState();
}
class _ResultDialogState extends State<ResultDialog>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scaleAnimation;
late Animation<double> _fadeAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 400),
);
_scaleAnimation = CurvedAnimation(
parent: _controller,
curve: Curves.elasticOut,
);
_fadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _controller,
curve: const Interval(0.0, 0.5, curve: Curves.easeOut),
));
_controller.forward();
// Auto-fermeture si demandé
if (widget.autoDismiss != null) {
Future.delayed(widget.autoDismiss!, () {
if (mounted) {
Navigator.of(context).pop();
}
});
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: _fadeAnimation,
child: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: 8.0,
sigmaY: 8.0,
),
child: Dialog(
backgroundColor: Colors.transparent,
elevation: 0,
child: ScaleTransition(
scale: _scaleAnimation,
child: _buildContent(context),
),
),
),
);
}
Widget _buildContent(BuildContext context) {
final theme = Theme.of(context);
final iconColor = widget.success ? Colors.green : Colors.red;
final icon = widget.success ? Icons.check_circle : Icons.error;
return Container(
constraints: const BoxConstraints(
maxWidth: 340,
),
padding: const EdgeInsets.all(32),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 24,
spreadRadius: 4,
offset: const Offset(0, 10),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Icône principale
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: iconColor.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Icon(
icon,
size: 50,
color: iconColor,
),
),
const SizedBox(height: 24),
// Message
Text(
widget.message,
style: theme.textTheme.titleMedium?.copyWith(
fontSize: 17,
fontWeight: FontWeight.w500,
color: Colors.grey[800],
height: 1.4,
),
textAlign: TextAlign.center,
),
// Bouton OK pour les erreurs
if (!widget.success) ...[
const SizedBox(height: 28),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () => Navigator.of(context).pop(),
style: ElevatedButton.styleFrom(
backgroundColor: iconColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 0,
),
child: const Text(
'OK',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
),
),
),
),
],
],
),
);
}
}

View File

@@ -64,8 +64,8 @@ class _SectorDistributionCardState extends State<SectorDistributionCard> {
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: isActive
? Colors.blue.withValues(alpha: 0.1)
: Colors.grey.withValues(alpha: 0.1),
? Colors.blue.withOpacity(0.1)
: Colors.grey.withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: isActive ? Colors.blue : Colors.grey[400]!,
@@ -295,7 +295,6 @@ class _SectorDistributionCardState extends State<SectorDistributionCard> {
// Récupérer les données du secteur actuel
final sectorData = allStats.firstWhere((s) => s['name'] == name);
final Map<int, int> passagesByType = sectorData['passagesByType'] ?? {};
final int progressPercentage = sectorData['progressPercentage'] ?? 0;
final int sectorId = sectorData['id'] ?? 0;
// Calculer le ratio par rapport au maximum (éviter division par zéro)
@@ -310,72 +309,51 @@ class _SectorDistributionCardState extends State<SectorDistributionCard> {
return Padding(
padding: const EdgeInsets.only(bottom: AppTheme.spacingM),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Nom du secteur et total
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: InkWell(
onTap: () {
final settingsBox = Hive.box(AppKeys.settingsBoxName);
child: InkWell(
onTap: () {
final settingsBox = Hive.box(AppKeys.settingsBoxName);
if (isAdmin) {
// Admin : naviguer vers la page carte
settingsBox.put('selectedSectorId', sectorId);
settingsBox.put('selectedPageIndex', 4); // Index de la page carte
context.go('/admin');
} else {
// User : naviguer vers la page historique avec le secteur sélectionné
settingsBox.delete('history_selectedTypeId');
settingsBox.delete('history_selectedPaymentTypeId');
settingsBox.delete('history_selectedMemberId');
settingsBox.delete('history_startDate');
settingsBox.delete('history_endDate');
// Sélectionner le secteur et "Tous les passages"
settingsBox.put('selectedSectorId', sectorId);
settingsBox.put('selectedPassageTypeFilter', -1); // -1 = Tous les passages
settingsBox.put('history_selectedSectorId', sectorId);
settingsBox.put('history_selectedSectorName', name);
context.go('/user/history');
}
},
child: Text(
name,
style: TextStyle(
fontSize: AppTheme.r(context, 14),
color: textColor,
fontWeight:
hasPassages ? FontWeight.w600 : FontWeight.w300,
decoration: TextDecoration.underline,
decorationColor: textColor.withValues(alpha: 0.5),
),
overflow: TextOverflow.ellipsis,
),
),
),
Text(
if (isAdmin) {
// Admin : naviguer vers la page carte
context.go('/admin/map');
} else {
// User : naviguer vers la page carte
context.go('/user/map');
}
},
child: Row(
children: [
// Première "cellule" : Nom du secteur avec nombre de passages (largeur fixe)
SizedBox(
width: 200,
child: Text(
hasPassages
? '$count passages ($progressPercentage% d\'avancement)'
: '0 passage',
? '$name ($count passages)'
: '$name (0 passage)',
style: TextStyle(
fontWeight: hasPassages ? FontWeight.bold : FontWeight.normal,
fontSize: AppTheme.r(context, 13),
fontSize: AppTheme.r(context, 14),
color: textColor,
fontWeight: hasPassages ? FontWeight.w600 : FontWeight.w300,
),
overflow: TextOverflow.ellipsis,
),
),
// Seconde "cellule" : Barre horizontale alignée à gauche
Expanded(
child: Align(
alignment: Alignment.centerLeft,
child: FractionallySizedBox(
widthFactor: widthRatio,
child: _buildStackedBar(passagesByType, count, sectorId, name),
),
),
],
),
const SizedBox(height: 6),
// Barre horizontale cumulée avec largeur proportionnelle
Align(
alignment: Alignment.centerLeft,
child: FractionallySizedBox(
widthFactor: widthRatio,
child: _buildStackedBar(passagesByType, count, sectorId, name),
),
),
],
],
),
),
);
}
@@ -385,7 +363,7 @@ class _SectorDistributionCardState extends State<SectorDistributionCard> {
if (totalCount == 0) {
// Barre vide pour les secteurs sans passages
return Container(
height: 24,
height: 36,
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: BorderRadius.circular(4),
@@ -397,7 +375,7 @@ class _SectorDistributionCardState extends State<SectorDistributionCard> {
final typeOrder = [1, 3, 4, 5, 6, 7, 8, 9, 2];
return Container(
height: 24,
height: 36,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
border: Border.all(color: Colors.grey[300]!, width: 0.5),

View File

@@ -185,10 +185,10 @@ class ThemeInfo extends StatelessWidget {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
color: theme.colorScheme.surfaceContainerHighest.withOpacity(0.5),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: theme.colorScheme.outline.withValues(alpha: 0.3),
color: theme.colorScheme.outline.withOpacity(0.3),
),
),
child: Row(

View File

@@ -204,26 +204,10 @@ class _UserFormState extends State<UserForm> {
}).catchError((error) {
// Gérer les erreurs spécifiques au sélecteur de date
debugPrint('Erreur lors de la sélection de la date: $error');
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Erreur lors de la sélection de la date'),
backgroundColor: Colors.red,
),
);
}
});
} catch (e) {
// Gérer toutes les autres erreurs
debugPrint('Exception lors de l\'affichage du sélecteur de date: $e');
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Impossible d\'afficher le sélecteur de date'),
backgroundColor: Colors.red,
),
);
}
}
}
@@ -425,6 +409,28 @@ class _UserFormState extends State<UserForm> {
// Méthode asynchrone pour valider et récupérer l'utilisateur avec vérification du username
Future<UserModel?> validateAndGetUserAsync(BuildContext context) async {
if (!_formKey.currentState!.validate()) {
// Afficher une dialog si la validation échoue
if (context.mounted) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Row(
children: [
Icon(Icons.warning_amber_rounded, color: Colors.orange),
SizedBox(width: 8),
Text('Formulaire incomplet'),
],
),
content: const Text('Veuillez vérifier tous les champs marqués en rouge avant d\'enregistrer'),
actions: [
ElevatedButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('OK'),
),
],
),
);
}
return null;
}

View File

@@ -62,22 +62,47 @@ class _UserFormDialogState extends State<UserFormDialog> {
final userData = await _userFormKey.currentState?.validateAndGetUserAsync(context);
final password = _userFormKey.currentState?.getPassword(); // Récupérer le mot de passe
if (userData != null) {
var finalUser = userData;
// Ajouter le rôle sélectionné si applicable
if (widget.showRoleSelector && _selectedRole != null) {
finalUser = finalUser.copyWith(role: _selectedRole);
if (userData == null) {
// Afficher une dialog si la validation échoue
if (context.mounted) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Row(
children: [
Icon(Icons.warning_amber_rounded, color: Colors.orange),
SizedBox(width: 8),
Text('Formulaire incomplet'),
],
),
content: const Text('Veuillez vérifier tous les champs marqués en rouge avant d\'enregistrer'),
actions: [
ElevatedButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('OK'),
),
],
),
);
}
return;
}
// Ajouter le statut actif si applicable
if (widget.showActiveCheckbox && _isActive != null) {
finalUser = finalUser.copyWith(isActive: _isActive);
}
// À ce stade, userData ne peut pas être null
var finalUser = userData;
if (widget.onSubmit != null) {
widget.onSubmit!(finalUser, password: password); // Passer le mot de passe
}
// Ajouter le rôle sélectionné si applicable
if (widget.showRoleSelector && _selectedRole != null) {
finalUser = finalUser.copyWith(role: _selectedRole);
}
// Ajouter le statut actif si applicable
if (widget.showActiveCheckbox && _isActive != null) {
finalUser = finalUser.copyWith(isActive: _isActive);
}
if (widget.onSubmit != null) {
widget.onSubmit!(finalUser, password: password); // Passer le mot de passe
}
}
@@ -220,33 +245,33 @@ class _UserFormDialogState extends State<UserFormDialog> {
isAdmin: widget.isAdmin, // Passer isAdmin
onSubmit: null, // Pas besoin de callback
),
// Boutons en bas du scroll
const SizedBox(height: 32),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Fermer'),
),
const SizedBox(width: 16),
if (!widget.readOnly)
ElevatedButton(
onPressed: _handleSubmit,
style: ElevatedButton.styleFrom(
backgroundColor: theme.colorScheme.primary,
foregroundColor: Colors.white,
),
child: const Text('Enregistrer'),
),
],
),
const SizedBox(height: 16), // Padding supplémentaire pour le confort
],
),
),
),
const SizedBox(height: 24),
// Footer avec boutons
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Fermer'),
),
const SizedBox(width: 16),
if (!widget.readOnly)
ElevatedButton(
onPressed: _handleSubmit,
style: ElevatedButton.styleFrom(
backgroundColor: theme.colorScheme.primary,
foregroundColor: Colors.white,
),
child: const Text('Enregistrer'),
),
],
),
],
),
),

View File

@@ -282,10 +282,10 @@ class _ValidationExampleState extends State<ValidationExample> {
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer.withValues(alpha: 0.3),
color: Theme.of(context).colorScheme.primaryContainer.withOpacity(0.3),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.3),
color: Theme.of(context).colorScheme.primary.withOpacity(0.3),
),
),
child: Column(