Files
geo/app/lib/presentation/widgets/grouped_passages_dialog.dart
pierre 2f5946a184 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>
2025-11-09 18:26:27 +01:00

649 lines
22 KiB
Dart

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);
}
}
}
}