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:
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
379
app/lib/presentation/widgets/btn_passages.dart
Normal file
379
app/lib/presentation/widgets/btn_passages.dart
Normal 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');
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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' : ''}';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
648
app/lib/presentation/widgets/grouped_passages_dialog.dart
Normal file
648
app/lib/presentation/widgets/grouped_passages_dialog.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
182
app/lib/presentation/widgets/qr_code_payment_dialog.dart
Normal file
182
app/lib/presentation/widgets/qr_code_payment_dialog.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
197
app/lib/presentation/widgets/result_dialog.dart
Normal file
197
app/lib/presentation/widgets/result_dialog.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user