- 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>
649 lines
22 KiB
Dart
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);
|
|
}
|
|
}
|
|
}
|
|
}
|