Initialisation du projet geosector complet (web + flutter)

This commit is contained in:
d6soft
2025-05-01 18:59:27 +02:00
commit b5aafc424b
244 changed files with 37296 additions and 0 deletions

View File

@@ -0,0 +1,918 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:geosector_app/core/theme/app_theme.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
/// Un widget réutilisable pour afficher une liste de passages avec filtres
class PassagesListWidget extends StatefulWidget {
/// Liste des passages à afficher
final List<Map<String, dynamic>> passages;
/// Titre de la section (optionnel)
final String? title;
/// Nombre maximum de passages à afficher (optionnel)
final int? maxPassages;
/// Si vrai, les filtres seront affichés
final bool showFilters;
/// Si vrai, la barre de recherche sera affichée
final bool showSearch;
/// Si vrai, les boutons d'action (détails, modifier, etc.) seront affichés
final bool showActions;
/// Callback appelé lorsqu'un passage est sélectionné
final Function(Map<String, dynamic>)? onPassageSelected;
/// Callback appelé lorsqu'un passage est modifié
final Function(Map<String, dynamic>)? onPassageEdit;
/// Callback appelé lorsqu'un reçu est demandé
final Function(Map<String, dynamic>)? onReceiptView;
/// Callback appelé lorsque les détails sont demandés
final Function(Map<String, dynamic>)? onDetailsView;
/// Filtres initiaux (optionnels)
final String? initialTypeFilter;
final String? initialPaymentFilter;
final String? initialSearchQuery;
/// Filtres avancés (optionnels)
/// Liste des types de passages à exclure (ex: [2] pour exclure les passages "À finaliser")
final List<int>? excludePassageTypes;
/// ID de l'utilisateur pour filtrer les passages (null = tous les utilisateurs)
final int? filterByUserId;
/// ID du secteur pour filtrer les passages (null = tous les secteurs)
final int? filterBySectorId;
/// Période de filtrage pour la date passedAt
final String? periodFilter; // 'last15', 'lastWeek', 'lastMonth', 'custom'
/// Plage de dates personnalisée pour le filtrage (utilisé si periodFilter = 'custom')
final DateTimeRange? dateRange;
const PassagesListWidget({
super.key,
required this.passages,
this.title,
this.maxPassages,
this.showFilters = true,
this.showSearch = true,
this.showActions = true,
this.onPassageSelected,
this.onPassageEdit,
this.onReceiptView,
this.onDetailsView,
this.initialTypeFilter,
this.initialPaymentFilter,
this.initialSearchQuery,
this.excludePassageTypes,
this.filterByUserId,
this.filterBySectorId,
this.periodFilter,
this.dateRange,
});
@override
State<PassagesListWidget> createState() => _PassagesListWidgetState();
}
class _PassagesListWidgetState extends State<PassagesListWidget> {
// Filtres
late String _selectedTypeFilter;
late String _selectedPaymentFilter;
late String _searchQuery;
// Contrôleur de recherche
final TextEditingController _searchController = TextEditingController();
@override
void initState() {
super.initState();
// Initialiser les filtres
_selectedTypeFilter = widget.initialTypeFilter ?? 'Tous';
_selectedPaymentFilter = widget.initialPaymentFilter ?? 'Tous';
_searchQuery = widget.initialSearchQuery ?? '';
_searchController.text = _searchQuery;
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
// Liste filtrée avec gestion des erreurs
List<Map<String, dynamic>> get _filteredPassages {
try {
var filtered = widget.passages.where((passage) {
try {
// Vérification que le passage est valide
if (passage == null) {
return false;
}
// Exclure les types de passages spécifiés
if (widget.excludePassageTypes != null &&
passage.containsKey('type') &&
widget.excludePassageTypes!.contains(passage['type'])) {
return false;
}
// Filtrer par utilisateur
if (widget.filterByUserId != null &&
passage.containsKey('fkUser') &&
passage['fkUser'] != widget.filterByUserId) {
return false;
}
// Filtrer par secteur
if (widget.filterBySectorId != null &&
passage.containsKey('fkSector') &&
passage['fkSector'] != widget.filterBySectorId) {
return false;
}
// Filtre par type
if (_selectedTypeFilter != 'Tous') {
try {
final typeEntries = AppKeys.typesPassages.entries.where(
(entry) => entry.value['titre'] == _selectedTypeFilter);
if (typeEntries.isNotEmpty) {
final typeIndex = typeEntries.first.key;
if (!passage.containsKey('type') ||
passage['type'] != typeIndex) {
return false;
}
}
} catch (e) {
debugPrint('Erreur de filtrage par type: $e');
}
}
// Filtre par type de règlement
if (_selectedPaymentFilter != 'Tous') {
try {
final paymentEntries = AppKeys.typesReglements.entries.where(
(entry) => entry.value['titre'] == _selectedPaymentFilter);
if (paymentEntries.isNotEmpty) {
final paymentIndex = paymentEntries.first.key;
if (!passage.containsKey('payment') ||
passage['payment'] != paymentIndex) {
return false;
}
}
} catch (e) {
debugPrint('Erreur de filtrage par type de règlement: $e');
}
}
// Filtre par recherche
if (_searchQuery.isNotEmpty) {
try {
final query = _searchQuery.toLowerCase();
final address = passage.containsKey('address')
? passage['address']?.toString().toLowerCase() ?? ''
: '';
final name = passage.containsKey('name')
? passage['name']?.toString().toLowerCase() ?? ''
: '';
final notes = passage.containsKey('notes')
? passage['notes']?.toString().toLowerCase() ?? ''
: '';
return address.contains(query) ||
name.contains(query) ||
notes.contains(query);
} catch (e) {
debugPrint('Erreur de filtrage par recherche: $e');
return false;
}
}
return true;
} catch (e) {
debugPrint('Erreur lors du filtrage d\'un passage: $e');
return false;
}
}).toList();
// Trier les passages par date (les plus récents d'abord)
filtered.sort((a, b) {
if (a.containsKey('date') && b.containsKey('date')) {
final DateTime dateA = a['date'] as DateTime;
final DateTime dateB = b['date'] as DateTime;
return dateB.compareTo(dateA); // Ordre décroissant
}
return 0;
});
// Limiter le nombre de passages si maxPassages est défini
if (widget.maxPassages != null && filtered.length > widget.maxPassages!) {
filtered = filtered.sublist(0, widget.maxPassages!);
}
return filtered;
} catch (e) {
debugPrint('Erreur critique dans _filteredPassages: $e');
return [];
}
}
// Vérifier si un passage appartient à l'utilisateur courant
bool _isPassageOwnedByCurrentUser(Map<String, dynamic> passage) {
// Utiliser directement le champ isOwnedByCurrentUser s'il existe
if (passage.containsKey('isOwnedByCurrentUser')) {
return passage['isOwnedByCurrentUser'] == true;
}
// Sinon, vérifier si le passage appartient à l'utilisateur filtré
if (widget.filterByUserId != null && passage.containsKey('fkUser')) {
return passage['fkUser'].toString() == widget.filterByUserId.toString();
}
// Par défaut, considérer que le passage n'appartient pas à l'utilisateur courant
return false;
}
// Widget pour construire la ligne d'informations du passage (date, nom, montant, règlement)
Widget _buildPassageInfoRow(Map<String, dynamic> passage, ThemeData theme,
DateFormat dateFormat, Map<String, dynamic> typeReglement) {
try {
final bool hasName = passage.containsKey('name') &&
(passage['name'] as String?).toString().isNotEmpty;
final double amount =
passage.containsKey('amount') ? passage['amount'] as double : 0.0;
final bool hasValidAmount = amount > 0;
final bool isTypeEffectue = passage.containsKey('type') &&
passage['type'] == 1; // Type 1 = Effectué
final bool isOwnedByCurrentUser = _isPassageOwnedByCurrentUser(passage);
// Déterminer si nous sommes dans une page admin (pas de filterByUserId)
final bool isAdminPage = widget.filterByUserId == null;
// Dans les pages admin, tous les passages sont affichés normalement
// Dans les pages user, seuls les passages de l'utilisateur courant sont affichés normalement
final bool shouldGreyOut = !isAdminPage && !isOwnedByCurrentUser;
// Définir des styles différents en fonction du propriétaire du passage et du type de page
final TextStyle? baseTextStyle = shouldGreyOut
? theme.textTheme.bodyMedium
?.copyWith(color: theme.colorScheme.onSurface.withOpacity(0.5))
: theme.textTheme.bodyMedium;
return Row(
children: [
// Partie gauche: Date et informations
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Date (toujours affichée)
Row(
children: [
Icon(
Icons.calendar_today,
size: 16,
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
const SizedBox(width: 4),
Text(
passage.containsKey('date')
? dateFormat.format(passage['date'] as DateTime)
: 'Date non disponible',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
),
],
),
const SizedBox(height: 4),
// Ligne avec nom, montant et type de règlement
Row(
children: [
// Nom (si connu)
if (hasName)
Flexible(
child: Text(
passage['name'] as String,
style: baseTextStyle,
overflow: TextOverflow.ellipsis,
),
),
// Montant et type de règlement (si montant > 0)
if (hasValidAmount) ...[
const SizedBox(width: 8),
Icon(
Icons.euro,
size: 16,
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
const SizedBox(width: 4),
Text(
'${passage['amount']}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.6),
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 8),
// Type de règlement
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Color(typeReglement['couleur'] as int)
.withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
),
child: Text(
typeReglement['titre'] as String,
style: TextStyle(
color: Color(typeReglement['couleur'] as int),
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
),
],
],
),
],
),
),
// Partie droite: Boutons d'action
if (widget.showActions) ...[
// Bouton Reçu (pour les passages de type 1 - Effectué)
// Dans la page admin, afficher pour tous les passages
// Dans la page user, uniquement pour les passages de l'utilisateur courant
if (isTypeEffectue &&
widget.onReceiptView != null &&
(isAdminPage || isOwnedByCurrentUser))
IconButton(
icon: const Icon(Icons.picture_as_pdf, color: Colors.green),
tooltip: 'Reçu',
onPressed: () => widget.onReceiptView!(passage),
constraints: const BoxConstraints(),
padding: const EdgeInsets.all(8.0),
iconSize: 20,
),
// Bouton Modifier
// Dans la page admin, afficher pour tous les passages
// Dans la page user, uniquement pour les passages de l'utilisateur courant
if (widget.onPassageEdit != null &&
(isAdminPage || isOwnedByCurrentUser))
IconButton(
icon: const Icon(Icons.edit, color: Colors.blue),
tooltip: 'Modifier',
onPressed: () => widget.onPassageEdit!(passage),
constraints: const BoxConstraints(),
padding: const EdgeInsets.all(8.0),
iconSize: 20,
),
],
],
);
} catch (e) {
debugPrint(
'Erreur lors de la construction de la ligne d\'informations du passage: $e');
return const SizedBox();
}
}
// Construction d'une carte pour un passage
Widget _buildPassageCard(
Map<String, dynamic> passage, ThemeData theme, bool isDesktop) {
try {
// Vérification des données et valeurs par défaut
final int type = passage.containsKey('type') ? passage['type'] as int : 0;
final Map<String, dynamic> typePassage =
AppKeys.typesPassages[type] ?? AppKeys.typesPassages[1]!;
final int paymentType =
passage.containsKey('payment') ? passage['payment'] as int : 0;
final Map<String, dynamic> typeReglement =
AppKeys.typesReglements[paymentType] ?? AppKeys.typesReglements[0]!;
final DateFormat dateFormat = DateFormat('dd/MM/yyyy HH:mm');
final bool isOwnedByCurrentUser = _isPassageOwnedByCurrentUser(passage);
// Déterminer si nous sommes dans une page admin (pas de filterByUserId)
final bool isAdminPage = widget.filterByUserId == null;
// Dans les pages admin, tous les passages sont affichés normalement
// Dans les pages user, seuls les passages de l'utilisateur courant sont affichés normalement
final bool shouldGreyOut = !isAdminPage && !isOwnedByCurrentUser;
final bool isClickable = isAdminPage || isOwnedByCurrentUser;
return Card(
margin: const EdgeInsets.only(bottom: 8),
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
// Appliquer une couleur grisée uniquement dans les pages user et si le passage n'appartient pas à l'utilisateur courant
color: shouldGreyOut
? theme.colorScheme.surface.withOpacity(0.7)
: theme.colorScheme.surface,
child: InkWell(
// Rendre le passage cliquable uniquement s'il appartient à l'utilisateur courant
// ou si nous sommes dans la page admin
onTap: isClickable && widget.onPassageSelected != null
? () => widget.onPassageSelected!(passage)
: null,
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Icône du type de passage
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: Color(typePassage['couleur1'] as int)
.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
typePassage['icon_data'] as IconData,
color: Color(typePassage['couleur1'] as int),
),
),
const SizedBox(width: 10),
// Informations principales
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
passage['address'] as String,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Color(typePassage['couleur1'] as int)
.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
typePassage['titre'] as String,
style: TextStyle(
color:
Color(typePassage['couleur1'] as int),
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
],
),
const SizedBox(height: 2),
// Utilisation du widget de ligne d'informations pour tous les types de passages
_buildPassageInfoRow(
passage, theme, dateFormat, typeReglement),
],
),
),
],
),
if (passage['notes'] != null &&
passage['notes'].toString().isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 6.0),
child: Text(
'Notes: ${passage['notes']}',
style: theme.textTheme.bodyMedium?.copyWith(
fontStyle: FontStyle.italic,
color: theme.colorScheme.onSurface.withOpacity(0.7),
),
),
),
// Indicateur d'erreur (si présent)
if (passage['hasError'] == true)
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Row(
children: [
Icon(
Icons.error_outline,
color: Colors.red,
size: 16,
),
const SizedBox(width: 4),
Text(
'Erreur détectée',
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.red,
),
),
],
),
),
],
),
),
),
);
} catch (e) {
debugPrint('Erreur lors de la construction de la carte de passage: $e');
return const SizedBox();
}
}
// Construction d'un filtre déroulant (version standard)
Widget _buildDropdownFilter(
String label,
String selectedValue,
List<String> options,
Function(String) onChanged,
ThemeData theme,
) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
decoration: BoxDecoration(
border: Border.all(color: theme.colorScheme.outline),
borderRadius: BorderRadius.circular(8.0),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: selectedValue,
isExpanded: true,
icon: const Icon(Icons.arrow_drop_down),
items: options.map((String value) {
return DropdownMenuItem<String>(
value: value,
child: Text(
value,
style: theme.textTheme.bodyMedium,
overflow: TextOverflow.ellipsis,
),
);
}).toList(),
onChanged: (String? value) {
if (value != null) {
onChanged(value);
}
},
),
),
),
],
);
}
// Construction d'un filtre déroulant (version compacte)
Widget _buildCompactDropdownFilter(
String label,
String selectedValue,
List<String> options,
Function(String) onChanged,
ThemeData theme,
) {
return Row(
children: [
Text(
'$label:',
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 8),
Expanded(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
decoration: BoxDecoration(
border: Border.all(color: theme.colorScheme.outline),
borderRadius: BorderRadius.circular(8.0),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: selectedValue,
isExpanded: true,
icon: const Icon(Icons.arrow_drop_down),
items: options.map((String value) {
return DropdownMenuItem<String>(
value: value,
child: Text(
value,
style: theme.textTheme.bodyMedium,
overflow: TextOverflow.ellipsis,
),
);
}).toList(),
onChanged: (String? value) {
if (value != null) {
onChanged(value);
}
},
),
),
),
),
],
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final size = MediaQuery.of(context).size;
final isDesktop = size.width > 900;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Titre (si fourni)
if (widget.title != null)
Padding(
padding: const EdgeInsets.all(10.0),
child: Text(
widget.title!,
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
),
),
// Filtres (si activés)
if (widget.showFilters) _buildFilters(theme, isDesktop),
// Liste des passages dans une card de hauteur fixe avec défilement
Container(
height: 600, // Hauteur fixe de 600px
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: theme.shadowColor.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: _filteredPassages.isEmpty
? Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.search_off,
size: 64,
color: theme.colorScheme.onSurface.withOpacity(0.3),
),
const SizedBox(height: 16),
Text(
'Aucun passage trouvé',
style: theme.textTheme.titleLarge?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.5),
),
),
const SizedBox(height: 8),
Text(
'Essayez de modifier vos filtres de recherche',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.5),
),
),
],
),
),
)
: ListView.builder(
padding: const EdgeInsets.all(16.0),
itemCount: _filteredPassages.length,
itemBuilder: (context, index) {
final passage = _filteredPassages[index];
return _buildPassageCard(passage, theme, isDesktop);
},
),
),
],
);
}
// Construction du panneau de filtres
Widget _buildFilters(ThemeData theme, bool isDesktop) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
color: theme.colorScheme.surface,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (isDesktop)
// Version compacte pour le web (desktop)
Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Barre de recherche (si activée)
if (widget.showSearch)
Expanded(
flex: 2,
child: Padding(
padding: const EdgeInsets.only(right: 16.0),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Rechercher par adresse ou nom...',
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8.0),
borderSide: BorderSide(
color: theme.colorScheme.outline,
width: 1.0,
),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16.0, vertical: 14.0),
),
onChanged: (value) {
setState(() {
_searchQuery = value;
});
},
),
),
),
// Filtre par type de passage
Expanded(
child: Padding(
padding: const EdgeInsets.only(right: 16.0),
child: _buildCompactDropdownFilter(
'Type',
_selectedTypeFilter,
[
'Tous',
...AppKeys.typesPassages.values
.map((type) => type['titre'] as String)
],
(value) {
setState(() {
_selectedTypeFilter = value;
});
},
theme,
),
),
),
// Filtre par type de règlement
Expanded(
child: _buildCompactDropdownFilter(
'Règlement',
_selectedPaymentFilter,
[
'Tous',
...AppKeys.typesReglements.values
.map((type) => type['titre'] as String)
],
(value) {
setState(() {
_selectedPaymentFilter = value;
});
},
theme,
),
),
],
),
)
else
// Version mobile (non-desktop)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Barre de recherche (si activée)
if (widget.showSearch)
Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Rechercher par adresse ou nom...',
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
setState(() {
_searchQuery = '';
});
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8.0),
borderSide: BorderSide(
color: theme.colorScheme.outline,
width: 1.0,
),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16.0, vertical: 14.0),
),
onChanged: (value) {
setState(() {
_searchQuery = value;
});
},
),
),
// Filtres
Row(
children: [
// Filtre par type de passage
Expanded(
child: Padding(
padding: const EdgeInsets.only(right: 8.0),
child: _buildDropdownFilter(
'Type',
_selectedTypeFilter,
[
'Tous',
...AppKeys.typesPassages.values
.map((type) => type['titre'] as String)
],
(value) {
setState(() {
_selectedTypeFilter = value;
});
},
theme,
),
),
),
// Filtre par type de règlement
Expanded(
child: _buildDropdownFilter(
'Règlement',
_selectedPaymentFilter,
[
'Tous',
...AppKeys.typesReglements.values
.map((type) => type['titre'] as String)
],
(value) {
setState(() {
_selectedPaymentFilter = value;
});
},
theme,
),
),
],
),
],
),
],
),
);
}
}