feat: Début des évolutions interfaces mobiles v3.2.4

- Préparation de la nouvelle branche pour les évolutions
- Mise à jour de la version vers 3.2.4
- Intégration des modifications en cours

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-09-04 16:49:29 +02:00
parent 2187dccfeb
commit 2786252307
86 changed files with 3434 additions and 180898 deletions

View File

@@ -6,6 +6,8 @@ import 'package:geosector_app/core/services/current_amicale_service.dart';
import 'package:geosector_app/core/utils/api_exception.dart';
import 'package:geosector_app/app.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/user_model.dart';
import 'package:geosector_app/presentation/widgets/passage_form_dialog.dart';
import 'package:hive_flutter/hive_flutter.dart';
@@ -25,6 +27,13 @@ class PassagesListWidget extends StatefulWidget {
/// Si vrai, la barre de recherche sera affichée
final bool showSearch;
/// Contrôle de l'affichage des filtres individuels
final bool showTypeFilter;
final bool showPaymentFilter;
final bool showSectorFilter;
final bool showUserFilter;
final bool showPeriodFilter;
/// Si vrai, les boutons d'action (détails, modifier, etc.) seront affichés
final bool showActions;
@@ -76,6 +85,18 @@ class PassagesListWidget extends StatefulWidget {
/// Callback appelé lorsque le bouton d'ajout est cliqué
final VoidCallback? onAddPassage;
/// Données pour les filtres avancés
final List<SectorModel>? sectors;
final List<UserModel>? members;
/// Valeurs initiales pour les filtres avancés
final int? initialSectorId;
final int? initialUserId;
final String? initialPeriod;
/// Callback appelé lorsque les filtres changent
final Function(Map<String, dynamic>)? onFiltersChanged;
const PassagesListWidget({
super.key,
@@ -85,6 +106,11 @@ class PassagesListWidget extends StatefulWidget {
this.showFilters = true,
this.showSearch = true,
this.showActions = true,
this.showTypeFilter = true,
this.showPaymentFilter = true,
this.showSectorFilter = false,
this.showUserFilter = false,
this.showPeriodFilter = false,
this.onPassageSelected,
this.onPassageEdit,
this.onReceiptView,
@@ -102,6 +128,12 @@ class PassagesListWidget extends StatefulWidget {
this.sortingButtons,
this.showAddButton = false,
this.onAddPassage,
this.sectors,
this.members,
this.initialSectorId,
this.initialUserId,
this.initialPeriod,
this.onFiltersChanged,
});
@override
@@ -113,6 +145,10 @@ class _PassagesListWidgetState extends State<PassagesListWidget> {
late String _selectedTypeFilter;
late String _selectedPaymentFilter;
late String _searchQuery;
late int? _selectedSectorId;
late int? _selectedUserId;
late String _selectedPeriod;
DateTimeRange? _selectedDateRange;
// Contrôleur de recherche
final TextEditingController _searchController = TextEditingController();
@@ -121,10 +157,29 @@ class _PassagesListWidgetState extends State<PassagesListWidget> {
void initState() {
super.initState();
// Initialiser les filtres
_selectedTypeFilter = widget.initialTypeFilter ?? 'Tous';
_selectedPaymentFilter = widget.initialPaymentFilter ?? 'Tous';
_selectedTypeFilter = widget.initialTypeFilter ?? 'Tous les types';
_selectedPaymentFilter = widget.initialPaymentFilter ?? 'Tous les règlements';
_searchQuery = widget.initialSearchQuery ?? '';
_searchController.text = _searchQuery;
_selectedSectorId = widget.initialSectorId;
_selectedUserId = widget.initialUserId;
_selectedPeriod = widget.initialPeriod ?? 'Toutes les périodes';
_selectedDateRange = widget.dateRange;
}
// Notifier les changements de filtres
void _notifyFiltersChanged() {
if (widget.onFiltersChanged != null) {
widget.onFiltersChanged!({
'typeFilter': _selectedTypeFilter,
'paymentFilter': _selectedPaymentFilter,
'searchQuery': _searchQuery,
'sectorId': _selectedSectorId,
'userId': _selectedUserId,
'period': _selectedPeriod,
'dateRange': _selectedDateRange,
});
}
}
// Vérifier si l'amicale autorise la suppression des passages
@@ -204,13 +259,13 @@ class _PassagesListWidgetState extends State<PassagesListWidget> {
width: 40,
height: 40,
decoration: BoxDecoration(
color: Color(typeInfo?['couleur1'] ?? Colors.blue.value)
color: Color(typeInfo?['couleur1'] ?? Colors.blue.toARGB32())
.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
typeInfo?['icon_data'] ?? Icons.receipt_long,
color: Color(typeInfo?['couleur1'] ?? Colors.blue.value),
color: Color(typeInfo?['couleur1'] ?? Colors.blue.toARGB32()),
size: 24,
),
),
@@ -231,7 +286,7 @@ class _PassagesListWidgetState extends State<PassagesListWidget> {
horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color:
Color(typeInfo?['couleur1'] ?? Colors.blue.value)
Color(typeInfo?['couleur1'] ?? Colors.blue.toARGB32())
.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
@@ -239,7 +294,7 @@ class _PassagesListWidgetState extends State<PassagesListWidget> {
typeInfo?['titre'] ?? 'Inconnu',
style: TextStyle(
color: Color(
typeInfo?['couleur1'] ?? Colors.blue.value),
typeInfo?['couleur1'] ?? Colors.blue.toARGB32()),
fontSize: AppTheme.r(context, 12),
fontWeight: FontWeight.w600,
),
@@ -323,7 +378,7 @@ class _PassagesListWidgetState extends State<PassagesListWidget> {
horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Color(paymentInfo?['couleur'] ??
Colors.grey.value)
Colors.grey.toARGB32())
.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(6),
),
@@ -331,7 +386,7 @@ class _PassagesListWidgetState extends State<PassagesListWidget> {
paymentInfo?['titre'] ?? 'Inconnu',
style: TextStyle(
color: Color(paymentInfo?['couleur'] ??
Colors.grey.value),
Colors.grey.toARGB32()),
fontSize: AppTheme.r(context, 12),
fontWeight: FontWeight.w600,
),
@@ -749,14 +804,58 @@ class _PassagesListWidgetState extends State<PassagesListWidget> {
}
// Filtrer par secteur
if (widget.filterBySectorId != null &&
if (_selectedSectorId != null &&
passage.containsKey('fkSector') &&
passage['fkSector'] != widget.filterBySectorId) {
passage['fkSector'] != _selectedSectorId) {
return false;
}
// Filtrer par membre/utilisateur
if (_selectedUserId != null &&
passage.containsKey('fkUser') &&
passage['fkUser'] != _selectedUserId) {
// Les passages de type 2 sont partagés
if (passage.containsKey('type') && passage['type'] == 2) {
// Ne pas filtrer les passages type 2
} else {
return false;
}
}
// Filtrer par période
if (_selectedPeriod != 'Toutes les périodes' && passage.containsKey('date')) {
final DateTime passageDate = passage['date'] as DateTime;
final DateTime now = DateTime.now();
switch (_selectedPeriod) {
case 'Dernières 24h':
if (now.difference(passageDate).inHours > 24) return false;
break;
case 'Dernières 48h':
if (now.difference(passageDate).inHours > 48) return false;
break;
case 'Derniers 7 jours':
if (now.difference(passageDate).inDays > 7) return false;
break;
case 'Derniers 15 jours':
if (now.difference(passageDate).inDays > 15) return false;
break;
case 'Dernier mois':
if (now.difference(passageDate).inDays > 30) return false;
break;
case 'Personnalisée':
if (_selectedDateRange != null) {
if (passageDate.isBefore(_selectedDateRange!.start) ||
passageDate.isAfter(_selectedDateRange!.end.add(const Duration(days: 1)))) {
return false;
}
}
break;
}
}
// Filtre par type
if (_selectedTypeFilter != 'Tous') {
if (_selectedTypeFilter != 'Tous les types') {
try {
final typeEntries = AppKeys.typesPassages.entries.where(
(entry) => entry.value['titre'] == _selectedTypeFilter);
@@ -774,7 +873,7 @@ class _PassagesListWidgetState extends State<PassagesListWidget> {
}
// Filtre par type de règlement
if (_selectedPaymentFilter != 'Tous') {
if (_selectedPaymentFilter != 'Tous les règlements') {
try {
final paymentEntries = AppKeys.typesReglements.entries.where(
(entry) => entry.value['titre'] == _selectedPaymentFilter);
@@ -1043,9 +1142,17 @@ class _PassagesListWidgetState extends State<PassagesListWidget> {
// 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;
// Dimensions responsives
final screenWidth = MediaQuery.of(context).size.width;
final bool isMobile = screenWidth < 600;
final cardMargin = isMobile ? 4.0 : 6.0;
final horizontalPadding = isMobile ? 10.0 : 12.0;
final verticalPadding = isMobile ? 8.0 : 10.0;
final iconSize = isMobile ? 32.0 : 36.0;
return Card(
margin: const EdgeInsets.only(bottom: 6), // Réduit de 8 à 6
margin: EdgeInsets.only(bottom: cardMargin),
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
@@ -1059,8 +1166,9 @@ class _PassagesListWidgetState extends State<PassagesListWidget> {
onTap: isClickable ? () => _handlePassageClick(passage) : null,
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12.0, vertical: 10.0), // Réduit de 16 à 12/10
padding: EdgeInsets.symmetric(
horizontal: horizontalPadding,
vertical: verticalPadding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -1069,8 +1177,8 @@ class _PassagesListWidgetState extends State<PassagesListWidget> {
children: [
// Icône du type de passage avec bordure couleur2
Container(
width: 36, // Réduit de 40 à 36
height: 36,
width: iconSize,
height: iconSize,
decoration: BoxDecoration(
color: Color(typePassage['couleur1'] as int)
.withValues(alpha: 0.1),
@@ -1296,13 +1404,15 @@ class _PassagesListWidgetState extends State<PassagesListWidget> {
) {
return Row(
children: [
Text(
'$label:',
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.bold,
if (label.isNotEmpty) ...[
Text(
'$label:',
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 8),
const SizedBox(width: 8),
],
Expanded(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
@@ -1337,6 +1447,190 @@ class _PassagesListWidgetState extends State<PassagesListWidget> {
],
);
}
// Construction du filtre de secteur
Widget _buildSectorFilter(ThemeData theme, bool isCompact) {
if (widget.sectors == null || widget.sectors!.isEmpty) {
return const SizedBox();
}
final options = ['Tous les secteurs'] +
widget.sectors!.map((s) => s.libelle).toList();
final selectedValue = _selectedSectorId == null
? 'Tous les secteurs'
: () {
final sector = widget.sectors!.firstWhere((s) => s.id == _selectedSectorId,
orElse: () => widget.sectors!.first);
return sector.libelle;
}();
return isCompact
? _buildCompactDropdownFilter(
'Secteur',
selectedValue,
options,
(value) {
setState(() {
if (value == 'Tous les secteurs') {
_selectedSectorId = null;
} else {
_selectedSectorId = widget.sectors!.firstWhere((s) => s.libelle == value).id;
}
_notifyFiltersChanged();
});
},
theme,
)
: _buildDropdownFilter(
'',
selectedValue,
options,
(value) {
setState(() {
if (value == 'Tous les secteurs') {
_selectedSectorId = null;
} else {
_selectedSectorId = widget.sectors!.firstWhere((s) => s.libelle == value).id;
}
_notifyFiltersChanged();
});
},
theme,
);
}
// Construction du filtre de membre/utilisateur
Widget _buildUserFilter(ThemeData theme, bool isCompact) {
if (widget.members == null || widget.members!.isEmpty) {
return const SizedBox();
}
final options = ['Tous les membres'] +
widget.members!.map((u) => '${u.firstName} ${u.name}'.trim()).toList();
final selectedValue = _selectedUserId == null
? 'Tous les membres'
: () {
final user = widget.members!.firstWhere((u) => u.id == _selectedUserId,
orElse: () => widget.members!.first);
return '${user.firstName} ${user.name}'.trim();
}();
return isCompact
? _buildCompactDropdownFilter(
'Membre',
selectedValue,
options,
(value) {
setState(() {
if (value == 'Tous les membres') {
_selectedUserId = null;
} else {
_selectedUserId = widget.members!.firstWhere(
(u) => '${u.firstName} ${u.name}'.trim() == value
).id;
}
_notifyFiltersChanged();
});
},
theme,
)
: _buildDropdownFilter(
'',
selectedValue,
options,
(value) {
setState(() {
if (value == 'Tous les membres') {
_selectedUserId = null;
} else {
_selectedUserId = widget.members!.firstWhere(
(u) => '${u.firstName} ${u.name}'.trim() == value
).id;
}
_notifyFiltersChanged();
});
},
theme,
);
}
// Construction du filtre de période
Widget _buildPeriodFilter(ThemeData theme, bool isCompact) {
final options = [
'Toutes les périodes',
'Dernières 24h',
'Dernières 48h',
'Derniers 7 jours',
'Derniers 15 jours',
'Dernier mois',
];
if (_selectedDateRange != null && _selectedPeriod == 'Personnalisée') {
options.add('Personnalisée');
}
return isCompact
? _buildCompactDropdownFilter(
'Période',
_selectedPeriod,
options,
(value) async {
if (value == 'Personnalisée') {
final picked = await showDateRangePicker(
context: context,
firstDate: DateTime.now().subtract(const Duration(days: 365)),
lastDate: DateTime.now(),
initialDateRange: _selectedDateRange,
);
if (picked != null) {
setState(() {
_selectedDateRange = picked;
_selectedPeriod = 'Personnalisée';
_notifyFiltersChanged();
});
}
} else {
setState(() {
_selectedPeriod = value;
_selectedDateRange = null;
_notifyFiltersChanged();
});
}
},
theme,
)
: _buildDropdownFilter(
'',
_selectedPeriod,
options,
(value) async {
if (value == 'Personnalisée') {
final picked = await showDateRangePicker(
context: context,
firstDate: DateTime.now().subtract(const Duration(days: 365)),
lastDate: DateTime.now(),
initialDateRange: _selectedDateRange,
);
if (picked != null) {
setState(() {
_selectedDateRange = picked;
_selectedPeriod = 'Personnalisée';
_notifyFiltersChanged();
});
}
} else {
setState(() {
_selectedPeriod = value;
_selectedDateRange = null;
_notifyFiltersChanged();
});
}
},
theme,
);
}
@override
Widget build(BuildContext context) {
@@ -1536,185 +1830,236 @@ class _PassagesListWidgetState extends State<PassagesListWidget> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (isDesktop)
// Version compacte pour le web (desktop)
// Barre de recherche (si activée) - toujours en premier
if (widget.showSearch)
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),
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) {
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 = value;
_searchQuery = '';
_notifyFiltersChanged();
});
},
)
: 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;
_notifyFiltersChanged();
});
},
),
),
if (isDesktop)
// Version compacte pour le web (desktop)
Column(
children: [
// Première ligne : Type, Règlement, Secteur
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Filtre par type de passage
if (widget.showTypeFilter)
Expanded(
child: Padding(
padding: const EdgeInsets.only(right: 16.0),
child: _buildCompactDropdownFilter(
'Type',
_selectedTypeFilter,
[
'Tous les types',
...AppKeys.typesPassages.values
.map((type) => type['titre'] as String)
],
(value) {
setState(() {
_selectedTypeFilter = value;
_notifyFiltersChanged();
});
},
theme,
),
),
),
),
// 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
if (widget.showPaymentFilter)
Expanded(
child: Padding(
padding: const EdgeInsets.only(right: 16.0),
child: _buildCompactDropdownFilter(
'Règlement',
_selectedPaymentFilter,
[
'Tous les règlements',
...AppKeys.typesReglements.values
.map((type) => type['titre'] as String)
],
(value) {
setState(() {
_selectedPaymentFilter = value;
_notifyFiltersChanged();
});
},
theme,
),
),
),
),
),
// Filtre par type de règlement
Expanded(
child: _buildCompactDropdownFilter(
'Règlement',
_selectedPaymentFilter,
[
'Tous',
...AppKeys.typesReglements.values
.map((type) => type['titre'] as String)
// Filtre par secteur
if (widget.showSectorFilter && widget.sectors != null)
Expanded(
child: Padding(
padding: const EdgeInsets.only(right: 16.0),
child: _buildSectorFilter(theme, true),
),
),
],
),
// Deuxième ligne : Membre et Période (si nécessaire)
if (widget.showUserFilter || widget.showPeriodFilter)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Filtre par membre
if (widget.showUserFilter && widget.members != null)
Expanded(
child: Padding(
padding: const EdgeInsets.only(right: 16.0),
child: _buildUserFilter(theme, true),
),
),
// Filtre par période
if (widget.showPeriodFilter)
Expanded(
child: Padding(
padding: const EdgeInsets.only(right: 16.0),
child: _buildPeriodFilter(theme, true),
),
),
// Spacer si un seul filtre sur la deuxième ligne
if ((widget.showUserFilter && !widget.showPeriodFilter) ||
(!widget.showUserFilter && widget.showPeriodFilter))
const Expanded(child: SizedBox()),
],
(value) {
setState(() {
_selectedPaymentFilter = value;
});
},
theme,
),
),
],
),
],
)
else
// Version mobile (non-desktop)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Barre de recherche (si activée)
if (widget.showSearch)
// Première ligne : Type et Règlement
if (widget.showTypeFilter || widget.showPaymentFilter)
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();
padding: const EdgeInsets.only(bottom: 8.0),
child: Row(
children: [
// Filtre par type de passage
if (widget.showTypeFilter)
Expanded(
child: Padding(
padding: const EdgeInsets.only(right: 8.0),
child: _buildDropdownFilter(
'',
_selectedTypeFilter,
[
'Tous les types',
...AppKeys.typesPassages.values
.map((type) => type['titre'] as String)
],
(value) {
setState(() {
_searchQuery = '';
_selectedTypeFilter = value;
_notifyFiltersChanged();
});
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8.0),
borderSide: BorderSide(
color: theme.colorScheme.outline,
width: 1.0,
theme,
),
),
),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16.0, vertical: 14.0),
),
onChanged: (value) {
setState(() {
_searchQuery = value;
});
},
// Filtre par type de règlement
if (widget.showPaymentFilter)
Expanded(
child: _buildDropdownFilter(
'',
_selectedPaymentFilter,
[
'Tous les règlements',
...AppKeys.typesReglements.values
.map((type) => type['titre'] as String)
],
(value) {
setState(() {
_selectedPaymentFilter = value;
_notifyFiltersChanged();
});
},
theme,
),
),
],
),
),
// 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,
),
),
// Deuxième ligne : Secteur et Période
if (widget.showSectorFilter || widget.showPeriodFilter)
Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Row(
children: [
// Filtre par secteur
if (widget.showSectorFilter && widget.sectors != null)
Expanded(
child: Padding(
padding: const EdgeInsets.only(right: 8.0),
child: _buildSectorFilter(theme, false),
),
),
// Filtre par période
if (widget.showPeriodFilter)
Expanded(
child: _buildPeriodFilter(theme, false),
),
],
),
// 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,
),
),
],
),
),
// Troisième ligne : Membre (si nécessaire)
if (widget.showUserFilter && widget.members != null)
Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: _buildUserFilter(theme, false),
),
],
),
],