Initialisation du projet geosector complet (web + flutter)
This commit is contained in:
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user