feat: Version 3.5.2 - Configuration Stripe et gestion des immeubles

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

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

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

View File

@@ -7,11 +7,11 @@ import 'package:geosector_app/core/theme/app_theme.dart';
import 'package:geosector_app/core/data/models/passage_model.dart';
import 'package:geosector_app/core/data/models/sector_model.dart';
import 'package:geosector_app/core/data/models/membre_model.dart';
import 'package:geosector_app/core/data/models/user_model.dart';
import 'package:geosector_app/presentation/widgets/passages/passages_list_widget.dart';
import 'package:geosector_app/presentation/widgets/passage_form_dialog.dart';
import 'package:geosector_app/presentation/widgets/app_scaffold.dart';
import 'package:geosector_app/presentation/widgets/charts/charts.dart';
import 'package:geosector_app/presentation/widgets/btn_passages.dart';
import 'package:intl/intl.dart';
/// Page d'historique unifiée utilisant AppScaffold
@@ -56,12 +56,16 @@ enum PassageSortType {
addressDesc, // Adresse Z-A
}
class _HistoryContentState extends State<HistoryContent> {
class _HistoryContentState extends State<HistoryContent> with SingleTickerProviderStateMixin {
// Détection du rôle et permissions
late final bool isAdmin;
late final int currentUserId;
late final int currentUserId; // users.id (table centrale)
late final int? currentOpeUserId; // ope_users.id (pour comparaisons avec passages)
late final bool canDeletePassages; // Permission de suppression pour les users
// TabController pour les onglets Filtres / Statistiques
late TabController _tabController;
// Filtres principaux (nouveaux)
String _selectedTypeFilter = 'Tous les types';
String _selectedPaymentFilter = 'Tous les règlements';
@@ -89,8 +93,7 @@ class _HistoryContentState extends State<HistoryContent> {
// Listes pour les filtres
List<SectorModel> _sectors = [];
List<MembreModel> _membres = [];
List<UserModel> _users = []; // Liste des users pour le filtre
List<MembreModel> _membres = []; // Liste des membres de l'opération
// Passages originaux pour l'édition
List<PassageModel> _originalPassages = [];
@@ -100,14 +103,15 @@ class _HistoryContentState extends State<HistoryContent> {
bool _isLoading = true;
String _errorMessage = '';
// Statistiques pour l'affichage
int _totalSectors = 0;
int _sharedMembersCount = 0;
// État de la section graphiques
bool _isGraphicsExpanded = true;
// Hauteur dynamique du TabBarView selon l'onglet actif
double _tabBarViewHeight = 280.0; // Hauteur par défaut (Filtres)
// Onglet précédemment sélectionné (pour détecter les clics sur le même onglet)
int _previousTabIndex = 0;
// Listener pour les changements de secteur depuis map_page
late final Box _settingsBox;
@@ -115,6 +119,9 @@ class _HistoryContentState extends State<HistoryContent> {
void initState() {
super.initState();
// Initialiser le TabController (2 onglets)
_tabController = TabController(length: 2, vsync: this);
// Initialiser la box settings et écouter les changements de secteur
_initSettingsListener();
@@ -122,6 +129,7 @@ class _HistoryContentState extends State<HistoryContent> {
final currentUser = userRepository.getCurrentUser();
isAdmin = CurrentUserService.instance.shouldShowAdminUI;
currentUserId = currentUser?.id ?? 0;
currentOpeUserId = currentUser?.opeUserId; // ID dans ope_users pour comparaisons avec passages
// Vérifier la permission de suppression pour les users
bool userCanDelete = false;
@@ -158,6 +166,7 @@ class _HistoryContentState extends State<HistoryContent> {
@override
void dispose() {
_tabController.dispose();
_startDateController.dispose();
_endDateController.dispose();
_searchController.dispose();
@@ -165,6 +174,68 @@ class _HistoryContentState extends State<HistoryContent> {
super.dispose();
}
// Callback pour gérer les clics sur les onglets
void _onTabTapped(int index) {
setState(() {
// Si on clique sur le même onglet alors que l'ExpansionTile est ouvert → le fermer
if (index == _previousTabIndex && _isGraphicsExpanded) {
_isGraphicsExpanded = false;
_saveGraphicsExpandedState();
}
// Sinon, ouvrir l'ExpansionTile et ajuster la hauteur
else {
if (!_isGraphicsExpanded) {
_isGraphicsExpanded = true;
_saveGraphicsExpandedState();
}
// Onglet 0 = Filtres (hauteur plus petite)
// Onglet 1 = Statistiques (hauteur plus grande)
_tabBarViewHeight = index == 0 ? 280.0 : 800.0;
}
_previousTabIndex = index;
});
}
// Callback pour gérer les clics sur les boutons de type de passage
void _handleTypeSelected(int? typeId) {
setState(() {
// Réinitialiser tous les filtres
_selectedPaymentFilter = 'Tous les règlements';
_selectedPaymentTypeId = null;
selectedPaymentTypeId = null;
startDate = null;
endDate = null;
_startDateController.clear();
_endDateController.clear();
_searchQuery = '';
_searchController.clear();
_selectedSectorId = null;
selectedSectorId = null;
if (isAdmin) {
_selectedUserId = null;
selectedMemberId = null;
}
// Appliquer le filtre de type
if (typeId == null) {
// Tous les passages
_selectedTypeFilter = 'Tous les types';
selectedTypeId = null;
} else {
// Type spécifique
selectedTypeId = typeId;
final typeInfo = AppKeys.typesPassages[typeId];
if (typeInfo != null) {
_selectedTypeFilter = typeInfo['titre'] as String;
}
}
});
// Appliquer les filtres
_notifyFiltersChanged();
}
// Initialiser le listener pour les changements de secteur
Future<void> _initSettingsListener() async {
try {
@@ -368,14 +439,87 @@ class _HistoryContentState extends State<HistoryContent> {
debugPrint('HistoryPage: ${filteredPassages.length} passages filtrés sur ${_originalPassages.length}');
}
/// Construire la card de filtres intégrée
Widget _buildFiltersCard() {
/// Construire la section TabBar + ExpansionTile
Widget _buildTabBarSection() {
return Card(
elevation: 0,
color: Colors.transparent,
child: Theme(
data: Theme.of(context).copyWith(
dividerColor: Colors.transparent,
colorScheme: Theme.of(context).colorScheme.copyWith(
primary: AppTheme.primaryColor,
),
),
child: ExpansionTile(
key: ValueKey('expansion_tile_$_isGraphicsExpanded'),
initiallyExpanded: _isGraphicsExpanded,
trailing: const SizedBox.shrink(), // Masquer la flèche d'expansion
onExpansionChanged: (expanded) {
setState(() {
_isGraphicsExpanded = expanded;
// Réinitialiser _previousTabIndex quand on ferme manuellement
// pour permettre de rouvrir en cliquant sur l'onglet actif
if (!expanded) {
_previousTabIndex = -1;
}
});
_saveGraphicsExpandedState();
},
tilePadding: EdgeInsets.zero,
childrenPadding: EdgeInsets.zero,
title: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
boxShadow: AppTheme.cardShadow,
),
child: TabBar(
controller: _tabController,
labelColor: AppTheme.primaryColor,
unselectedLabelColor: Colors.grey[600],
indicatorColor: AppTheme.primaryColor,
indicatorWeight: 3,
onTap: _onTabTapped,
tabs: const [
Tab(
icon: Icon(Icons.filter_list, size: 20),
text: 'Filtres',
),
Tab(
icon: Icon(Icons.analytics_outlined, size: 20),
text: 'Statistiques',
),
],
),
),
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
height: _tabBarViewHeight,
child: TabBarView(
controller: _tabController,
children: [
// Onglet 1 : Filtres
_buildFiltersContent(),
// Onglet 2 : Statistiques
_buildGraphicsContent(),
],
),
),
],
),
),
);
}
/// Construire le contenu des filtres (ancien _buildFiltersCard sans la Card)
Widget _buildFiltersContent() {
final screenWidth = MediaQuery.of(context).size.width;
final isDesktop = screenWidth > 800;
return Card(
elevation: 2,
color: Colors.transparent,
return SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
@@ -534,10 +678,10 @@ class _HistoryContentState extends State<HistoryContent> {
value: null,
child: Text('Membres'),
),
..._users.map((UserModel user) {
..._membres.map((MembreModel membre) {
return DropdownMenuItem<int?>(
value: user.id,
child: Text('${user.firstName ?? ''} ${user.name ?? ''}'),
value: membre.opeUserId,
child: Text('${membre.firstName ?? ''} ${membre.name ?? ''}${membre.sectName != null && membre.sectName!.isNotEmpty ? ' (${membre.sectName})' : ''}'),
);
}),
],
@@ -910,7 +1054,34 @@ class _HistoryContentState extends State<HistoryContent> {
// Charger le type de passage sélectionné
final typeId = settingsBox.get('history_selectedTypeId');
if (typeId != null && typeId is int) {
// Réinitialiser TOUS les filtres avant d'appliquer le type
setState(() {
// Réinitialiser les filtres de type et paiement
_selectedPaymentFilter = 'Tous les règlements';
_selectedPaymentTypeId = null;
selectedPaymentTypeId = null;
// Réinitialiser les dates
startDate = null;
endDate = null;
_startDateController.clear();
_endDateController.clear();
// Réinitialiser la recherche
_searchQuery = '';
_searchController.clear();
// Réinitialiser le secteur
_selectedSectorId = null;
selectedSectorId = null;
// Réinitialiser le membre (admin seulement)
if (isAdmin) {
_selectedUserId = null;
selectedMemberId = null;
}
// Appliquer le type de passage sélectionné
selectedTypeId = typeId;
final typeInfo = AppKeys.typesPassages[typeId];
selectedType = typeInfo != null ? typeInfo['titre'] as String : 'Inconnu';
@@ -919,7 +1090,11 @@ class _HistoryContentState extends State<HistoryContent> {
_selectedTypeFilter = typeInfo['titre'] as String;
}
});
debugPrint('HistoryPage: Type de passage présélectionné: $typeId');
// Supprimer le typeId de Hive après l'avoir utilisé
settingsBox.delete('history_selectedTypeId');
debugPrint('HistoryPage: Type de passage présélectionné: $typeId (tous les autres filtres réinitialisés)');
}
// Charger le type de règlement sélectionné
@@ -977,30 +1152,15 @@ class _HistoryContentState extends State<HistoryContent> {
_sectors = sectorRepository.getAllSectors()
.where((s) => userSectorIds.contains(s.id))
.toList();
// Calculer les statistiques pour l'utilisateur
_totalSectors = _sectors.length;
// Compter les membres partageant les mêmes secteurs
final allUserSectors = userRepository.getUserSectors();
final sharedMembers = <int>{};
for (final userSector in allUserSectors) {
if (userSectorIds.contains(userSector.id) && userSector.id != currentUserId) {
sharedMembers.add(userSector.id);
}
}
_sharedMembersCount = sharedMembers.length;
}
debugPrint('Nombre de secteurs récupérés: ${_sectors.length}');
// Charger les membres (admin seulement)
// Charger les membres de l'opération (admin seulement)
if (isAdmin) {
_membres = membreRepository.getAllMembres();
debugPrint('Nombre de membres récupérés: ${_membres.length}');
// Convertir les membres en users pour le filtre
_users = _convertMembresToUsers();
debugPrint('Nombre d\'utilisateurs pour le filtre: ${_users.length}');
// Charger directement depuis MembreModel (déjà unique, pas de déduplication nécessaire)
final membreBox = Hive.box<MembreModel>(AppKeys.membresBoxName);
_membres = membreBox.values.whereType<MembreModel>().toList();
debugPrint('Nombre de membres de l\'opération récupérés: ${_membres.length}');
}
// Charger les passages
@@ -1018,7 +1178,7 @@ class _HistoryContentState extends State<HistoryContent> {
return true;
}
// Autres types : seulement les passages de l'utilisateur
return p.fkUser == currentUserId;
return p.fkUser == currentOpeUserId;
}).toList();
}
debugPrint('Nombre de passages récupérés: ${_originalPassages.length}');
@@ -1042,46 +1202,6 @@ class _HistoryContentState extends State<HistoryContent> {
}
}
// Convertir les MembreModel en UserModel pour le filtre (admin seulement)
List<UserModel> _convertMembresToUsers() {
final users = <UserModel>[];
for (final membre in _membres) {
// Utiliser l'ID du membre pour récupérer l'utilisateur associé
final user = userRepository.getUserById(membre.id);
if (user != null) {
// Si l'utilisateur existe, copier avec le sectName du membre
users.add(user.copyWith(
sectName: membre.sectName ?? user.sectName,
));
} else {
// Créer un UserModel temporaire si l'utilisateur n'existe pas
users.add(UserModel(
id: membre.id,
username: membre.username ?? 'membre_${membre.id}',
name: membre.name,
firstName: membre.firstName,
email: membre.email,
role: membre.role,
isActive: membre.isActive,
createdAt: membre.createdAt,
lastSyncedAt: DateTime.now(),
sectName: membre.sectName,
));
}
}
// Trier par nom complet
users.sort((a, b) {
final nameA = '${a.firstName ?? ''} ${a.name ?? ''}'.trim().toLowerCase();
final nameB = '${b.firstName ?? ''} ${b.name ?? ''}'.trim().toLowerCase();
return nameA.compareTo(nameB);
});
return users;
}
@override
Widget build(BuildContext context) {
// Le contenu sans scaffold (AppScaffold est déjà dans HistoryPage)
@@ -1111,129 +1231,47 @@ class _HistoryContentState extends State<HistoryContent> {
}
Widget _buildContent() {
// Titre unique pour tous
const pageTitle = 'Historique des passages';
// Statistiques pour les users
final statsText = !isAdmin
? '$_totalSectors secteur${_totalSectors > 1 ? 's' : ''} | $_sharedMembersCount membre${_sharedMembersCount > 1 ? 's' : ''} en partage'
: null;
final screenWidth = MediaQuery.of(context).size.width;
final isDesktop = screenWidth > 800;
return SingleChildScrollView(
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: isDesktop ? AppTheme.spacingL : AppTheme.spacingS,
vertical: AppTheme.spacingL,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// En-tête avec titre
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Titre
Text(
pageTitle,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
if (statsText != null) ...[
const SizedBox(height: 8),
Text(
statsText,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey[600],
),
),
],
],
),
const SizedBox(height: 16),
// 1. Card de filtres intégrée
_buildFiltersCard(),
const SizedBox(height: 16),
// 2. Section graphiques (rétractable)
_buildGraphicsSection(),
const SizedBox(height: 16),
// 3. Liste des passages avec hauteur maximale
Card(
elevation: 2,
child: Container(
constraints: const BoxConstraints(
maxHeight: 700,
),
child: PassagesListWidget(
passages: _convertPassagesToMaps(),
showActions: true, // Actions disponibles pour tous (avec vérification des droits)
showAddButton: true, // Bouton + pour tous
onPassageEdit: _handlePassageEditMap, // Tous peuvent éditer (avec vérification)
onPassageDelete: canDeletePassages ? _handlePassageDeleteMap : null, // Selon permissions
onAddPassage: () async {
await _showPassageFormDialog(context);
},
),
),
),
],
),
return Padding(
padding: EdgeInsets.symmetric(
horizontal: isDesktop ? AppTheme.spacingL : AppTheme.spacingS,
vertical: AppTheme.spacingL,
),
);
}
// Construction de la section graphiques rétractable (pour intégration dans PassagesListWidget)
Widget _buildGraphicsSection() {
// final screenWidth = MediaQuery.of(context).size.width; // Non utilisé actuellement
return Card(
elevation: 0,
color: Colors.transparent,
child: Theme(
data: Theme.of(context).copyWith(
dividerColor: Colors.transparent,
colorScheme: Theme.of(context).colorScheme.copyWith(
primary: AppTheme.primaryColor,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 0. BtnPassages
BtnPassages(
onTypeSelected: _handleTypeSelected,
selectedTypeId: selectedTypeId,
),
),
child: ExpansionTile(
title: Row(
children: [
Icon(Icons.analytics_outlined, color: AppTheme.primaryColor, size: 20),
const SizedBox(width: 8),
Text(
'Statistiques graphiques',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
const SizedBox(height: AppTheme.spacingL),
// 1. TabBar + ExpansionTile (Filtres / Statistiques) - FIXE EN HAUT
_buildTabBarSection(),
SizedBox(height: _isGraphicsExpanded ? 8 : 16),
// 2. Liste des passages - EXPANDED pour prendre tout l'espace restant
Expanded(
child: Card(
elevation: 2,
child: PassagesListWidget(
passages: _convertPassagesToMaps(),
showActions: true, // Actions disponibles pour tous (avec vérification des droits)
showAddButton: true, // Bouton + pour tous
onPassageEdit: _handlePassageEditMap, // Tous peuvent éditer (avec vérification)
onPassageDelete: canDeletePassages ? _handlePassageDeleteMap : null, // Selon permissions
onAddPassage: () async {
await _showPassageFormDialog(context);
},
filteredPassageType: _selectedTypeFilter != 'Tous les types' ? _selectedTypeFilter : null,
),
],
),
),
subtitle: !_isGraphicsExpanded ? Text(
isAdmin ? "Tous les passages de l'opération" : "Mes passages de l'opération",
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
) : null,
initiallyExpanded: _isGraphicsExpanded,
onExpansionChanged: (expanded) {
setState(() {
_isGraphicsExpanded = expanded;
});
_saveGraphicsExpandedState();
},
tilePadding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
childrenPadding: const EdgeInsets.only(top: 0, bottom: 16.0),
expandedCrossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildGraphicsContent(),
],
),
],
),
);
}
@@ -1243,8 +1281,9 @@ class _HistoryContentState extends State<HistoryContent> {
final screenWidth = MediaQuery.of(context).size.width;
final isDesktop = screenWidth > 800;
return Column(
children: [
return SingleChildScrollView(
child: Column(
children: [
// Graphiques en camembert (côte à côte sur desktop)
isDesktop
? Row(
@@ -1266,7 +1305,8 @@ class _HistoryContentState extends State<HistoryContent> {
// Graphique d'activité
_buildActivityChart(),
],
],
),
);
}
@@ -1603,7 +1643,7 @@ class _HistoryContentState extends State<HistoryContent> {
// - Admin peut tout éditer
// - User peut éditer ses propres passages
// - Type 2 (À finaliser) : éditable par tous les utilisateurs
if (isAdmin || passage.fkUser == currentUserId || passage.fkType == 2) {
if (isAdmin || passage.fkUser == currentOpeUserId || passage.fkType == 2) {
_handlePassageEdit(passage);
} else {
ScaffoldMessenger.of(context).showSnackBar(
@@ -1624,7 +1664,7 @@ class _HistoryContentState extends State<HistoryContent> {
);
// Vérifier les permissions : admin peut tout supprimer, user seulement ses propres passages si autorisé
if (isAdmin || (canDeletePassages && passage.fkUser == currentUserId)) {
if (isAdmin || (canDeletePassages && passage.fkUser == currentOpeUserId)) {
_handlePassageDelete(passage);
} else {
ScaffoldMessenger.of(context).showSnackBar(