Files
geo/app/lib/presentation/pages/history_page.dart
Pierre 6952417147 fix: Utiliser box Hive membres pour filtre membre (#42)
- Correction du filtre membre : utilise membreRepository.getMembresBox()
- Récupère les membres depuis la box Hive (ope_users)
- Filtre uniquement les membres ayant des passages (memberIdsInPassages)
- Affichage : member.name ou member.firstName
- Tri alphabétique par nom

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 11:12:57 +01:00

711 lines
23 KiB
Dart
Executable File

import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/services/current_user_service.dart';
import 'package:geosector_app/core/theme/app_theme.dart';
import 'package:geosector_app/core/data/models/passage_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/btn_passages.dart';
/// Page d'historique unifiée utilisant AppScaffold
class HistoryPage extends StatelessWidget {
/// ID du membre à filtrer (optionnel, pour les admins)
final int? memberId;
const HistoryPage({
super.key,
this.memberId,
});
@override
Widget build(BuildContext context) {
return AppScaffold(
selectedIndex: 1, // Index de la page History dans la navigation
pageTitle: 'Historique',
body: HistoryContent(memberId: memberId),
);
}
}
/// Contenu de la page historique unifié pour admin et user
class HistoryContent extends StatefulWidget {
/// ID du membre à filtrer (optionnel, pour les admins)
final int? memberId;
const HistoryContent({
super.key,
this.memberId,
});
@override
State<HistoryContent> createState() => _HistoryContentState();
}
class _HistoryContentState extends State<HistoryContent> {
// Détection du rôle et permissions
late final bool isAdmin;
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
// Filtres
String _selectedTypeFilter = 'Tous les types';
String _searchQuery = '';
int? selectedTypeId;
int? _selectedMemberId; // null = "Tous" (admin uniquement)
int? _selectedSectorId; // null = "Tous" (admin uniquement)
// Contrôleur de recherche
final TextEditingController _searchController = TextEditingController();
// Passages originaux pour l'édition
List<PassageModel> _originalPassages = [];
List<PassageModel> _filteredPassages = [];
// État de chargement
bool _isLoading = true;
String _errorMessage = '';
@override
void initState() {
super.initState();
// Déterminer le rôle et les permissions de l'utilisateur (prend en compte le mode d'affichage)
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;
if (!isAdmin && currentUser != null && currentUser.fkEntite != null) {
final userAmicale = amicaleRepository.getAmicaleById(currentUser.fkEntite!);
if (userAmicale != null) {
userCanDelete = userAmicale.chkUserDeletePass;
}
}
canDeletePassages = isAdmin || userCanDelete;
// Charger les filtres présélectionnés depuis Hive
_loadPreselectedFilters();
// Initialiser les données
_initializeData();
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
// Callback pour gérer les clics sur les boutons de type de passage
void _handleTypeSelected(int? typeId) {
setState(() {
// Réinitialiser la recherche
_searchQuery = '';
_searchController.clear();
// 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
_applyFilters();
}
// Charger les filtres présélectionnés depuis Hive
Future<void> _loadPreselectedFilters() async {
try {
if (!Hive.isBoxOpen(AppKeys.settingsBoxName)) {
await Hive.openBox(AppKeys.settingsBoxName);
}
final settingsBox = Hive.box(AppKeys.settingsBoxName);
// Charger le type de passage sélectionné
final typeId = settingsBox.get('history_selectedTypeId');
if (typeId != null && typeId is int) {
setState(() {
selectedTypeId = typeId;
final typeInfo = AppKeys.typesPassages[typeId];
if (typeInfo != null) {
_selectedTypeFilter = typeInfo['titre'] as String;
}
});
// Supprimer le typeId de Hive après l'avoir utilisé
settingsBox.delete('history_selectedTypeId');
debugPrint('HistoryPage: Type de passage présélectionné: $typeId');
}
} catch (e) {
debugPrint('Erreur lors du chargement des filtres: $e');
}
}
// Initialiser les données
void _initializeData() {
try {
setState(() {
_isLoading = true;
_errorMessage = '';
});
// Charger les passages
final currentOperation = userRepository.getCurrentOperation();
if (currentOperation != null) {
if (isAdmin) {
// Admin : tous les passages de l'opération
_originalPassages = passageRepository.getPassagesByOperation(currentOperation.id);
} else {
// User : logique spéciale selon le type de passage
final allPassages = passageRepository.getPassagesByOperation(currentOperation.id);
_originalPassages = allPassages.where((p) {
// Type 2 (À finaliser) : afficher TOUS les passages
if (p.fkType == 2) {
return true;
}
// Autres types : seulement les passages de l'utilisateur
return p.fkUser == currentOpeUserId;
}).toList();
}
debugPrint('Nombre de passages récupérés: ${_originalPassages.length}');
// Initialiser les passages filtrés avec tous les passages
_filteredPassages = List.from(_originalPassages);
// Appliquer les filtres initiaux après le chargement
_applyFilters();
}
setState(() {
_isLoading = false;
});
} catch (e) {
setState(() {
_isLoading = false;
_errorMessage = 'Erreur lors du chargement des données: $e';
});
debugPrint('Erreur lors de l\'initialisation des données: $e');
}
}
/// Appliquer les filtres aux données
void _applyFilters() {
debugPrint('HistoryPage: Application des filtres');
debugPrint(' Type: $_selectedTypeFilter');
debugPrint(' Recherche: $_searchQuery');
// Appliquer les filtres aux passages originaux
List<PassageModel> filteredPassages = _originalPassages.where((passage) {
// Filtre par type de passage
if (_selectedTypeFilter != 'Tous les types') {
final typeInfo = AppKeys.typesPassages.entries.firstWhere(
(entry) => entry.value['titre'] == _selectedTypeFilter,
orElse: () => const MapEntry(-1, {'titre': '', 'couleur': ''}),
);
if (typeInfo.key == -1 || passage.fkType != typeInfo.key) {
return false;
}
}
// Filtre par membre (admin uniquement)
if (isAdmin && _selectedMemberId != null) {
if (passage.fkUser != _selectedMemberId) {
return false;
}
}
// Filtre par secteur (admin uniquement)
if (isAdmin && _selectedSectorId != null) {
if (passage.fkSector != _selectedSectorId) {
return false;
}
}
// Filtre par recherche textuelle
if (_searchQuery.isNotEmpty) {
final query = _searchQuery.toLowerCase();
// Construire l'adresse complète pour la recherche
final fullAddress = '${passage.numero} ${passage.rueBis} ${passage.rue} ${passage.residence} ${passage.ville}'
.toLowerCase()
.trim();
// Ajouter le nom et l'email
final name = passage.name.toLowerCase();
final email = passage.email.toLowerCase();
// Vérifier si la recherche correspond à l'adresse, au nom ou à l'email
if (!fullAddress.contains(query) &&
!name.contains(query) &&
!email.contains(query)) {
return false;
}
}
return true;
}).toList();
// Mettre à jour les données filtrées
setState(() {
_filteredPassages = filteredPassages;
});
debugPrint('HistoryPage: ${filteredPassages.length} passages filtrés sur ${_originalPassages.length}');
}
@override
Widget build(BuildContext context) {
// Le contenu sans scaffold (AppScaffold est déjà dans HistoryPage)
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_errorMessage.isNotEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 64, color: Colors.red),
const SizedBox(height: 16),
Text(_errorMessage, style: const TextStyle(fontSize: 16)),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _initializeData,
child: const Text('Réessayer'),
),
],
),
);
}
return _buildContent();
}
Widget _buildContent() {
final screenWidth = MediaQuery.of(context).size.width;
final isDesktop = screenWidth > 800;
return Column(
children: [
// 0. BtnPassages collé en haut/gauche/droite
BtnPassages(
onTypeSelected: _handleTypeSelected,
selectedTypeId: selectedTypeId,
),
// 1. Barre de recherche
Padding(
padding: EdgeInsets.symmetric(
horizontal: isDesktop ? AppTheme.spacingL : AppTheme.spacingS,
vertical: AppTheme.spacingS,
),
child: Row(
children: [
// Barre de recherche
Expanded(
flex: 3,
child: TextFormField(
controller: _searchController,
decoration: const InputDecoration(
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
hintText: 'Rechercher...',
prefixIcon: Icon(Icons.search, size: 20),
isDense: true,
),
onChanged: (String value) {
setState(() {
_searchQuery = value;
});
_applyFilters();
},
),
),
// Filtres admin uniquement
if (isAdmin) ...[
const SizedBox(width: 8),
// Filtre par membre
Expanded(
flex: 2,
child: _buildMemberDropdown(),
),
const SizedBox(width: 8),
// Filtre par secteur
Expanded(
flex: 2,
child: _buildSectorDropdown(),
),
],
],
),
),
// 2. Liste des passages - EXPANDED pour prendre tout l'espace restant
Expanded(
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: isDesktop ? AppTheme.spacingL : AppTheme.spacingS,
),
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,
),
),
),
),
],
);
}
// Afficher le formulaire 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: () {
_initializeData(); // Recharger les données
},
),
);
}
// Méthode helper pour convertir une valeur potentiellement null en String
String _safeString(dynamic value) {
if (value == null) return '';
return value.toString();
}
// Convertir les passages en Map pour PassagesListWidget
List<Map<String, dynamic>> _convertPassagesToMaps() {
try {
// Utiliser les passages filtrés
var passages = _filteredPassages.toList();
// Convertir chaque passage en Map
final maps = <Map<String, dynamic>>[];
for (final passage in passages) {
try {
// Construire l'adresse complète
final fullAddress = '${_safeString(passage.numero)} ${_safeString(passage.rue)} ${_safeString(passage.ville)}'.trim();
// Convertir le montant en double (normalement un String dans PassageModel)
double? montantDouble = 0.0;
final montantStr = _safeString(passage.montant);
if (montantStr.isNotEmpty) {
montantDouble = double.tryParse(montantStr) ?? 0.0;
}
// Gestion de la date selon le type de passage
// Pour les passages "À finaliser" (type 2), la date peut être null
DateTime? passageDate = passage.passedAt;
final map = <String, dynamic>{
'id': passage.id,
'fk_operation': passage.fkOperation,
'fk_sector': passage.fkSector,
'fk_user': passage.fkUser,
'fkUser': passage.fkUser,
'type': passage.fkType,
'fk_type': passage.fkType,
'payment': passage.fkTypeReglement,
'fk_type_reglement': passage.fkTypeReglement,
'fk_adresse': _safeString(passage.fkAdresse),
'address': fullAddress.isNotEmpty ? fullAddress : 'Adresse non renseignée',
'date': passageDate,
'datePassage': passageDate,
'passed_at': passage.passedAt?.toIso8601String(),
'passedAt': passageDate,
'numero': _safeString(passage.numero),
'rue': _safeString(passage.rue),
'rue_bis': _safeString(passage.rueBis),
'ville': _safeString(passage.ville),
'residence': _safeString(passage.residence),
'fk_habitat': passage.fkHabitat,
'appt': _safeString(passage.appt),
'niveau': _safeString(passage.niveau),
'gps_lat': _safeString(passage.gpsLat),
'gps_lng': _safeString(passage.gpsLng),
'nom_recu': _safeString(passage.nomRecu),
'remarque': _safeString(passage.remarque),
'notes': _safeString(passage.remarque),
'montant': _safeString(passage.montant),
'amount': montantDouble,
'email_erreur': _safeString(passage.emailErreur),
'nb_passages': passage.nbPassages,
'name': _safeString(passage.name),
'email': _safeString(passage.email),
'phone': _safeString(passage.phone),
'fullAddress': fullAddress,
};
maps.add(map);
} catch (e) {
debugPrint('Erreur lors de la conversion du passage ${passage.id}: $e');
}
}
debugPrint('${maps.length} passages convertis avec succès');
return maps;
} catch (e) {
debugPrint('Erreur lors de la conversion des passages en Maps: $e');
return [];
}
}
// Gérer l'édition d'un passage depuis le Map
void _handlePassageEditMap(Map<String, dynamic> passageMap) {
final passageId = passageMap['id'] as int;
final passage = _originalPassages.firstWhere(
(p) => p.id == passageId,
orElse: () => PassageModel.fromJson(passageMap),
);
// Vérifier les permissions :
// - Admin peut tout éditer
// - User peut éditer ses propres passages
// - Type 2 (À finaliser) : éditable par tous les utilisateurs
if (isAdmin || passage.fkUser == currentOpeUserId || passage.fkType == 2) {
_handlePassageEdit(passage);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Vous ne pouvez éditer que vos propres passages'),
backgroundColor: Colors.orange,
),
);
}
}
// Gérer la suppression d'un passage depuis le Map
void _handlePassageDeleteMap(Map<String, dynamic> passageMap) {
final passageId = passageMap['id'] as int;
final passage = _originalPassages.firstWhere(
(p) => p.id == passageId,
orElse: () => PassageModel.fromJson(passageMap),
);
// Vérifier les permissions : admin peut tout supprimer, user seulement ses propres passages si autorisé
if (isAdmin || (canDeletePassages && passage.fkUser == currentOpeUserId)) {
_handlePassageDelete(passage);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
!canDeletePassages
? 'Vous n\'êtes pas autorisé à supprimer des passages'
: 'Vous ne pouvez supprimer que vos propres passages'
),
backgroundColor: Colors.orange,
),
);
}
}
// Gérer l'édition d'un passage
void _handlePassageEdit(PassageModel passage) async {
await showDialog<PassageModel>(
context: context,
builder: (context) => PassageFormDialog(
passage: passage,
title: 'Modifier le passage',
readOnly: false,
passageRepository: passageRepository,
userRepository: userRepository,
operationRepository: operationRepository,
amicaleRepository: amicaleRepository,
onSuccess: () {
_initializeData(); // Recharger les données
},
),
);
}
// Gérer la suppression d'un passage
void _handlePassageDelete(PassageModel passage) async {
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Confirmer la suppression'),
content: Text(
'Êtes-vous sûr de vouloir supprimer le passage chez ${passage.name} ?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Annuler'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Supprimer'),
),
],
),
);
if (confirm == true) {
try {
await passageRepository.deletePassage(passage.id);
_initializeData(); // Recharger les données
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Passage supprimé avec succès'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur lors de la suppression: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
}
/// Construit le dropdown de sélection de membre (admin uniquement)
Widget _buildMemberDropdown() {
// Récupérer les IDs de membres uniques depuis les passages
final memberIdsInPassages = <int>{};
for (final passage in _originalPassages) {
memberIdsInPassages.add(passage.fkUser);
}
// Récupérer les membres depuis la box Hive
final membresBox = membreRepository.getMembresBox();
final membres = membresBox.values.where((membre) {
return memberIdsInPassages.contains(membre.id);
}).toList();
// Trier par nom
membres.sort((a, b) {
final nameA = a.name ?? a.firstName ?? '';
final nameB = b.name ?? b.firstName ?? '';
return nameA.compareTo(nameB);
});
return DropdownButtonFormField<int?>(
value: _selectedMemberId,
decoration: const InputDecoration(
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
isDense: true,
labelText: 'Membre',
prefixIcon: Icon(Icons.person, size: 20),
),
items: [
const DropdownMenuItem<int?>(
value: null,
child: Text('Tous'),
),
...membres.map((membre) {
final displayName = membre.name ?? membre.firstName ?? 'Membre #${membre.id}';
return DropdownMenuItem<int?>(
value: membre.id,
child: Text(
displayName,
overflow: TextOverflow.ellipsis,
),
);
}),
],
onChanged: (int? newValue) {
setState(() {
_selectedMemberId = newValue;
});
_applyFilters();
},
);
}
/// Construit le dropdown de sélection de secteur (admin uniquement)
Widget _buildSectorDropdown() {
// Récupérer les secteurs uniques depuis les passages de l'opération courante
final sectorIds = <int>{};
for (final passage in _originalPassages) {
if (passage.fkSector != null && !sectorIds.contains(passage.fkSector)) {
sectorIds.add(passage.fkSector!);
}
}
// Récupérer les noms des secteurs depuis le repository
final allSectors = sectorRepository.getAllSectors();
final sectorNames = <int, String>{};
for (final sector in allSectors) {
if (sectorIds.contains(sector.id)) {
sectorNames[sector.id] = sector.libelle;
}
}
// Trier par nom
final sortedSectors = sectorIds.toList()
..sort((a, b) => (sectorNames[a] ?? '').compareTo(sectorNames[b] ?? ''));
return DropdownButtonFormField<int?>(
value: _selectedSectorId,
decoration: const InputDecoration(
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
isDense: true,
labelText: 'Secteur',
prefixIcon: Icon(Icons.location_on, size: 20),
),
items: [
const DropdownMenuItem<int?>(
value: null,
child: Text('Tous'),
),
...sortedSectors.map((sectorId) {
return DropdownMenuItem<int?>(
value: sectorId,
child: Text(
sectorNames[sectorId] ?? 'Secteur #$sectorId',
overflow: TextOverflow.ellipsis,
),
);
}),
],
onChanged: (int? newValue) {
setState(() {
_selectedSectorId = newValue;
});
_applyFilters();
},
);
}
}