Restructuration majeure du projet: migration de flutt vers app, ajout de l'API et mise à jour du site web
This commit is contained in:
167
app/lib/presentation/widgets/amicale_row_widget.dart
Normal file
167
app/lib/presentation/widgets/amicale_row_widget.dart
Normal file
@@ -0,0 +1,167 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/app.dart';
|
||||
import 'package:geosector_app/core/data/models/amicale_model.dart';
|
||||
|
||||
/// Widget pour afficher une ligne du tableau d'amicales
|
||||
/// Affiche les colonnes id, name, codePostal, libRegion et une colonne Actions
|
||||
/// La colonne Actions contient un bouton Delete pour les utilisateurs avec rôle > 2
|
||||
/// La ligne entière est cliquable pour afficher les détails de l'amicale
|
||||
class AmicaleRowWidget extends StatelessWidget {
|
||||
final AmicaleModel amicale;
|
||||
final Function(AmicaleModel)? onTap;
|
||||
final Function(AmicaleModel)? onDelete;
|
||||
final bool isHeader;
|
||||
final bool isAlternate;
|
||||
|
||||
const AmicaleRowWidget({
|
||||
Key? key,
|
||||
required this.amicale,
|
||||
this.onTap,
|
||||
this.onDelete,
|
||||
this.isHeader = false,
|
||||
this.isAlternate = false,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final userRole = userRepository.getUserRole();
|
||||
|
||||
// Définir les styles en fonction du type de ligne (en-tête ou données)
|
||||
final textStyle = isHeader
|
||||
? theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.primary,
|
||||
)
|
||||
: theme.textTheme.bodyMedium;
|
||||
|
||||
// Couleur de fond en fonction du type de ligne
|
||||
final backgroundColor = isHeader
|
||||
? theme.colorScheme.primary.withOpacity(0.1)
|
||||
: (isAlternate
|
||||
? theme.colorScheme.surface
|
||||
: theme.colorScheme.background);
|
||||
|
||||
return InkWell(
|
||||
onTap: isHeader || onTap == null ? null : () => onTap!(amicale),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: theme.dividerColor.withOpacity(0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
// Colonne ID
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: Text(
|
||||
isHeader ? 'ID' : amicale.id.toString(),
|
||||
style: textStyle,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Colonne Nom
|
||||
Expanded(
|
||||
flex: 4,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: Text(
|
||||
isHeader ? 'Nom' : amicale.name,
|
||||
style: textStyle,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Colonne Code Postal
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: Text(
|
||||
isHeader ? 'Code Postal' : amicale.codePostal,
|
||||
style: textStyle,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Colonne Ville
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: Text(
|
||||
isHeader ? 'Ville' : (amicale.ville ?? ''),
|
||||
style: textStyle,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Colonne Région
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: Text(
|
||||
isHeader ? 'Région' : (amicale.libRegion ?? ''),
|
||||
style: textStyle,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Colonne Actions - seulement si l'utilisateur a le rôle > 2 et onDelete n'est pas null
|
||||
if (isHeader || (userRole > 2 && onDelete != null))
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: isHeader
|
||||
? Text(
|
||||
'Actions',
|
||||
style: textStyle,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
)
|
||||
: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
// Bouton Delete
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.delete,
|
||||
color: theme.colorScheme.error,
|
||||
size: 20,
|
||||
),
|
||||
tooltip: 'Supprimer',
|
||||
onPressed: () => onDelete!(amicale),
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 36,
|
||||
minHeight: 36,
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
184
app/lib/presentation/widgets/amicale_table_widget.dart
Normal file
184
app/lib/presentation/widgets/amicale_table_widget.dart
Normal file
@@ -0,0 +1,184 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/app.dart';
|
||||
import 'package:geosector_app/core/data/models/amicale_model.dart';
|
||||
import 'package:geosector_app/core/repositories/region_repository.dart';
|
||||
import 'package:geosector_app/core/repositories/user_repository.dart';
|
||||
import 'package:geosector_app/presentation/widgets/amicale_row_widget.dart';
|
||||
import 'package:geosector_app/presentation/widgets/entite_form.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
/// Widget de tableau pour afficher une liste d'amicales
|
||||
///
|
||||
/// Ce widget affiche un tableau avec les colonnes :
|
||||
/// - ID
|
||||
/// - Nom
|
||||
/// - Code Postal
|
||||
/// - Région
|
||||
/// - Actions (boutons selon les droits de l'utilisateur)
|
||||
///
|
||||
/// Lorsqu'on clique sur une ligne, une modale s'affiche avec le formulaire EntiteForm
|
||||
class AmicaleTableWidget extends StatelessWidget {
|
||||
final List<AmicaleModel> amicales;
|
||||
final Function(AmicaleModel)? onDelete;
|
||||
final bool isLoading;
|
||||
final String? emptyMessage;
|
||||
final bool readOnly;
|
||||
|
||||
const AmicaleTableWidget({
|
||||
Key? key,
|
||||
required this.amicales,
|
||||
this.onDelete,
|
||||
this.isLoading = false,
|
||||
this.emptyMessage,
|
||||
this.readOnly = false,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// En-tête du tableau - utiliser AmicaleRowWidget pour l'en-tête
|
||||
AmicaleRowWidget(
|
||||
amicale: AmicaleModel(
|
||||
id: 0,
|
||||
name: '',
|
||||
codePostal: '',
|
||||
ville: '',
|
||||
libRegion: '',
|
||||
),
|
||||
isHeader: true,
|
||||
onTap: null,
|
||||
onDelete: null,
|
||||
),
|
||||
|
||||
// Corps du tableau
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
borderRadius: const BorderRadius.only(
|
||||
bottomLeft: Radius.circular(8),
|
||||
bottomRight: Radius.circular(8),
|
||||
),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.primary.withOpacity(0.1),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: _buildTableContent(context),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTableContent(BuildContext context) {
|
||||
// Afficher un indicateur de chargement si isLoading est true
|
||||
if (isLoading) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 32.0),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
// Afficher un message si la liste est vide
|
||||
if (amicales.isEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 32.0),
|
||||
child: Center(
|
||||
child: Text(
|
||||
emptyMessage ?? 'Aucune amicale trouvée',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color:
|
||||
Theme.of(context).colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Afficher la liste des amicales
|
||||
return ListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: amicales.length,
|
||||
itemBuilder: (context, index) {
|
||||
final amicale = amicales[index];
|
||||
return AmicaleRowWidget(
|
||||
amicale: amicale,
|
||||
isAlternate: index % 2 == 1, // Alterner les couleurs
|
||||
onTap: (selectedAmicale) =>
|
||||
_showAmicaleDetails(context, selectedAmicale),
|
||||
onDelete: onDelete,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Afficher une modale avec le formulaire EntiteForm
|
||||
void _showAmicaleDetails(BuildContext context, AmicaleModel amicale) {
|
||||
// Utiliser l'instance globale de userRepository définie dans app.dart
|
||||
final userRepo = userRepository;
|
||||
// Créer une instance de RegionRepository
|
||||
final regionRepo = RegionRepository();
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogContext) => MultiProvider(
|
||||
providers: [
|
||||
// Fournir les repositories nécessaires au formulaire
|
||||
Provider<UserRepository>.value(value: userRepo),
|
||||
Provider<RegionRepository>.value(value: regionRepo),
|
||||
],
|
||||
child: Dialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Container(
|
||||
width: MediaQuery.of(dialogContext).size.width * 0.6,
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Détails de l\'amicale',
|
||||
style: Theme.of(dialogContext)
|
||||
.textTheme
|
||||
.headlineSmall
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color:
|
||||
Theme.of(dialogContext).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Formulaire EntiteForm en mode lecture seule
|
||||
EntiteForm(
|
||||
amicale: amicale,
|
||||
readOnly: readOnly,
|
||||
onSubmit: (updatedAmicale) {
|
||||
Navigator.of(dialogContext).pop();
|
||||
// Ici, vous pourriez ajouter une logique pour mettre à jour l'amicale
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
484
app/lib/presentation/widgets/charts/activity_chart.dart
Normal file
484
app/lib/presentation/widgets/charts/activity_chart.dart
Normal file
@@ -0,0 +1,484 @@
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:flutter/foundation.dart' show listEquals;
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:syncfusion_flutter_charts/charts.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/core/repositories/passage_repository.dart';
|
||||
import 'package:geosector_app/core/repositories/user_repository.dart';
|
||||
import 'package:geosector_app/core/services/passage_data_service.dart';
|
||||
|
||||
/// Widget de graphique d'activité affichant les passages
|
||||
class ActivityChart extends StatefulWidget {
|
||||
/// Liste des données de passage par date et type (si fournie directement)
|
||||
/// Format attendu: [{"date": String, "type_passage": int, "nb": int}, ...]
|
||||
final List<Map<String, dynamic>>? passageData;
|
||||
|
||||
/// Type de période (Jour, Semaine, Mois, Année)
|
||||
final String periodType;
|
||||
|
||||
/// Hauteur du graphique
|
||||
final double height;
|
||||
|
||||
/// Nombre de jours à afficher (par défaut 15)
|
||||
final int daysToShow;
|
||||
|
||||
/// ID de l'utilisateur pour filtrer les passages (null = tous les utilisateurs)
|
||||
final int? userId;
|
||||
|
||||
/// Types de passages à exclure (par défaut [2] = "À finaliser")
|
||||
final List<int> excludePassageTypes;
|
||||
|
||||
/// Indique si les données doivent être chargées depuis la Hive box
|
||||
final bool loadFromHive;
|
||||
|
||||
/// Callback appelé lorsque la période change
|
||||
final Function(int days)? onPeriodChanged;
|
||||
|
||||
/// Titre du graphique
|
||||
final String title;
|
||||
|
||||
/// Afficher les étiquettes de valeur
|
||||
final bool showDataLabels;
|
||||
|
||||
/// Largeur des colonnes (en pourcentage)
|
||||
final double columnWidth;
|
||||
|
||||
/// Espacement entre les colonnes (en pourcentage)
|
||||
final double columnSpacing;
|
||||
|
||||
/// Si vrai, n'applique aucun filtrage par utilisateur (affiche tous les passages)
|
||||
final bool showAllPassages;
|
||||
|
||||
/// Si vrai, force le rechargement des données
|
||||
final bool forceRefresh;
|
||||
|
||||
const ActivityChart({
|
||||
super.key,
|
||||
this.passageData,
|
||||
this.periodType = 'Jour',
|
||||
this.height = 350,
|
||||
this.daysToShow = 15,
|
||||
this.userId,
|
||||
this.excludePassageTypes = const [2],
|
||||
this.loadFromHive = false,
|
||||
this.onPeriodChanged,
|
||||
this.title = 'Dernière activité enregistrée sur 15 jours',
|
||||
this.showDataLabels = true,
|
||||
this.columnWidth = 0.8,
|
||||
this.columnSpacing = 0.2,
|
||||
this.showAllPassages = false,
|
||||
this.forceRefresh = false,
|
||||
}) : assert(loadFromHive || passageData != null,
|
||||
'Soit loadFromHive doit être true, soit passageData doit être fourni');
|
||||
|
||||
@override
|
||||
State<ActivityChart> createState() => _ActivityChartState();
|
||||
}
|
||||
|
||||
/// Classe pour stocker les données d'activité par date
|
||||
class ActivityData {
|
||||
final DateTime date;
|
||||
final String dateStr;
|
||||
final Map<int, int> passagesByType;
|
||||
final int totalPassages;
|
||||
|
||||
ActivityData({
|
||||
required this.date,
|
||||
required this.dateStr,
|
||||
required this.passagesByType,
|
||||
}) : totalPassages =
|
||||
passagesByType.values.fold(0, (sum, count) => sum + count);
|
||||
}
|
||||
|
||||
class _ActivityChartState extends State<ActivityChart>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _animationController;
|
||||
|
||||
// Données pour les graphiques
|
||||
List<Map<String, dynamic>> _passageData = [];
|
||||
List<ActivityData> _chartData = [];
|
||||
bool _isLoading = true;
|
||||
bool _hasData = false;
|
||||
bool _dataLoaded = false;
|
||||
|
||||
// Période sélectionnée en jours
|
||||
int _selectedDays = 15;
|
||||
|
||||
// Contrôleur de zoom pour le graphique
|
||||
late ZoomPanBehavior _zoomPanBehavior;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_animationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1500),
|
||||
);
|
||||
|
||||
// Initialiser la période sélectionnée avec la valeur par défaut du widget
|
||||
_selectedDays = widget.daysToShow;
|
||||
|
||||
// Initialiser le contrôleur de zoom
|
||||
_zoomPanBehavior = ZoomPanBehavior(
|
||||
enablePinching: true,
|
||||
enableDoubleTapZooming: true,
|
||||
enablePanning: true,
|
||||
zoomMode: ZoomMode.x,
|
||||
);
|
||||
|
||||
_loadData();
|
||||
_animationController.forward();
|
||||
}
|
||||
|
||||
/// Trouve la date du passage le plus récent
|
||||
DateTime _getMostRecentDate() {
|
||||
final allDates = [
|
||||
..._passageData.map((data) => DateTime.parse(data['date'] as String)),
|
||||
];
|
||||
if (allDates.isEmpty) {
|
||||
return DateTime.now();
|
||||
}
|
||||
return allDates.reduce((a, b) => a.isAfter(b) ? a : b);
|
||||
}
|
||||
|
||||
void _loadData() {
|
||||
// Marquer comme chargé immédiatement pour éviter les appels multiples pendant le chargement
|
||||
// Mais permettre un rechargement ultérieur si nécessaire
|
||||
if (_dataLoaded && _hasData) return;
|
||||
|
||||
_dataLoaded = true;
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
if (widget.loadFromHive) {
|
||||
// Charger les données depuis Hive
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// Éviter de recharger si le widget a été démonté entre-temps
|
||||
if (!mounted) return;
|
||||
|
||||
try {
|
||||
// Utiliser les instances globales définies dans app.dart
|
||||
|
||||
// Créer une instance du service de données
|
||||
final passageDataService = PassageDataService(
|
||||
passageRepository: passageRepository,
|
||||
userRepository: userRepository,
|
||||
);
|
||||
|
||||
// Utiliser le service pour charger les données
|
||||
_passageData = passageDataService.loadPassageData(
|
||||
daysToShow: _selectedDays,
|
||||
excludePassageTypes: widget.excludePassageTypes,
|
||||
userId: widget.userId,
|
||||
showAllPassages: widget.showAllPassages,
|
||||
);
|
||||
|
||||
_prepareChartData();
|
||||
|
||||
// Mettre à jour l'état une seule fois après avoir préparé les données
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_hasData = _chartData.isNotEmpty;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// En cas d'erreur, réinitialiser l'état pour permettre une future tentative
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_hasData = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Utiliser les données fournies directement
|
||||
_passageData = widget.passageData ?? [];
|
||||
_prepareChartData();
|
||||
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_hasData = _chartData.isNotEmpty;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ActivityChart oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
// Vérifier si les propriétés importantes ont changé
|
||||
final bool periodChanged = oldWidget.periodType != widget.periodType ||
|
||||
oldWidget.daysToShow != widget.daysToShow;
|
||||
final bool dataSourceChanged = widget.loadFromHive
|
||||
? false
|
||||
: oldWidget.passageData != widget.passageData;
|
||||
final bool filteringChanged = oldWidget.userId != widget.userId ||
|
||||
!listEquals(
|
||||
oldWidget.excludePassageTypes, widget.excludePassageTypes) ||
|
||||
oldWidget.showAllPassages != widget.showAllPassages;
|
||||
final bool refreshForced = widget.forceRefresh && !oldWidget.forceRefresh;
|
||||
|
||||
// Si des paramètres importants ont changé ou si forceRefresh est passé à true, recharger les données
|
||||
if (periodChanged ||
|
||||
dataSourceChanged ||
|
||||
filteringChanged ||
|
||||
refreshForced) {
|
||||
_selectedDays = widget.daysToShow;
|
||||
_dataLoaded = false; // Réinitialiser l'état pour forcer le rechargement
|
||||
_loadData();
|
||||
}
|
||||
}
|
||||
|
||||
// La méthode _loadPassageDataFromHive a été intégrée directement dans _loadData
|
||||
// pour éviter les appels multiples et les problèmes de cycle de vie
|
||||
|
||||
/// Prépare les données pour le graphique
|
||||
void _prepareChartData() {
|
||||
try {
|
||||
// Vérifier que les données sont disponibles
|
||||
if (_passageData.isEmpty) {
|
||||
_chartData = [];
|
||||
return;
|
||||
}
|
||||
|
||||
// Obtenir toutes les dates uniques
|
||||
final Set<String> uniqueDatesSet = {};
|
||||
for (final data in _passageData) {
|
||||
if (data.containsKey('date') && data['date'] != null) {
|
||||
uniqueDatesSet.add(data['date'] as String);
|
||||
}
|
||||
}
|
||||
|
||||
// Trier les dates
|
||||
final List<String> uniqueDates = uniqueDatesSet.toList();
|
||||
uniqueDates.sort();
|
||||
|
||||
// Créer les données pour chaque date
|
||||
_chartData = [];
|
||||
for (final dateStr in uniqueDates) {
|
||||
final passagesByType = <int, int>{};
|
||||
|
||||
// Initialiser tous les types de passage possibles
|
||||
for (final typeId in AppKeys.typesPassages.keys) {
|
||||
if (!widget.excludePassageTypes.contains(typeId)) {
|
||||
passagesByType[typeId] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Remplir les données de passage
|
||||
for (final data in _passageData) {
|
||||
if (data.containsKey('date') &&
|
||||
data['date'] == dateStr &&
|
||||
data.containsKey('type_passage') &&
|
||||
data.containsKey('nb')) {
|
||||
final typeId = data['type_passage'] as int;
|
||||
if (!widget.excludePassageTypes.contains(typeId)) {
|
||||
passagesByType[typeId] = data['nb'] as int;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Convertir la date en objet DateTime
|
||||
final dateParts = dateStr.split('-');
|
||||
if (dateParts.length == 3) {
|
||||
final year = int.parse(dateParts[0]);
|
||||
final month = int.parse(dateParts[1]);
|
||||
final day = int.parse(dateParts[2]);
|
||||
|
||||
final date = DateTime(year, month, day);
|
||||
|
||||
// Ajouter les données à la liste
|
||||
_chartData.add(ActivityData(
|
||||
date: date,
|
||||
dateStr: dateStr,
|
||||
passagesByType: passagesByType,
|
||||
));
|
||||
}
|
||||
} catch (e) {
|
||||
// Silencieux lors des erreurs de conversion de date pour éviter les logs excessifs
|
||||
}
|
||||
}
|
||||
|
||||
// Trier les données par date
|
||||
_chartData.sort((a, b) => a.date.compareTo(b.date));
|
||||
} catch (e) {
|
||||
// Erreur silencieuse pour éviter les logs excessifs
|
||||
_chartData = [];
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isLoading) {
|
||||
return SizedBox(
|
||||
height: widget.height,
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!_hasData || _chartData.isEmpty) {
|
||||
return SizedBox(
|
||||
height: widget.height,
|
||||
child: const Center(
|
||||
child: Text('Aucune donnée disponible'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Préparer les données si nécessaire
|
||||
if (_chartData.isEmpty) {
|
||||
_prepareChartData();
|
||||
}
|
||||
|
||||
return SizedBox(
|
||||
height: widget.height,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Titre (conservé)
|
||||
if (widget.title.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 16.0, right: 16.0, top: 16.0, bottom: 8.0),
|
||||
child: Text(
|
||||
widget.title,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Graphique (occupe maintenant plus d'espace)
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16.0, 8.0, 16.0, 16.0),
|
||||
child: SfCartesianChart(
|
||||
plotAreaBorderWidth: 0,
|
||||
legend: Legend(
|
||||
isVisible: true,
|
||||
position: LegendPosition.bottom,
|
||||
overflowMode: LegendItemOverflowMode.wrap,
|
||||
),
|
||||
primaryXAxis: DateTimeAxis(
|
||||
dateFormat: DateFormat('dd/MM'),
|
||||
intervalType: DateTimeIntervalType.days,
|
||||
majorGridLines: const MajorGridLines(width: 0),
|
||||
labelStyle: const TextStyle(fontSize: 10),
|
||||
// Définir explicitement la plage de dates à afficher
|
||||
minimum: _chartData.isNotEmpty ? _chartData.first.date : null,
|
||||
maximum: _chartData.isNotEmpty ? _chartData.last.date : null,
|
||||
// Assurer que tous les jours sont affichés
|
||||
interval: 1,
|
||||
axisLabelFormatter: (AxisLabelRenderDetails details) {
|
||||
return ChartAxisLabel(details.text, details.textStyle);
|
||||
},
|
||||
),
|
||||
primaryYAxis: NumericAxis(
|
||||
labelStyle: const TextStyle(fontSize: 10),
|
||||
axisLine: const AxisLine(width: 0),
|
||||
majorTickLines: const MajorTickLines(size: 0),
|
||||
majorGridLines: const MajorGridLines(
|
||||
width: 0.5,
|
||||
color: Colors.grey,
|
||||
dashArray: <double>[5, 5], // Motif de pointillés
|
||||
),
|
||||
title: const AxisTitle(
|
||||
text: 'Passages',
|
||||
textStyle: TextStyle(fontSize: 10, color: Colors.grey),
|
||||
),
|
||||
),
|
||||
series: _buildSeries(),
|
||||
tooltipBehavior: TooltipBehavior(enable: true),
|
||||
zoomPanBehavior: _zoomPanBehavior,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit les séries de données pour le graphique
|
||||
List<CartesianSeries<ActivityData, DateTime>> _buildSeries() {
|
||||
final List<CartesianSeries<ActivityData, DateTime>> series = [];
|
||||
|
||||
// Vérifier que les données sont disponibles
|
||||
if (_chartData.isEmpty) {
|
||||
return series;
|
||||
}
|
||||
|
||||
// Obtenir tous les types de passage (sauf ceux exclus)
|
||||
final passageTypes = AppKeys.typesPassages.keys
|
||||
.where((typeId) => !widget.excludePassageTypes.contains(typeId))
|
||||
.toList();
|
||||
|
||||
// Créer les séries pour les passages (colonnes empilées)
|
||||
for (final typeId in passageTypes) {
|
||||
// Vérifier que le type existe dans AppKeys
|
||||
if (!AppKeys.typesPassages.containsKey(typeId)) {
|
||||
continue;
|
||||
}
|
||||
final typeInfo = AppKeys.typesPassages[typeId]!;
|
||||
|
||||
// Vérifier que les clés nécessaires existent
|
||||
if (!typeInfo.containsKey('couleur1') || !typeInfo.containsKey('titre')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final typeColor = Color(typeInfo['couleur1'] as int);
|
||||
final typeName = typeInfo['titre'] as String;
|
||||
|
||||
// Calculer le total pour ce type pour déterminer s'il faut l'afficher
|
||||
int totalForType = 0;
|
||||
for (final data in _chartData) {
|
||||
totalForType += data.passagesByType[typeId] ?? 0;
|
||||
}
|
||||
|
||||
// On peut décider de ne pas afficher les types sans données
|
||||
final addZeroValueTypes = false;
|
||||
|
||||
// Ajouter la série pour ce type
|
||||
if (totalForType > 0 || addZeroValueTypes) {
|
||||
series.add(
|
||||
StackedColumnSeries<ActivityData, DateTime>(
|
||||
name: typeName,
|
||||
dataSource: _chartData,
|
||||
xValueMapper: (ActivityData data, _) => data.date,
|
||||
yValueMapper: (ActivityData data, _) {
|
||||
final value = data.passagesByType.containsKey(typeId)
|
||||
? data.passagesByType[typeId]!
|
||||
: 0;
|
||||
return value;
|
||||
},
|
||||
color: typeColor,
|
||||
width: widget.columnWidth,
|
||||
spacing: widget.columnSpacing,
|
||||
dataLabelSettings: DataLabelSettings(
|
||||
isVisible: widget.showDataLabels,
|
||||
labelAlignment: ChartDataLabelAlignment.middle,
|
||||
textStyle: const TextStyle(fontSize: 8, color: Colors.white),
|
||||
),
|
||||
markerSettings: const MarkerSettings(isVisible: false),
|
||||
animationDuration: 1500,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return series;
|
||||
}
|
||||
}
|
||||
11
app/lib/presentation/widgets/charts/charts.dart
Normal file
11
app/lib/presentation/widgets/charts/charts.dart
Normal file
@@ -0,0 +1,11 @@
|
||||
/// Bibliothèque de widgets de graphiques pour l'application GeoSector
|
||||
library geosector_charts;
|
||||
|
||||
export 'payment_data.dart';
|
||||
export 'payment_pie_chart.dart';
|
||||
export 'payment_utils.dart';
|
||||
export 'passage_data.dart';
|
||||
export 'passage_utils.dart';
|
||||
export 'passage_pie_chart.dart';
|
||||
export 'activity_chart.dart';
|
||||
export 'combined_chart.dart';
|
||||
313
app/lib/presentation/widgets/charts/combined_chart.dart
Normal file
313
app/lib/presentation/widgets/charts/combined_chart.dart
Normal file
@@ -0,0 +1,313 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:geosector_app/presentation/widgets/charts/passage_data.dart';
|
||||
import 'package:geosector_app/presentation/widgets/charts/passage_utils.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
/// Widget de graphique combiné pour afficher les passages et règlements
|
||||
class CombinedChart extends StatelessWidget {
|
||||
/// Liste des données de passage par type
|
||||
final List<Map<String, dynamic>> passageData;
|
||||
|
||||
/// Liste des données de règlement par type
|
||||
final List<Map<String, dynamic>> paymentData;
|
||||
|
||||
/// Type de période (Jour, Semaine, Mois, Année)
|
||||
final String periodType;
|
||||
|
||||
/// Hauteur du graphique
|
||||
final double height;
|
||||
|
||||
/// Largeur des barres
|
||||
final double barWidth;
|
||||
|
||||
/// Rayon des points sur les lignes
|
||||
final double dotRadius;
|
||||
|
||||
/// Épaisseur des lignes
|
||||
final double lineWidth;
|
||||
|
||||
/// Montant maximum pour l'axe Y des règlements
|
||||
final double? maxYAmount;
|
||||
|
||||
/// Nombre maximum pour l'axe Y des passages
|
||||
final int? maxYCount;
|
||||
|
||||
const CombinedChart({
|
||||
super.key,
|
||||
required this.passageData,
|
||||
required this.paymentData,
|
||||
this.periodType = 'Jour',
|
||||
this.height = 300,
|
||||
this.barWidth = 16,
|
||||
this.dotRadius = 4,
|
||||
this.lineWidth = 3,
|
||||
this.maxYAmount,
|
||||
this.maxYCount,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
// Convertir les données brutes en modèles structurés
|
||||
final passagesByType = PassageUtils.getPassageDataByType(passageData);
|
||||
final paymentsByType = PassageUtils.getPaymentDataByType(paymentData);
|
||||
|
||||
// Extraire les dates uniques pour l'axe X
|
||||
final List<DateTime> allDates = [];
|
||||
for (final data in passageData) {
|
||||
final DateTime date = data['date'] is DateTime
|
||||
? data['date']
|
||||
: DateTime.parse(data['date']);
|
||||
if (!allDates.any((d) =>
|
||||
d.year == date.year && d.month == date.month && d.day == date.day)) {
|
||||
allDates.add(date);
|
||||
}
|
||||
}
|
||||
|
||||
// Trier les dates
|
||||
allDates.sort((a, b) => a.compareTo(b));
|
||||
|
||||
// Calculer le maximum pour les axes Y
|
||||
double maxAmount = 0;
|
||||
for (final typeData in paymentsByType) {
|
||||
for (final data in typeData) {
|
||||
if (data.amount > maxAmount) {
|
||||
maxAmount = data.amount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int maxCount = 0;
|
||||
for (final typeData in passagesByType) {
|
||||
for (final data in typeData) {
|
||||
if (data.count > maxCount) {
|
||||
maxCount = data.count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Utiliser les maximums fournis ou calculés
|
||||
final effectiveMaxYAmount = maxYAmount ?? (maxAmount * 1.2).ceilToDouble();
|
||||
final effectiveMaxYCount = maxYCount ?? (maxCount * 1.2).ceil();
|
||||
|
||||
return SizedBox(
|
||||
height: height,
|
||||
child: BarChart(
|
||||
BarChartData(
|
||||
alignment: BarChartAlignment.spaceAround,
|
||||
maxY: effectiveMaxYCount.toDouble(),
|
||||
barTouchData: BarTouchData(
|
||||
touchTooltipData: BarTouchTooltipData(
|
||||
tooltipPadding: const EdgeInsets.all(8),
|
||||
tooltipMargin: 8,
|
||||
getTooltipItem: (group, groupIndex, rod, rodIndex) {
|
||||
final date = allDates[group.x.toInt()];
|
||||
final formattedDate = DateFormat('dd/MM').format(date);
|
||||
|
||||
// Calculer le total des passages pour cette date
|
||||
int totalPassages = 0;
|
||||
for (final typeData in passagesByType) {
|
||||
for (final data in typeData) {
|
||||
if (data.date.year == date.year &&
|
||||
data.date.month == date.month &&
|
||||
data.date.day == date.day) {
|
||||
totalPassages += data.count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return BarTooltipItem(
|
||||
'$formattedDate: $totalPassages passages',
|
||||
TextStyle(
|
||||
color: theme.colorScheme.onSurface,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
titlesData: FlTitlesData(
|
||||
show: true,
|
||||
bottomTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 30,
|
||||
getTitlesWidget: (value, meta) {
|
||||
if (value >= 0 && value < allDates.length) {
|
||||
final date = allDates[value.toInt()];
|
||||
final formattedDate =
|
||||
PassageUtils.formatDateForChart(date, periodType);
|
||||
|
||||
return SideTitleWidget(
|
||||
meta: meta,
|
||||
space: 8,
|
||||
child: Text(
|
||||
formattedDate,
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
),
|
||||
leftTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
getTitlesWidget: (value, meta) {
|
||||
return SideTitleWidget(
|
||||
meta: meta,
|
||||
space: 8,
|
||||
child: Text(
|
||||
value.toInt().toString(),
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
reservedSize: 30,
|
||||
),
|
||||
),
|
||||
rightTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
getTitlesWidget: (value, meta) {
|
||||
// Convertir la valeur de l'axe Y des passages à l'échelle des montants
|
||||
final amountValue =
|
||||
(value / effectiveMaxYCount) * effectiveMaxYAmount;
|
||||
|
||||
return SideTitleWidget(
|
||||
meta: meta,
|
||||
space: 8,
|
||||
child: Text(
|
||||
'${amountValue.toInt()}€',
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
reservedSize: 40,
|
||||
),
|
||||
),
|
||||
topTitles: AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
),
|
||||
gridData: FlGridData(
|
||||
show: true,
|
||||
getDrawingHorizontalLine: (value) {
|
||||
return FlLine(
|
||||
color: theme.dividerColor.withOpacity(0.2),
|
||||
strokeWidth: 1,
|
||||
);
|
||||
},
|
||||
drawVerticalLine: false,
|
||||
),
|
||||
borderData: FlBorderData(show: false),
|
||||
barGroups: _createBarGroups(allDates, passagesByType),
|
||||
extraLinesData: ExtraLinesData(
|
||||
horizontalLines: [],
|
||||
verticalLines: [],
|
||||
extraLinesOnTop: true,
|
||||
),
|
||||
),
|
||||
swapAnimationDuration: const Duration(milliseconds: 250),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Créer les groupes de barres pour les passages
|
||||
List<BarChartGroupData> _createBarGroups(
|
||||
List<DateTime> allDates,
|
||||
List<List<PassageData>> passagesByType,
|
||||
) {
|
||||
final List<BarChartGroupData> groups = [];
|
||||
|
||||
for (int i = 0; i < allDates.length; i++) {
|
||||
final date = allDates[i];
|
||||
|
||||
// Calculer le total des passages pour cette date
|
||||
int totalPassages = 0;
|
||||
for (final typeData in passagesByType) {
|
||||
for (final data in typeData) {
|
||||
if (data.date.year == date.year &&
|
||||
data.date.month == date.month &&
|
||||
data.date.day == date.day) {
|
||||
totalPassages += data.count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Créer un groupe de barres pour cette date
|
||||
groups.add(
|
||||
BarChartGroupData(
|
||||
x: i,
|
||||
barRods: [
|
||||
BarChartRodData(
|
||||
toY: totalPassages.toDouble(),
|
||||
color: Colors.blue.shade700,
|
||||
width: barWidth,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(6),
|
||||
topRight: Radius.circular(6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
}
|
||||
|
||||
/// Widget de légende pour le graphique combiné
|
||||
class CombinedChartLegend extends StatelessWidget {
|
||||
const CombinedChartLegend({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Wrap(
|
||||
spacing: 16,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_buildLegendItem('Passages', Colors.blue.shade700, isBar: true),
|
||||
_buildLegendItem('Espèces', const Color(0xFF4CAF50)),
|
||||
_buildLegendItem('Chèques', const Color(0xFF2196F3)),
|
||||
_buildLegendItem('CB', const Color(0xFFF44336)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Créer un élément de légende
|
||||
Widget _buildLegendItem(String label, Color color, {bool isBar = false}) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 16,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: isBar ? BoxShape.rectangle : BoxShape.circle,
|
||||
borderRadius: isBar ? BorderRadius.circular(3) : null,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
99
app/lib/presentation/widgets/charts/passage_data.dart
Normal file
99
app/lib/presentation/widgets/charts/passage_data.dart
Normal file
@@ -0,0 +1,99 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Modèle de données pour représenter un passage avec sa date, son type et son nombre
|
||||
class PassageData {
|
||||
/// Date du passage
|
||||
final DateTime date;
|
||||
|
||||
/// Identifiant du type de passage (1: Effectué, 2: À finaliser, 3: Refusé, etc.)
|
||||
final int typeId;
|
||||
|
||||
/// Nombre de passages
|
||||
final int count;
|
||||
|
||||
/// Couleur associée au type de passage
|
||||
final Color color;
|
||||
|
||||
/// Icône associée au type de passage (chemin vers le fichier d'icône)
|
||||
final String iconPath;
|
||||
|
||||
/// Titre du type de passage
|
||||
final String title;
|
||||
|
||||
const PassageData({
|
||||
required this.date,
|
||||
required this.typeId,
|
||||
required this.count,
|
||||
required this.color,
|
||||
required this.iconPath,
|
||||
required this.title,
|
||||
});
|
||||
|
||||
/// Crée une instance de PassageData à partir d'une date au format ISO 8601
|
||||
factory PassageData.fromIsoDate({
|
||||
required String isoDate,
|
||||
required int typeId,
|
||||
required int count,
|
||||
required Color color,
|
||||
required String iconPath,
|
||||
required String title,
|
||||
}) {
|
||||
return PassageData(
|
||||
date: DateTime.parse(isoDate),
|
||||
typeId: typeId,
|
||||
count: count,
|
||||
color: color,
|
||||
iconPath: iconPath,
|
||||
title: title,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Modèle de données pour représenter un règlement avec sa date, son type et son montant
|
||||
class PaymentAmountData {
|
||||
/// Date du règlement
|
||||
final DateTime date;
|
||||
|
||||
/// Identifiant du type de règlement (1: Espèces, 2: Chèques, 3: CB)
|
||||
final int typeId;
|
||||
|
||||
/// Montant du règlement
|
||||
final double amount;
|
||||
|
||||
/// Couleur associée au type de règlement
|
||||
final Color color;
|
||||
|
||||
/// Icône associée au type de règlement (chemin vers le fichier d'icône ou IconData)
|
||||
final dynamic iconData;
|
||||
|
||||
/// Titre du type de règlement
|
||||
final String title;
|
||||
|
||||
/// Crée une instance de PaymentAmountData à partir d'une date au format ISO 8601
|
||||
factory PaymentAmountData.fromIsoDate({
|
||||
required String isoDate,
|
||||
required int typeId,
|
||||
required double amount,
|
||||
required Color color,
|
||||
required dynamic iconData,
|
||||
required String title,
|
||||
}) {
|
||||
return PaymentAmountData(
|
||||
date: DateTime.parse(isoDate),
|
||||
typeId: typeId,
|
||||
amount: amount,
|
||||
color: color,
|
||||
iconData: iconData,
|
||||
title: title,
|
||||
);
|
||||
}
|
||||
|
||||
const PaymentAmountData({
|
||||
required this.date,
|
||||
required this.typeId,
|
||||
required this.amount,
|
||||
required this.color,
|
||||
required this.iconData,
|
||||
required this.title,
|
||||
});
|
||||
}
|
||||
549
app/lib/presentation/widgets/charts/passage_pie_chart.dart
Normal file
549
app/lib/presentation/widgets/charts/passage_pie_chart.dart
Normal file
@@ -0,0 +1,549 @@
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:flutter/foundation.dart' show listEquals, mapEquals;
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:syncfusion_flutter_charts/charts.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/core/repositories/passage_repository.dart';
|
||||
import 'package:geosector_app/core/repositories/user_repository.dart';
|
||||
import 'package:geosector_app/core/services/passage_data_service.dart';
|
||||
|
||||
/// Modèle de données pour le graphique en camembert des passages
|
||||
class PassageChartData {
|
||||
/// Identifiant du type de passage
|
||||
final int typeId;
|
||||
|
||||
/// Nombre de passages de ce type
|
||||
final int count;
|
||||
|
||||
/// Titre du type de passage
|
||||
final String title;
|
||||
|
||||
/// Couleur associée au type de passage
|
||||
final Color color;
|
||||
|
||||
/// Icône associée au type de passage
|
||||
final IconData icon;
|
||||
|
||||
PassageChartData({
|
||||
required this.typeId,
|
||||
required this.count,
|
||||
required this.title,
|
||||
required this.color,
|
||||
required this.icon,
|
||||
});
|
||||
}
|
||||
|
||||
/// Widget de graphique en camembert pour représenter la répartition des passages par type
|
||||
class PassagePieChart extends StatefulWidget {
|
||||
/// Liste des données de passages par type sous forme de Map avec typeId et count
|
||||
/// Si loadFromHive est true, ce paramètre est ignoré
|
||||
final Map<int, int> passagesByType;
|
||||
|
||||
/// Taille du graphique
|
||||
final double size;
|
||||
|
||||
/// Taille des étiquettes
|
||||
final double labelSize;
|
||||
|
||||
/// Afficher les pourcentages
|
||||
final bool showPercentage;
|
||||
|
||||
/// Afficher les icônes
|
||||
final bool showIcons;
|
||||
|
||||
/// Afficher la légende
|
||||
final bool showLegend;
|
||||
|
||||
/// Format donut (anneau)
|
||||
final bool isDonut;
|
||||
|
||||
/// Rayon central pour le format donut (en pourcentage)
|
||||
final String innerRadius;
|
||||
|
||||
/// Charger les données depuis Hive
|
||||
final bool loadFromHive;
|
||||
|
||||
/// ID de l'utilisateur pour filtrer les passages (utilisé seulement si loadFromHive est true)
|
||||
final int? userId;
|
||||
|
||||
/// Types de passages à exclure (utilisé seulement si loadFromHive est true)
|
||||
final List<int> excludePassageTypes;
|
||||
|
||||
/// Afficher tous les passages sans filtrer par utilisateur (utilisé seulement si loadFromHive est true)
|
||||
final bool showAllPassages;
|
||||
|
||||
const PassagePieChart({
|
||||
super.key,
|
||||
this.passagesByType = const {},
|
||||
this.size = 300,
|
||||
this.labelSize = 12,
|
||||
this.showPercentage = true,
|
||||
this.showIcons = true,
|
||||
this.showLegend = true,
|
||||
this.isDonut = false,
|
||||
this.innerRadius = '40%',
|
||||
this.loadFromHive = false,
|
||||
this.userId,
|
||||
this.excludePassageTypes = const [2],
|
||||
this.showAllPassages = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PassagePieChart> createState() => _PassagePieChartState();
|
||||
}
|
||||
|
||||
class _PassagePieChartState extends State<PassagePieChart>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _animationController;
|
||||
|
||||
/// Données de passages par type
|
||||
late Map<int, int> _passagesByType;
|
||||
|
||||
/// Variables pour la mise en cache et l'optimisation
|
||||
bool _dataLoaded = false;
|
||||
bool _isLoading = false;
|
||||
List<PassageChartData>? _cachedChartData;
|
||||
List<CircularChartAnnotation>? _cachedAnnotations;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_passagesByType = widget.passagesByType;
|
||||
|
||||
// Initialiser le contrôleur d'animation
|
||||
_animationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 2000),
|
||||
);
|
||||
|
||||
_animationController.forward();
|
||||
|
||||
// Si nous n'utilisons pas Hive, préparer les données immédiatement
|
||||
if (!widget.loadFromHive) {
|
||||
_prepareChartData();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (widget.loadFromHive && !_dataLoaded && !_isLoading) {
|
||||
_isLoading = true; // Prévenir les chargements multiples
|
||||
_loadPassageDataFromHive(context);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(PassagePieChart oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
// Vérifier si les propriétés importantes ont changé
|
||||
final bool dataSourceChanged = widget.loadFromHive
|
||||
? false
|
||||
: !mapEquals(oldWidget.passagesByType, widget.passagesByType);
|
||||
final bool filteringChanged = oldWidget.userId != widget.userId ||
|
||||
!listEquals(
|
||||
oldWidget.excludePassageTypes, widget.excludePassageTypes) ||
|
||||
oldWidget.showAllPassages != widget.showAllPassages;
|
||||
final bool visualChanged = oldWidget.size != widget.size ||
|
||||
oldWidget.labelSize != widget.labelSize ||
|
||||
oldWidget.showPercentage != widget.showPercentage ||
|
||||
oldWidget.showIcons != widget.showIcons ||
|
||||
oldWidget.showLegend != widget.showLegend ||
|
||||
oldWidget.isDonut != widget.isDonut ||
|
||||
oldWidget.innerRadius != widget.innerRadius;
|
||||
|
||||
// Si les paramètres de filtrage ou de source de données ont changé, recharger les données
|
||||
if (dataSourceChanged || filteringChanged) {
|
||||
_cachedChartData = null;
|
||||
_cachedAnnotations = null;
|
||||
|
||||
// Relancer l'animation si les données ont changé
|
||||
_animationController.reset();
|
||||
_animationController.forward();
|
||||
|
||||
if (!widget.loadFromHive) {
|
||||
_passagesByType = widget.passagesByType;
|
||||
_prepareChartData();
|
||||
} else if (!_isLoading) {
|
||||
_dataLoaded = false;
|
||||
_isLoading = true;
|
||||
_loadPassageDataFromHive(context);
|
||||
}
|
||||
}
|
||||
// Si seuls les paramètres visuels ont changé, recalculer les annotations sans recharger les données
|
||||
else if (visualChanged) {
|
||||
_cachedAnnotations = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Charge les données de passage depuis Hive en utilisant le service PassageDataService
|
||||
void _loadPassageDataFromHive(BuildContext context) {
|
||||
// Éviter les appels multiples pendant le chargement
|
||||
if (_isLoading) {
|
||||
debugPrint('PassagePieChart: Déjà en cours de chargement, ignoré');
|
||||
return;
|
||||
}
|
||||
|
||||
// Si les données sont déjà chargées et non vides, ne pas recharger
|
||||
if (_dataLoaded && _passagesByType.isNotEmpty) {
|
||||
debugPrint('PassagePieChart: Données déjà chargées, ignoré');
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint('PassagePieChart: Début du chargement des données');
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
// Charger les données dans un addPostFrameCallback pour éviter les problèmes de cycle de vie
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// Vérifier si le widget est toujours monté
|
||||
if (!mounted) {
|
||||
debugPrint('PassagePieChart: Widget démonté, chargement annulé');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
debugPrint('PassagePieChart: Création du service de données');
|
||||
// Utiliser les instances globales définies dans app.dart
|
||||
|
||||
// Vérifier que les repositories sont disponibles
|
||||
if (passageRepository == null) {
|
||||
debugPrint('PassagePieChart: ERREUR - passageRepository est null');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (userRepository == null) {
|
||||
debugPrint('PassagePieChart: ERREUR - userRepository est null');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Créer une instance du service de données
|
||||
final passageDataService = PassageDataService(
|
||||
passageRepository: passageRepository,
|
||||
userRepository: userRepository,
|
||||
);
|
||||
|
||||
debugPrint(
|
||||
'PassagePieChart: Chargement des données avec excludePassageTypes=${widget.excludePassageTypes}, userId=${widget.userId}, showAllPassages=${widget.showAllPassages}');
|
||||
|
||||
// Utiliser le service pour charger les données
|
||||
final data = passageDataService.loadPassageDataForPieChart(
|
||||
excludePassageTypes: widget.excludePassageTypes,
|
||||
userId: widget.userId,
|
||||
showAllPassages: widget.showAllPassages,
|
||||
);
|
||||
|
||||
debugPrint('PassagePieChart: Données chargées: $data');
|
||||
|
||||
// Mettre à jour les données et les états
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_passagesByType = data;
|
||||
_dataLoaded = true;
|
||||
_isLoading = false;
|
||||
_cachedChartData =
|
||||
null; // Forcer la régénération des données du graphique
|
||||
_cachedAnnotations = null;
|
||||
});
|
||||
|
||||
// Préparer les données du graphique
|
||||
_prepareChartData();
|
||||
debugPrint('PassagePieChart: Données préparées pour le graphique');
|
||||
}
|
||||
} catch (e) {
|
||||
// Gérer les erreurs et réinitialiser l'état pour permettre une future tentative
|
||||
debugPrint(
|
||||
'PassagePieChart: ERREUR lors du chargement des données: $e');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Prépare les données pour le graphique en camembert avec mise en cache
|
||||
List<PassageChartData> _prepareChartData() {
|
||||
// Utiliser les données en cache si disponibles
|
||||
if (_cachedChartData != null) {
|
||||
debugPrint('PassagePieChart: Utilisation des données en cache');
|
||||
return _cachedChartData!;
|
||||
}
|
||||
|
||||
debugPrint('PassagePieChart: Préparation des données pour le graphique');
|
||||
debugPrint('PassagePieChart: Données brutes: $_passagesByType');
|
||||
|
||||
// Vérifier si les données sont vides
|
||||
if (_passagesByType.isEmpty) {
|
||||
debugPrint('PassagePieChart: Aucune donnée disponible');
|
||||
return [];
|
||||
}
|
||||
|
||||
// Vérifier si les données contiennent uniquement des passages de type 2
|
||||
bool onlyType2 = true;
|
||||
_passagesByType.forEach((typeId, count) {
|
||||
if (typeId != 2 && count > 0) {
|
||||
onlyType2 = false;
|
||||
}
|
||||
});
|
||||
|
||||
if (onlyType2) {
|
||||
debugPrint(
|
||||
'PassagePieChart: Les données contiennent uniquement des passages de type 2');
|
||||
}
|
||||
|
||||
final List<PassageChartData> chartData = [];
|
||||
|
||||
// Créer les données du graphique
|
||||
_passagesByType.forEach((typeId, count) {
|
||||
// Vérifier que le type existe et que le compteur est positif
|
||||
if (count > 0 && AppKeys.typesPassages.containsKey(typeId)) {
|
||||
// Vérifier si le type est exclu
|
||||
bool isExcluded = widget.excludePassageTypes.contains(typeId);
|
||||
if (isExcluded) {
|
||||
debugPrint('PassagePieChart: Type $typeId exclu');
|
||||
} else {
|
||||
final typeInfo = AppKeys.typesPassages[typeId]!;
|
||||
final typeName = typeInfo['titre'] as String;
|
||||
debugPrint(
|
||||
'PassagePieChart: Ajout du type $typeId ($typeName) avec $count passages');
|
||||
|
||||
chartData.add(PassageChartData(
|
||||
typeId: typeId,
|
||||
count: count,
|
||||
title: typeName,
|
||||
color: Color(typeInfo['couleur2'] as int),
|
||||
icon: typeInfo['icon_data'] as IconData,
|
||||
));
|
||||
}
|
||||
} else {
|
||||
if (count <= 0) {
|
||||
debugPrint('PassagePieChart: Type $typeId ignoré car count=$count');
|
||||
} else if (!AppKeys.typesPassages.containsKey(typeId)) {
|
||||
debugPrint(
|
||||
'PassagePieChart: Type $typeId ignoré car non défini dans AppKeys.typesPassages');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
debugPrint(
|
||||
'PassagePieChart: ${chartData.length} types de passages ajoutés au graphique');
|
||||
|
||||
// Mettre en cache les données générées
|
||||
_cachedChartData = chartData;
|
||||
|
||||
return chartData;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Si les données doivent être chargées depuis Hive mais ne sont pas encore prêtes
|
||||
if (widget.loadFromHive && !_dataLoaded) {
|
||||
return SizedBox(
|
||||
width: widget.size,
|
||||
height: widget.size,
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final chartData = _prepareChartData();
|
||||
|
||||
// Si aucune donnée, afficher un message
|
||||
if (chartData.isEmpty) {
|
||||
return SizedBox(
|
||||
width: widget.size,
|
||||
height: widget.size,
|
||||
child: const Center(
|
||||
child: Text('Aucune donnée disponible'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Créer des animations pour différents aspects du graphique
|
||||
final progressAnimation = CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
|
||||
final explodeAnimation = CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Interval(0.7, 1.0, curve: Curves.elasticOut),
|
||||
);
|
||||
|
||||
final opacityAnimation = CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Interval(0.1, 0.5, curve: Curves.easeIn),
|
||||
);
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _animationController,
|
||||
builder: (context, child) {
|
||||
return SizedBox(
|
||||
width: widget.size,
|
||||
height: widget.size,
|
||||
child: SfCircularChart(
|
||||
margin: EdgeInsets.zero,
|
||||
legend: Legend(
|
||||
isVisible: widget.showLegend,
|
||||
position: LegendPosition.bottom,
|
||||
overflowMode: LegendItemOverflowMode.wrap,
|
||||
textStyle: TextStyle(fontSize: widget.labelSize),
|
||||
),
|
||||
tooltipBehavior: TooltipBehavior(enable: true),
|
||||
series: <CircularSeries>[
|
||||
widget.isDonut
|
||||
? DoughnutSeries<PassageChartData, String>(
|
||||
dataSource: chartData,
|
||||
xValueMapper: (PassageChartData data, _) => data.title,
|
||||
yValueMapper: (PassageChartData data, _) => data.count,
|
||||
pointColorMapper: (PassageChartData data, _) =>
|
||||
data.color,
|
||||
enableTooltip: true,
|
||||
dataLabelMapper: (PassageChartData data, _) {
|
||||
if (widget.showPercentage) {
|
||||
// Calculer le pourcentage avec une décimale
|
||||
final total = chartData.fold(
|
||||
0, (sum, item) => sum + item.count);
|
||||
final percentage = (data.count / total * 100);
|
||||
return '${percentage.toStringAsFixed(1)}%';
|
||||
} else {
|
||||
return data.title;
|
||||
}
|
||||
},
|
||||
dataLabelSettings: DataLabelSettings(
|
||||
isVisible: true,
|
||||
labelPosition: ChartDataLabelPosition.outside,
|
||||
textStyle: TextStyle(fontSize: widget.labelSize),
|
||||
connectorLineSettings: const ConnectorLineSettings(
|
||||
type: ConnectorType.curve,
|
||||
length: '15%',
|
||||
),
|
||||
),
|
||||
innerRadius: widget.innerRadius,
|
||||
explode: true,
|
||||
explodeIndex: 0,
|
||||
explodeOffset:
|
||||
'${(5 * explodeAnimation.value).toStringAsFixed(1)}%',
|
||||
opacity: opacityAnimation.value,
|
||||
animationDuration:
|
||||
0, // On désactive l'animation intégrée car nous utilisons notre propre animation
|
||||
startAngle: 270,
|
||||
endAngle: 270 + (360 * progressAnimation.value).toInt(),
|
||||
)
|
||||
: PieSeries<PassageChartData, String>(
|
||||
dataSource: chartData,
|
||||
xValueMapper: (PassageChartData data, _) => data.title,
|
||||
yValueMapper: (PassageChartData data, _) => data.count,
|
||||
pointColorMapper: (PassageChartData data, _) =>
|
||||
data.color,
|
||||
enableTooltip: true,
|
||||
dataLabelMapper: (PassageChartData data, _) {
|
||||
if (widget.showPercentage) {
|
||||
// Calculer le pourcentage avec une décimale
|
||||
final total = chartData.fold(
|
||||
0, (sum, item) => sum + item.count);
|
||||
final percentage = (data.count / total * 100);
|
||||
return '${percentage.toStringAsFixed(1)}%';
|
||||
} else {
|
||||
return data.title;
|
||||
}
|
||||
},
|
||||
dataLabelSettings: DataLabelSettings(
|
||||
isVisible: true,
|
||||
labelPosition: ChartDataLabelPosition.outside,
|
||||
textStyle: TextStyle(fontSize: widget.labelSize),
|
||||
connectorLineSettings: const ConnectorLineSettings(
|
||||
type: ConnectorType.curve,
|
||||
length: '15%',
|
||||
),
|
||||
),
|
||||
explode: true,
|
||||
explodeIndex: 0,
|
||||
explodeOffset:
|
||||
'${(5 * explodeAnimation.value).toStringAsFixed(1)}%',
|
||||
opacity: opacityAnimation.value,
|
||||
animationDuration:
|
||||
0, // On désactive l'animation intégrée car nous utilisons notre propre animation
|
||||
startAngle: 270,
|
||||
endAngle: 270 + (360 * progressAnimation.value).toInt(),
|
||||
),
|
||||
],
|
||||
annotations:
|
||||
widget.showIcons ? _buildIconAnnotations(chartData) : null,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Crée les annotations d'icônes pour le graphique avec mise en cache
|
||||
List<CircularChartAnnotation> _buildIconAnnotations(
|
||||
List<PassageChartData> chartData) {
|
||||
// Utiliser les annotations en cache si disponibles
|
||||
if (_cachedAnnotations != null) {
|
||||
return _cachedAnnotations!;
|
||||
}
|
||||
|
||||
final List<CircularChartAnnotation> annotations = [];
|
||||
|
||||
// Calculer le total pour les pourcentages
|
||||
int total = chartData.fold(0, (sum, item) => sum + item.count);
|
||||
if (total == 0) return []; // Éviter la division par zéro
|
||||
|
||||
// Position angulaire actuelle (en radians)
|
||||
double currentAngle = 0;
|
||||
|
||||
for (int i = 0; i < chartData.length; i++) {
|
||||
final data = chartData[i];
|
||||
final percentage = data.count / total;
|
||||
|
||||
// Calculer l'angle central de ce segment
|
||||
final segmentAngle = percentage * 2 * 3.14159;
|
||||
final midAngle = currentAngle + (segmentAngle / 2);
|
||||
|
||||
// Ajouter une annotation pour l'icône
|
||||
annotations.add(
|
||||
CircularChartAnnotation(
|
||||
widget: Icon(
|
||||
data.icon,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
),
|
||||
radius: '50%',
|
||||
angle: (midAngle * (180 / 3.14159)).toInt(), // Convertir en degrés
|
||||
),
|
||||
);
|
||||
|
||||
// Mettre à jour l'angle actuel
|
||||
currentAngle += segmentAngle;
|
||||
}
|
||||
|
||||
// Mettre en cache les annotations générées
|
||||
_cachedAnnotations = annotations;
|
||||
|
||||
return annotations;
|
||||
}
|
||||
}
|
||||
214
app/lib/presentation/widgets/charts/passage_utils.dart
Normal file
214
app/lib/presentation/widgets/charts/passage_utils.dart
Normal file
@@ -0,0 +1,214 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/presentation/widgets/charts/passage_data.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
/// Utilitaires pour les passages et règlements
|
||||
class PassageUtils {
|
||||
/// Convertit les données de passage brutes en liste de PassageData
|
||||
///
|
||||
/// [passageData] est une liste d'objets contenant date, type_passage et nb
|
||||
static List<List<PassageData>> getPassageDataByType(
|
||||
List<Map<String, dynamic>> passageData) {
|
||||
// Créer un Map pour stocker les données par type de passage
|
||||
final Map<int, List<PassageData>> passagesByType = {};
|
||||
|
||||
// Initialiser les listes pour chaque type de passage
|
||||
for (final entry in AppKeys.typesPassages.entries) {
|
||||
passagesByType[entry.key] = [];
|
||||
}
|
||||
|
||||
// Grouper les passages par type
|
||||
for (final data in passageData) {
|
||||
final int typeId = data['type_passage'];
|
||||
final int count = data['nb'];
|
||||
|
||||
if (AppKeys.typesPassages.containsKey(typeId)) {
|
||||
final typeData = AppKeys.typesPassages[typeId]!;
|
||||
final Color color = Color(typeData['couleur1'] as int);
|
||||
final String iconPath = typeData['icone'] as String;
|
||||
final String title = typeData['titre'] as String;
|
||||
|
||||
// Utiliser la méthode factory qui gère les dates au format ISO 8601
|
||||
if (data['date'] is String) {
|
||||
passagesByType[typeId]!.add(
|
||||
PassageData.fromIsoDate(
|
||||
isoDate: data['date'],
|
||||
typeId: typeId,
|
||||
count: count,
|
||||
color: color,
|
||||
iconPath: iconPath,
|
||||
title: title,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Fallback pour les objets DateTime (pour compatibilité)
|
||||
final DateTime date = data['date'] as DateTime;
|
||||
passagesByType[typeId]!.add(
|
||||
PassageData(
|
||||
date: date,
|
||||
typeId: typeId,
|
||||
count: count,
|
||||
color: color,
|
||||
iconPath: iconPath,
|
||||
title: title,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convertir le Map en liste de listes
|
||||
return passagesByType.values.toList();
|
||||
}
|
||||
|
||||
/// Convertit les données de règlement brutes en liste de PaymentAmountData
|
||||
///
|
||||
/// [paymentData] est une liste d'objets contenant date, type_reglement et montant
|
||||
static List<List<PaymentAmountData>> getPaymentDataByType(
|
||||
List<Map<String, dynamic>> paymentData) {
|
||||
// Créer un Map pour stocker les données par type de règlement
|
||||
final Map<int, List<PaymentAmountData>> paymentsByType = {};
|
||||
|
||||
// Initialiser les listes pour chaque type de règlement (sauf 0 qui est "Pas de règlement")
|
||||
for (final entry in AppKeys.typesReglements.entries) {
|
||||
if (entry.key > 0) {
|
||||
// Ignorer le type 0 (Pas de règlement)
|
||||
paymentsByType[entry.key] = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Grouper les règlements par type
|
||||
for (final data in paymentData) {
|
||||
final int typeId = data['type_reglement'];
|
||||
final double amount = data['montant'] is double
|
||||
? data['montant']
|
||||
: double.parse(data['montant'].toString());
|
||||
|
||||
if (typeId > 0 && AppKeys.typesReglements.containsKey(typeId)) {
|
||||
final typeData = AppKeys.typesReglements[typeId]!;
|
||||
final Color color = Color(typeData['couleur'] as int);
|
||||
final dynamic iconData = _getIconForPaymentType(typeId);
|
||||
final String title = typeData['titre'] as String;
|
||||
|
||||
// Utiliser la méthode factory qui gère les dates au format ISO 8601
|
||||
if (data['date'] is String) {
|
||||
paymentsByType[typeId]!.add(
|
||||
PaymentAmountData.fromIsoDate(
|
||||
isoDate: data['date'],
|
||||
typeId: typeId,
|
||||
amount: amount,
|
||||
color: color,
|
||||
iconData: iconData,
|
||||
title: title,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Fallback pour les objets DateTime (pour compatibilité)
|
||||
final DateTime date = data['date'] as DateTime;
|
||||
paymentsByType[typeId]!.add(
|
||||
PaymentAmountData(
|
||||
date: date,
|
||||
typeId: typeId,
|
||||
amount: amount,
|
||||
color: color,
|
||||
iconData: iconData,
|
||||
title: title,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convertir le Map en liste de listes
|
||||
return paymentsByType.values.toList();
|
||||
}
|
||||
|
||||
/// Génère des données de passage fictives pour les 14 derniers jours
|
||||
static List<Map<String, dynamic>> generateMockPassageData() {
|
||||
final List<Map<String, dynamic>> mockData = [];
|
||||
final now = DateTime.now();
|
||||
|
||||
for (int i = 13; i >= 0; i--) {
|
||||
final date = now.subtract(Duration(days: i));
|
||||
|
||||
// Ajouter des données pour chaque type de passage
|
||||
for (int typeId = 1; typeId <= 6; typeId++) {
|
||||
// Générer un nombre aléatoire de passages entre 0 et 5
|
||||
final count = (typeId == 1 || typeId == 2)
|
||||
? (1 + (date.day % 5)) // Plus de passages pour les types 1 et 2
|
||||
: (date.day % 3); // Moins pour les autres types
|
||||
|
||||
if (count > 0) {
|
||||
mockData.add({
|
||||
'date': date,
|
||||
'type_passage': typeId,
|
||||
'nb': count,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mockData;
|
||||
}
|
||||
|
||||
/// Génère des données de règlement fictives pour les 14 derniers jours
|
||||
static List<Map<String, dynamic>> generateMockPaymentData() {
|
||||
final List<Map<String, dynamic>> mockData = [];
|
||||
final now = DateTime.now();
|
||||
|
||||
for (int i = 13; i >= 0; i--) {
|
||||
final date = now.subtract(Duration(days: i));
|
||||
|
||||
// Ajouter des données pour chaque type de règlement
|
||||
for (int typeId = 1; typeId <= 3; typeId++) {
|
||||
// Générer un montant aléatoire
|
||||
final amount = (typeId * 100.0) + (date.day * 10.0);
|
||||
|
||||
mockData.add({
|
||||
'date': date,
|
||||
'type_reglement': typeId,
|
||||
'montant': amount,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return mockData;
|
||||
}
|
||||
|
||||
/// Obtenir l'icône correspondant au type de règlement
|
||||
/// Retourne un IconData pour les règlements car ils n'ont pas de chemin d'icône défini dans AppKeys
|
||||
static IconData _getIconForPaymentType(int typeId) {
|
||||
switch (typeId) {
|
||||
case 1: // Espèces
|
||||
return Icons.payments;
|
||||
case 2: // Chèque
|
||||
return Icons.money;
|
||||
case 3: // CB
|
||||
return Icons.credit_card;
|
||||
default:
|
||||
return Icons.euro;
|
||||
}
|
||||
}
|
||||
|
||||
/// Formater une date pour l'affichage dans les graphiques
|
||||
static String formatDateForChart(DateTime date, String periodType) {
|
||||
switch (periodType.toLowerCase()) {
|
||||
case 'jour':
|
||||
return DateFormat('dd/MM').format(date);
|
||||
case 'semaine':
|
||||
// Calculer le numéro de la semaine dans l'année
|
||||
final firstDayOfYear = DateTime(date.year, 1, 1);
|
||||
final dayOfYear = date.difference(firstDayOfYear).inDays;
|
||||
final weekNumber =
|
||||
((dayOfYear + firstDayOfYear.weekday - 1) / 7).ceil();
|
||||
return 'S$weekNumber';
|
||||
case 'mois':
|
||||
return DateFormat('MMM').format(date);
|
||||
case 'année':
|
||||
return DateFormat('yyyy').format(date);
|
||||
default:
|
||||
return DateFormat('dd/MM').format(date);
|
||||
}
|
||||
}
|
||||
}
|
||||
27
app/lib/presentation/widgets/charts/payment_data.dart
Normal file
27
app/lib/presentation/widgets/charts/payment_data.dart
Normal file
@@ -0,0 +1,27 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Modèle de données pour représenter un type de règlement avec son montant
|
||||
class PaymentData {
|
||||
/// Identifiant du type de règlement (1: Espèces, 2: Chèques, 3: CB)
|
||||
final int typeId;
|
||||
|
||||
/// Montant du règlement
|
||||
final double amount;
|
||||
|
||||
/// Couleur associée au type de règlement
|
||||
final Color color;
|
||||
|
||||
/// Icône associée au type de règlement
|
||||
final IconData icon;
|
||||
|
||||
/// Titre du type de règlement
|
||||
final String title;
|
||||
|
||||
const PaymentData({
|
||||
required this.typeId,
|
||||
required this.amount,
|
||||
required this.color,
|
||||
required this.icon,
|
||||
required this.title,
|
||||
});
|
||||
}
|
||||
407
app/lib/presentation/widgets/charts/payment_pie_chart.dart
Normal file
407
app/lib/presentation/widgets/charts/payment_pie_chart.dart
Normal file
@@ -0,0 +1,407 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:syncfusion_flutter_charts/charts.dart';
|
||||
import 'package:geosector_app/presentation/widgets/charts/payment_data.dart';
|
||||
import 'dart:math' as math;
|
||||
|
||||
/// Widget de graphique en camembert pour représenter la répartition des règlements
|
||||
class PaymentPieChart extends StatefulWidget {
|
||||
/// Liste des données de règlement à afficher dans le graphique
|
||||
final List<PaymentData> payments;
|
||||
|
||||
/// Taille du graphique
|
||||
final double size;
|
||||
|
||||
/// Taille des étiquettes
|
||||
final double labelSize;
|
||||
|
||||
/// Afficher les pourcentages
|
||||
final bool showPercentage;
|
||||
|
||||
/// Afficher les icônes
|
||||
final bool showIcons;
|
||||
|
||||
/// Afficher la légende
|
||||
final bool showLegend;
|
||||
|
||||
/// Format donut (anneau)
|
||||
final bool isDonut;
|
||||
|
||||
/// Rayon central pour le format donut (en pourcentage)
|
||||
final String innerRadius;
|
||||
|
||||
/// Activer l'effet 3D
|
||||
final bool enable3DEffect;
|
||||
|
||||
/// Intensité de l'effet 3D (1.0 = normal, 2.0 = fort)
|
||||
final double effect3DIntensity;
|
||||
|
||||
/// Activer l'effet d'explosion plus prononcé
|
||||
final bool enableEnhancedExplode;
|
||||
|
||||
/// Utiliser un dégradé pour simuler l'effet 3D
|
||||
final bool useGradient;
|
||||
|
||||
const PaymentPieChart({
|
||||
super.key,
|
||||
required this.payments,
|
||||
this.size = 300,
|
||||
this.labelSize = 12,
|
||||
this.showPercentage = true,
|
||||
this.showIcons = true,
|
||||
this.showLegend = true,
|
||||
this.isDonut = false,
|
||||
this.innerRadius = '40%',
|
||||
this.enable3DEffect = false,
|
||||
this.effect3DIntensity = 1.0,
|
||||
this.enableEnhancedExplode = false,
|
||||
this.useGradient = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PaymentPieChart> createState() => _PaymentPieChartState();
|
||||
}
|
||||
|
||||
class _PaymentPieChartState extends State<PaymentPieChart>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _animationController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_animationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 2000),
|
||||
);
|
||||
|
||||
_animationController.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(PaymentPieChart oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
// Relancer l'animation si les données ont changé
|
||||
// Utiliser une comparaison plus stricte pour éviter des animations inutiles
|
||||
bool shouldResetAnimation = false;
|
||||
|
||||
if (oldWidget.payments.length != widget.payments.length) {
|
||||
shouldResetAnimation = true;
|
||||
} else {
|
||||
// Comparer les éléments importants uniquement
|
||||
for (int i = 0; i < oldWidget.payments.length; i++) {
|
||||
if (i >= widget.payments.length) break;
|
||||
if (oldWidget.payments[i].amount != widget.payments[i].amount ||
|
||||
oldWidget.payments[i].title != widget.payments[i].title) {
|
||||
shouldResetAnimation = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldResetAnimation) {
|
||||
_animationController.reset();
|
||||
_animationController.forward();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Prépare les données pour le graphique en camembert
|
||||
List<PaymentData> _prepareChartData() {
|
||||
// Filtrer les règlements avec un montant > 0
|
||||
return widget.payments.where((payment) => payment.amount > 0).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final chartData = _prepareChartData();
|
||||
|
||||
// Si aucune donnée, afficher un message
|
||||
if (chartData.isEmpty) {
|
||||
return SizedBox(
|
||||
width: widget.size,
|
||||
height: widget.size,
|
||||
child: const Center(
|
||||
child: Text('Aucune donnée disponible'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Créer des animations pour différents aspects du graphique
|
||||
final progressAnimation = CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
|
||||
final explodeAnimation = CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Interval(0.7, 1.0, curve: Curves.elasticOut),
|
||||
);
|
||||
|
||||
final opacityAnimation = CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Interval(0.1, 0.5, curve: Curves.easeIn),
|
||||
);
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _animationController,
|
||||
builder: (context, child) {
|
||||
return SizedBox(
|
||||
width: widget.size,
|
||||
height: widget.size,
|
||||
child: SfCircularChart(
|
||||
margin: EdgeInsets.zero,
|
||||
legend: Legend(
|
||||
isVisible: widget.showLegend,
|
||||
position: LegendPosition.bottom,
|
||||
overflowMode: LegendItemOverflowMode.wrap,
|
||||
textStyle: TextStyle(fontSize: widget.labelSize),
|
||||
),
|
||||
tooltipBehavior: TooltipBehavior(enable: true),
|
||||
series: <CircularSeries>[
|
||||
widget.isDonut
|
||||
? DoughnutSeries<PaymentData, String>(
|
||||
dataSource: chartData,
|
||||
xValueMapper: (PaymentData data, _) => data.title,
|
||||
yValueMapper: (PaymentData data, _) => data.amount,
|
||||
pointColorMapper: (PaymentData data, _) {
|
||||
if (widget.enable3DEffect) {
|
||||
// Utiliser un angle différent pour chaque segment pour simuler un effet 3D
|
||||
final index = chartData.indexOf(data);
|
||||
final angle =
|
||||
(index / chartData.length) * 2 * math.pi;
|
||||
return widget.useGradient
|
||||
? _createEnhanced3DColor(data.color, angle)
|
||||
: _create3DColor(
|
||||
data.color, widget.effect3DIntensity);
|
||||
}
|
||||
return data.color;
|
||||
},
|
||||
// Note: Le gradient n'est pas directement pris en charge dans cette version de Syncfusion
|
||||
enableTooltip: true,
|
||||
dataLabelMapper: (PaymentData data, _) {
|
||||
if (widget.showPercentage) {
|
||||
// Calculer le pourcentage avec une décimale
|
||||
final total = chartData.fold(
|
||||
0.0, (sum, item) => sum + item.amount);
|
||||
final percentage = (data.amount / total * 100);
|
||||
return '${percentage.toStringAsFixed(1)}%';
|
||||
} else {
|
||||
return data.title;
|
||||
}
|
||||
},
|
||||
dataLabelSettings: DataLabelSettings(
|
||||
isVisible: true,
|
||||
labelPosition: ChartDataLabelPosition
|
||||
.inside, // Afficher les étiquettes à l'intérieur du donut
|
||||
textStyle: TextStyle(
|
||||
fontSize: widget.labelSize,
|
||||
color: Colors
|
||||
.white, // Texte blanc pour meilleure lisibilité
|
||||
fontWeight: FontWeight
|
||||
.bold, // Texte en gras pour meilleure lisibilité
|
||||
),
|
||||
),
|
||||
innerRadius: widget.innerRadius,
|
||||
// Effet d'explosion plus prononcé pour donner du relief avec animation
|
||||
explode: true,
|
||||
explodeAll: widget.enableEnhancedExplode,
|
||||
explodeIndex: widget.enableEnhancedExplode ? null : 0,
|
||||
explodeOffset: widget.enableEnhancedExplode
|
||||
? widget.enable3DEffect
|
||||
? '${(12 * explodeAnimation.value).toStringAsFixed(1)}%'
|
||||
: '${(8 * explodeAnimation.value).toStringAsFixed(1)}%'
|
||||
: '${(5 * explodeAnimation.value).toStringAsFixed(1)}%',
|
||||
// Effet 3D via l'opacité et les couleurs avec animation
|
||||
opacity: widget.enable3DEffect
|
||||
? 0.95 * opacityAnimation.value
|
||||
: opacityAnimation.value,
|
||||
// Animation progressive du graphique
|
||||
animationDuration:
|
||||
0, // On désactive l'animation intégrée car nous utilisons notre propre animation
|
||||
startAngle: 270,
|
||||
endAngle: 270 + (360 * progressAnimation.value).toInt(),
|
||||
)
|
||||
: PieSeries<PaymentData, String>(
|
||||
dataSource: chartData,
|
||||
xValueMapper: (PaymentData data, _) => data.title,
|
||||
yValueMapper: (PaymentData data, _) => data.amount,
|
||||
pointColorMapper: (PaymentData data, _) {
|
||||
if (widget.enable3DEffect) {
|
||||
// Utiliser un angle différent pour chaque segment pour simuler un effet 3D
|
||||
final index = chartData.indexOf(data);
|
||||
final angle =
|
||||
(index / chartData.length) * 2 * math.pi;
|
||||
return widget.useGradient
|
||||
? _createEnhanced3DColor(data.color, angle)
|
||||
: _create3DColor(
|
||||
data.color, widget.effect3DIntensity);
|
||||
}
|
||||
return data.color;
|
||||
},
|
||||
// Note: Le gradient n'est pas directement pris en charge dans cette version de Syncfusion
|
||||
enableTooltip: true,
|
||||
dataLabelMapper: (PaymentData data, _) {
|
||||
if (widget.showPercentage) {
|
||||
// Calculer le pourcentage avec une décimale
|
||||
final total = chartData.fold(
|
||||
0.0, (sum, item) => sum + item.amount);
|
||||
final percentage = (data.amount / total * 100);
|
||||
return '${percentage.toStringAsFixed(1)}%';
|
||||
} else {
|
||||
return data.title;
|
||||
}
|
||||
},
|
||||
dataLabelSettings: DataLabelSettings(
|
||||
isVisible: true,
|
||||
labelPosition: ChartDataLabelPosition.outside,
|
||||
textStyle: TextStyle(fontSize: widget.labelSize),
|
||||
connectorLineSettings: const ConnectorLineSettings(
|
||||
type: ConnectorType.curve,
|
||||
length: '15%',
|
||||
),
|
||||
),
|
||||
// Effet d'explosion plus prononcé pour donner du relief avec animation
|
||||
explode: true,
|
||||
explodeAll: widget.enableEnhancedExplode,
|
||||
explodeIndex: widget.enableEnhancedExplode ? null : 0,
|
||||
explodeOffset: widget.enableEnhancedExplode
|
||||
? widget.enable3DEffect
|
||||
? '${(12 * explodeAnimation.value).toStringAsFixed(1)}%'
|
||||
: '${(8 * explodeAnimation.value).toStringAsFixed(1)}%'
|
||||
: '${(5 * explodeAnimation.value).toStringAsFixed(1)}%',
|
||||
// Effet 3D via l'opacité et les couleurs avec animation
|
||||
opacity: widget.enable3DEffect
|
||||
? 0.95 * opacityAnimation.value
|
||||
: opacityAnimation.value,
|
||||
// Animation progressive du graphique
|
||||
animationDuration:
|
||||
0, // On désactive l'animation intégrée car nous utilisons notre propre animation
|
||||
startAngle: 270,
|
||||
endAngle: 270 + (360 * progressAnimation.value).toInt(),
|
||||
),
|
||||
],
|
||||
annotations:
|
||||
widget.showIcons ? _buildIconAnnotations(chartData) : null,
|
||||
// Paramètres pour améliorer l'effet 3D
|
||||
palette: widget.enable3DEffect ? _create3DPalette(chartData) : null,
|
||||
// Ajouter un effet de bordure pour renforcer l'effet 3D
|
||||
borderWidth: widget.enable3DEffect ? 0.5 : 0,
|
||||
// Note: La rotation n'est pas directement prise en charge dans cette version de Syncfusion
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Crée une couleur avec effet 3D en ajoutant des nuances
|
||||
Color _create3DColor(Color baseColor, double intensity) {
|
||||
// Ajuster la luminosité et la saturation pour créer un effet 3D plus prononcé
|
||||
final hslColor = HSLColor.fromColor(baseColor);
|
||||
|
||||
// Augmenter la luminosité pour simuler un éclairage
|
||||
final adjustedLightness =
|
||||
(hslColor.lightness + 0.15 * intensity).clamp(0.0, 1.0);
|
||||
|
||||
// Augmenter légèrement la saturation pour des couleurs plus vives
|
||||
final adjustedSaturation =
|
||||
(hslColor.saturation + 0.05 * intensity).clamp(0.0, 1.0);
|
||||
|
||||
return hslColor
|
||||
.withLightness(adjustedLightness)
|
||||
.withSaturation(adjustedSaturation)
|
||||
.toColor();
|
||||
}
|
||||
|
||||
/// Crée une palette de couleurs pour l'effet 3D
|
||||
List<Color> _create3DPalette(List<PaymentData> chartData) {
|
||||
List<Color> palette = [];
|
||||
|
||||
// Créer des variations de couleurs pour chaque segment
|
||||
for (var i = 0; i < chartData.length; i++) {
|
||||
var data = chartData[i];
|
||||
|
||||
// Calculer un angle pour chaque segment pour simuler un éclairage directionnel
|
||||
final angle = (i / chartData.length) * 2 * math.pi;
|
||||
|
||||
// Créer un effet d'ombre et de lumière en fonction de l'angle
|
||||
final hslColor = HSLColor.fromColor(data.color);
|
||||
|
||||
// Ajuster la luminosité en fonction de l'angle
|
||||
final lightAdjustment = 0.15 * widget.effect3DIntensity * math.sin(angle);
|
||||
final adjustedLightness = (hslColor.lightness -
|
||||
0.1 * widget.effect3DIntensity +
|
||||
lightAdjustment)
|
||||
.clamp(0.0, 1.0);
|
||||
|
||||
// Ajuster la saturation pour plus de profondeur
|
||||
final adjustedSaturation =
|
||||
(hslColor.saturation + 0.1 * widget.effect3DIntensity)
|
||||
.clamp(0.0, 1.0);
|
||||
|
||||
final enhancedColor = hslColor
|
||||
.withLightness(adjustedLightness)
|
||||
.withSaturation(adjustedSaturation)
|
||||
.toColor();
|
||||
|
||||
palette.add(enhancedColor);
|
||||
}
|
||||
|
||||
return palette;
|
||||
}
|
||||
|
||||
/// Crée une couleur avec effet 3D plus avancé
|
||||
Color _createEnhanced3DColor(Color baseColor, double angle) {
|
||||
// Simuler un effet de lumière directionnel
|
||||
final hslColor = HSLColor.fromColor(baseColor);
|
||||
|
||||
// Ajuster la luminosité en fonction de l'angle pour simuler un éclairage
|
||||
final adjustedLightness = hslColor.lightness +
|
||||
(0.2 * widget.effect3DIntensity * math.sin(angle)).clamp(-0.3, 0.3);
|
||||
|
||||
return hslColor.withLightness(adjustedLightness.clamp(0.0, 1.0)).toColor();
|
||||
}
|
||||
|
||||
/// Crée les annotations d'icônes pour le graphique
|
||||
List<CircularChartAnnotation> _buildIconAnnotations(
|
||||
List<PaymentData> chartData) {
|
||||
final List<CircularChartAnnotation> annotations = [];
|
||||
|
||||
// Calculer le total pour les pourcentages
|
||||
double total = chartData.fold(0.0, (sum, item) => sum + item.amount);
|
||||
|
||||
// Position angulaire actuelle (en radians)
|
||||
double currentAngle = 0;
|
||||
|
||||
for (int i = 0; i < chartData.length; i++) {
|
||||
final data = chartData[i];
|
||||
final percentage = data.amount / total;
|
||||
|
||||
// Calculer l'angle central de ce segment
|
||||
final segmentAngle = percentage * 2 * 3.14159;
|
||||
final midAngle = currentAngle + (segmentAngle / 2);
|
||||
|
||||
// Ajouter une annotation pour l'icône
|
||||
annotations.add(
|
||||
CircularChartAnnotation(
|
||||
widget: Icon(
|
||||
data.icon,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
),
|
||||
radius: '50%',
|
||||
angle: (midAngle * (180 / 3.14159)).toInt(), // Convertir en degrés
|
||||
),
|
||||
);
|
||||
|
||||
// Mettre à jour l'angle actuel
|
||||
currentAngle += segmentAngle;
|
||||
}
|
||||
|
||||
return annotations;
|
||||
}
|
||||
}
|
||||
33
app/lib/presentation/widgets/charts/payment_utils.dart
Normal file
33
app/lib/presentation/widgets/charts/payment_utils.dart
Normal file
@@ -0,0 +1,33 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/presentation/widgets/charts/payment_data.dart';
|
||||
|
||||
/// Utilitaires pour les paiements et règlements
|
||||
class PaymentUtils {
|
||||
/// Convertit les données de règlement depuis les constantes AppKeys
|
||||
///
|
||||
/// [paymentAmounts] est une Map associant l'ID du type de règlement à son montant
|
||||
static List<PaymentData> getPaymentDataFromAmounts(
|
||||
Map<int, double> paymentAmounts) {
|
||||
final List<PaymentData> paymentDataList = [];
|
||||
|
||||
// Parcourir tous les types de règlements définis dans AppKeys
|
||||
AppKeys.typesReglements.forEach((typeId, typeData) {
|
||||
// Vérifier si nous avons un montant pour ce type de règlement
|
||||
final double amount = paymentAmounts[typeId] ?? 0.0;
|
||||
|
||||
// Créer un objet PaymentData pour ce type de règlement
|
||||
final PaymentData paymentData = PaymentData(
|
||||
typeId: typeId,
|
||||
amount: amount,
|
||||
color: Color(typeData['couleur'] as int),
|
||||
icon: typeData['icon_data'] as IconData,
|
||||
title: typeData['titre'] as String,
|
||||
);
|
||||
|
||||
paymentDataList.add(paymentData);
|
||||
});
|
||||
|
||||
return paymentDataList;
|
||||
}
|
||||
}
|
||||
219
app/lib/presentation/widgets/chat/chat_input.dart
Normal file
219
app/lib/presentation/widgets/chat/chat_input.dart
Normal file
@@ -0,0 +1,219 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/shared/app_theme.dart';
|
||||
|
||||
/// Widget pour la zone de saisie des messages
|
||||
class ChatInput extends StatefulWidget {
|
||||
final Function(String) onMessageSent;
|
||||
|
||||
const ChatInput({
|
||||
Key? key,
|
||||
required this.onMessageSent,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<ChatInput> createState() => _ChatInputState();
|
||||
}
|
||||
|
||||
class _ChatInputState extends State<ChatInput> {
|
||||
final TextEditingController _controller = TextEditingController();
|
||||
bool _isComposing = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppTheme.spacingM,
|
||||
vertical: AppTheme.spacingS,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 5,
|
||||
offset: const Offset(0, -2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Bouton pour ajouter des pièces jointes
|
||||
IconButton(
|
||||
icon: const Icon(Icons.attach_file),
|
||||
color: AppTheme.primaryColor,
|
||||
onPressed: () {
|
||||
// Afficher les options de pièces jointes
|
||||
_showAttachmentOptions(context);
|
||||
},
|
||||
),
|
||||
|
||||
// Champ de saisie du message
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Écrivez votre message...',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: Colors.grey[100],
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
),
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
maxLines: null,
|
||||
keyboardType: TextInputType.multiline,
|
||||
textInputAction: TextInputAction.newline,
|
||||
onChanged: (text) {
|
||||
setState(() {
|
||||
_isComposing = text.isNotEmpty;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Bouton d'envoi
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
_isComposing ? Icons.send : Icons.mic,
|
||||
color: _isComposing ? AppTheme.primaryColor : Colors.grey[600],
|
||||
),
|
||||
onPressed: _isComposing
|
||||
? () {
|
||||
final text = _controller.text.trim();
|
||||
if (text.isNotEmpty) {
|
||||
widget.onMessageSent(text);
|
||||
_controller.clear();
|
||||
setState(() {
|
||||
_isComposing = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
: () {
|
||||
// Activer la reconnaissance vocale
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Afficher les options de pièces jointes
|
||||
void _showAttachmentOptions(BuildContext context) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: AppTheme.spacingL,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
'Ajouter une pièce jointe',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingL),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
_buildAttachmentOption(
|
||||
context,
|
||||
Icons.photo,
|
||||
'Photo',
|
||||
Colors.green,
|
||||
() {
|
||||
Navigator.pop(context);
|
||||
// Sélectionner une photo
|
||||
},
|
||||
),
|
||||
_buildAttachmentOption(
|
||||
context,
|
||||
Icons.camera_alt,
|
||||
'Caméra',
|
||||
Colors.blue,
|
||||
() {
|
||||
Navigator.pop(context);
|
||||
// Prendre une photo
|
||||
},
|
||||
),
|
||||
_buildAttachmentOption(
|
||||
context,
|
||||
Icons.insert_drive_file,
|
||||
'Document',
|
||||
Colors.orange,
|
||||
() {
|
||||
Navigator.pop(context);
|
||||
// Sélectionner un document
|
||||
},
|
||||
),
|
||||
_buildAttachmentOption(
|
||||
context,
|
||||
Icons.location_on,
|
||||
'Position',
|
||||
Colors.red,
|
||||
() {
|
||||
Navigator.pop(context);
|
||||
// Partager la position
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingL),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Construire une option de pièce jointe
|
||||
Widget _buildAttachmentOption(
|
||||
BuildContext context,
|
||||
IconData icon,
|
||||
String label,
|
||||
Color color,
|
||||
VoidCallback onTap,
|
||||
) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
color: color,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: Colors.grey[800],
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
245
app/lib/presentation/widgets/chat/chat_messages.dart
Normal file
245
app/lib/presentation/widgets/chat/chat_messages.dart
Normal file
@@ -0,0 +1,245 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/shared/app_theme.dart';
|
||||
|
||||
/// Widget pour afficher les messages d'une conversation
|
||||
class ChatMessages extends StatelessWidget {
|
||||
final List<Map<String, dynamic>> messages;
|
||||
final int currentUserId;
|
||||
final Function(Map<String, dynamic>) onReply;
|
||||
|
||||
const ChatMessages({
|
||||
Key? key,
|
||||
required this.messages,
|
||||
required this.currentUserId,
|
||||
required this.onReply,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return messages.isEmpty
|
||||
? const Center(
|
||||
child: Text('Aucun message dans cette conversation'),
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.all(AppTheme.spacingM),
|
||||
itemCount: messages.length,
|
||||
reverse:
|
||||
false, // Afficher les messages du plus ancien au plus récent
|
||||
itemBuilder: (context, index) {
|
||||
final message = messages[index];
|
||||
final isCurrentUser = message['senderId'] == currentUserId;
|
||||
final hasReply = message['replyTo'] != null;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppTheme.spacingM),
|
||||
child: Column(
|
||||
crossAxisAlignment: isCurrentUser
|
||||
? CrossAxisAlignment.end
|
||||
: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Afficher le message auquel on répond
|
||||
if (hasReply) ...[
|
||||
Container(
|
||||
margin: EdgeInsets.only(
|
||||
left: isCurrentUser ? 0 : 40,
|
||||
right: isCurrentUser ? 40 : 0,
|
||||
bottom: 4,
|
||||
),
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[200],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Réponse à ${message['replyTo']['senderName']}',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
message['replyTo']['message'],
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
// Message principal
|
||||
Row(
|
||||
mainAxisAlignment: isCurrentUser
|
||||
? MainAxisAlignment.end
|
||||
: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Avatar (seulement pour les messages des autres)
|
||||
if (!isCurrentUser)
|
||||
CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor:
|
||||
AppTheme.primaryColor.withOpacity(0.2),
|
||||
backgroundImage: message['avatar'] != null
|
||||
? AssetImage(message['avatar'] as String)
|
||||
: null,
|
||||
child: message['avatar'] == null
|
||||
? Text(
|
||||
message['senderName'].isNotEmpty
|
||||
? message['senderName'][0].toUpperCase()
|
||||
: '',
|
||||
style: const TextStyle(
|
||||
color: AppTheme.primaryColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// Contenu du message
|
||||
Flexible(
|
||||
child: Column(
|
||||
crossAxisAlignment: isCurrentUser
|
||||
? CrossAxisAlignment.end
|
||||
: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Nom de l'expéditeur (seulement pour les messages des autres)
|
||||
if (!isCurrentUser)
|
||||
Padding(
|
||||
padding:
|
||||
const EdgeInsets.only(left: 4, bottom: 2),
|
||||
child: Text(
|
||||
message['senderName'],
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Bulle de message
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isCurrentUser
|
||||
? AppTheme.primaryColor
|
||||
: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 3,
|
||||
offset: const Offset(0, 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Text(
|
||||
message['message'],
|
||||
style: TextStyle(
|
||||
color: isCurrentUser
|
||||
? Colors.white
|
||||
: Colors.black87,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Heure et statut
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4, left: 4),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
_formatTime(message['time']),
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
if (isCurrentUser)
|
||||
Icon(
|
||||
message['isRead']
|
||||
? Icons.done_all
|
||||
: Icons.done,
|
||||
size: 12,
|
||||
color: message['isRead']
|
||||
? Colors.blue
|
||||
: Colors.grey[600],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// Menu d'actions (seulement pour les messages des autres)
|
||||
if (!isCurrentUser)
|
||||
PopupMenuButton<String>(
|
||||
icon: Icon(
|
||||
Icons.more_vert,
|
||||
size: 16,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem<String>(
|
||||
value: 'reply',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.reply, size: 16),
|
||||
SizedBox(width: 8),
|
||||
Text('Répondre'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuItem<String>(
|
||||
value: 'copy',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.content_copy, size: 16),
|
||||
SizedBox(width: 8),
|
||||
Text('Copier'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
onSelected: (value) {
|
||||
if (value == 'reply') {
|
||||
onReply(message);
|
||||
} else if (value == 'copy') {
|
||||
// Copier le message
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Formater l'heure du message
|
||||
String _formatTime(DateTime time) {
|
||||
return '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
219
app/lib/presentation/widgets/chat/chat_sidebar.dart
Normal file
219
app/lib/presentation/widgets/chat/chat_sidebar.dart
Normal file
@@ -0,0 +1,219 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/shared/app_theme.dart';
|
||||
|
||||
/// Widget pour afficher la barre latérale des contacts
|
||||
class ChatSidebar extends StatelessWidget {
|
||||
final List<Map<String, dynamic>> teamContacts;
|
||||
final List<Map<String, dynamic>> clientContacts;
|
||||
final bool isTeamChat;
|
||||
final int selectedContactId;
|
||||
final Function(int, String, bool) onContactSelected;
|
||||
final Function(bool) onToggleGroup;
|
||||
|
||||
const ChatSidebar({
|
||||
Key? key,
|
||||
required this.teamContacts,
|
||||
required this.clientContacts,
|
||||
required this.isTeamChat,
|
||||
required this.selectedContactId,
|
||||
required this.onContactSelected,
|
||||
required this.onToggleGroup,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
// En-tête avec les onglets
|
||||
Container(
|
||||
padding: const EdgeInsets.all(AppTheme.spacingM),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 5,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildTabButton(
|
||||
context,
|
||||
'Équipe',
|
||||
isTeamChat,
|
||||
() => onToggleGroup(true),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppTheme.spacingS),
|
||||
Expanded(
|
||||
child: _buildTabButton(
|
||||
context,
|
||||
'Clients',
|
||||
!isTeamChat,
|
||||
() => onToggleGroup(false),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Liste des contacts
|
||||
Expanded(
|
||||
child: Container(
|
||||
color: Colors.grey[100],
|
||||
child: ListView(
|
||||
padding: EdgeInsets.zero,
|
||||
children: [
|
||||
// Afficher les contacts appropriés en fonction de l'onglet sélectionné
|
||||
...isTeamChat
|
||||
? teamContacts.map(
|
||||
(contact) => _buildContactItem(context, contact, true))
|
||||
: clientContacts.map((contact) =>
|
||||
_buildContactItem(context, contact, false)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Construire un bouton d'onglet
|
||||
Widget _buildTabButton(
|
||||
BuildContext context,
|
||||
String label,
|
||||
bool isSelected,
|
||||
VoidCallback onPressed,
|
||||
) {
|
||||
return ElevatedButton(
|
||||
onPressed: onPressed,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: isSelected ? AppTheme.primaryColor : Colors.grey[200],
|
||||
foregroundColor: isSelected ? Colors.white : Colors.black,
|
||||
elevation: isSelected ? 2 : 0,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppTheme.borderRadiusSmall),
|
||||
),
|
||||
),
|
||||
child: Text(label),
|
||||
);
|
||||
}
|
||||
|
||||
// Construire un élément de contact
|
||||
Widget _buildContactItem(
|
||||
BuildContext context,
|
||||
Map<String, dynamic> contact,
|
||||
bool isTeam,
|
||||
) {
|
||||
final bool isSelected = contact['id'] == selectedContactId;
|
||||
final bool hasUnread = (contact['unread'] as int) > 0;
|
||||
|
||||
return ListTile(
|
||||
selected: isSelected,
|
||||
selectedTileColor: Colors.blue.withOpacity(0.1),
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: AppTheme.primaryColor.withOpacity(0.2),
|
||||
backgroundImage: contact['avatar'] != null
|
||||
? AssetImage(contact['avatar'] as String)
|
||||
: null,
|
||||
child: contact['avatar'] == null
|
||||
? Text(
|
||||
(contact['name'] as String).isNotEmpty
|
||||
? (contact['name'] as String)[0].toUpperCase()
|
||||
: '',
|
||||
style: const TextStyle(
|
||||
color: AppTheme.primaryColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
contact['name'] as String,
|
||||
style: TextStyle(
|
||||
fontWeight: hasUnread ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (contact['online'] == true)
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.green,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Text(
|
||||
contact['lastMessage'] as String,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontWeight: hasUnread ? FontWeight.bold : FontWeight.normal,
|
||||
color: hasUnread ? Colors.black87 : Colors.grey[600],
|
||||
),
|
||||
),
|
||||
trailing: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
_formatTime(contact['time'] as DateTime),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: hasUnread ? AppTheme.primaryColor : Colors.grey[500],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
if (hasUnread)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Text(
|
||||
(contact['unread'] as int).toString(),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () => onContactSelected(
|
||||
contact['id'] as int,
|
||||
contact['name'] as String,
|
||||
isTeam,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Formater l'heure du dernier message
|
||||
String _formatTime(DateTime time) {
|
||||
final now = DateTime.now();
|
||||
final today = DateTime(now.year, now.month, now.day);
|
||||
final yesterday = today.subtract(const Duration(days: 1));
|
||||
final messageDate = DateTime(time.year, time.month, time.day);
|
||||
|
||||
if (messageDate == today) {
|
||||
return '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}';
|
||||
} else if (messageDate == yesterday) {
|
||||
return 'Hier';
|
||||
} else {
|
||||
return '${time.day}/${time.month}';
|
||||
}
|
||||
}
|
||||
}
|
||||
153
app/lib/presentation/widgets/clear_cache_dialog.dart
Normal file
153
app/lib/presentation/widgets/clear_cache_dialog.dart
Normal file
@@ -0,0 +1,153 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Widget de dialogue pour informer l'utilisateur qu'il doit vider le cache de son navigateur
|
||||
/// et recharger l'application en raison d'une incompatibilité après une mise à jour.
|
||||
class ClearCacheDialog extends StatelessWidget {
|
||||
/// Callback appelé lorsque l'utilisateur ferme le dialogue
|
||||
final VoidCallback? onClose;
|
||||
|
||||
const ClearCacheDialog({
|
||||
Key? key,
|
||||
this.onClose,
|
||||
}) : super(key: key);
|
||||
|
||||
/// Affiche le dialogue de nettoyage du cache
|
||||
static Future<void> show(BuildContext context,
|
||||
{VoidCallback? onClose}) async {
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible:
|
||||
false, // L'utilisateur doit appuyer sur un bouton pour fermer le dialogue
|
||||
builder: (BuildContext dialogContext) {
|
||||
return ClearCacheDialog(onClose: onClose);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return AlertDialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.warning_amber_rounded,
|
||||
color: Colors.orange,
|
||||
size: 28,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'Mise à jour requise',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Une incompatibilité a été détectée avec les données stockées localement. Veuillez suivre ces étapes pour résoudre le problème :',
|
||||
style: theme.textTheme.bodyLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildInstructionStep(
|
||||
context,
|
||||
1,
|
||||
'Videz le cache de votre navigateur',
|
||||
'Dans Chrome : Menu > Plus d\'outils > Effacer les données de navigation > Sélectionnez "Cookies et données de site" > Effacer les données'),
|
||||
const SizedBox(height: 12),
|
||||
_buildInstructionStep(
|
||||
context,
|
||||
2,
|
||||
'Fermez complètement le navigateur',
|
||||
'Assurez-vous de fermer toutes les fenêtres du navigateur'),
|
||||
const SizedBox(height: 12),
|
||||
_buildInstructionStep(context, 3, 'Rouvrez l\'application',
|
||||
'Reconnectez-vous à l\'application pour récupérer vos données depuis le serveur'),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Note : Cette opération est nécessaire en raison d\'une mise à jour de la structure des données. Toutes vos données seront récupérées depuis le serveur après reconnexion.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontStyle: FontStyle.italic,
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
if (onClose != null) {
|
||||
onClose!();
|
||||
}
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: theme.colorScheme.primary,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
),
|
||||
child: const Text('J\'ai compris'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit une étape d'instruction avec un numéro, un titre et une description
|
||||
Widget _buildInstructionStep(
|
||||
BuildContext context, int stepNumber, String title, String description) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'$stepNumber',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
description,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
156
app/lib/presentation/widgets/connectivity_indicator.dart
Normal file
156
app/lib/presentation/widgets/connectivity_indicator.dart
Normal file
@@ -0,0 +1,156 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/core/services/connectivity_service.dart';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
|
||||
/// Widget qui affiche l'état de la connexion Internet
|
||||
class ConnectivityIndicator extends StatelessWidget {
|
||||
/// Si true, affiche un message d'erreur lorsque l'appareil est déconnecté
|
||||
final bool showErrorMessage;
|
||||
|
||||
/// Si true, affiche un badge avec le type de connexion (WiFi, données mobiles)
|
||||
final bool showConnectionType;
|
||||
|
||||
/// Callback appelé lorsque l'état de la connexion change
|
||||
final Function(bool isConnected)? onConnectivityChanged;
|
||||
|
||||
const ConnectivityIndicator({
|
||||
super.key,
|
||||
this.showErrorMessage = true,
|
||||
this.showConnectionType = true,
|
||||
this.onConnectivityChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
// Utiliser l'instance globale de connectivityService définie dans app.dart
|
||||
final isConnected = connectivityService.isConnected;
|
||||
final connectionType = connectivityService.connectionType;
|
||||
final connectionStatus = connectivityService.connectionStatus;
|
||||
|
||||
// Appeler le callback si fourni, mais pas directement dans le build
|
||||
// pour éviter les problèmes de rendu
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (onConnectivityChanged != null) {
|
||||
onConnectivityChanged!(isConnected);
|
||||
}
|
||||
});
|
||||
|
||||
if (!isConnected && showErrorMessage) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.error.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.error.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.wifi_off,
|
||||
color: theme.colorScheme.error,
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Aucune connexion Internet. Certaines fonctionnalités peuvent être limitées.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else if (isConnected && showConnectionType) {
|
||||
// Obtenir la couleur et l'icône en fonction du type de connexion
|
||||
final color = _getConnectionColor(connectionStatus, theme);
|
||||
final icon = _getConnectionIcon(connectionStatus);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: color.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
color: color,
|
||||
size: 14,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
connectionType,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: color,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Si aucune condition n'est remplie ou si showErrorMessage et showConnectionType sont false
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
/// Retourne l'icône correspondant au type de connexion
|
||||
IconData _getConnectionIcon(List<ConnectivityResult> statusList) {
|
||||
// Utiliser le premier type de connexion qui n'est pas 'none'
|
||||
ConnectivityResult status = statusList.firstWhere(
|
||||
(result) => result != ConnectivityResult.none,
|
||||
orElse: () => ConnectivityResult.none);
|
||||
|
||||
switch (status) {
|
||||
case ConnectivityResult.wifi:
|
||||
return Icons.wifi;
|
||||
case ConnectivityResult.mobile:
|
||||
return Icons.signal_cellular_alt;
|
||||
case ConnectivityResult.ethernet:
|
||||
return Icons.lan;
|
||||
case ConnectivityResult.bluetooth:
|
||||
return Icons.bluetooth;
|
||||
case ConnectivityResult.vpn:
|
||||
return Icons.vpn_key;
|
||||
default:
|
||||
return Icons.wifi_off;
|
||||
}
|
||||
}
|
||||
|
||||
/// Retourne la couleur correspondant au type de connexion
|
||||
Color _getConnectionColor(
|
||||
List<ConnectivityResult> statusList, ThemeData theme) {
|
||||
// Utiliser le premier type de connexion qui n'est pas 'none'
|
||||
ConnectivityResult status = statusList.firstWhere(
|
||||
(result) => result != ConnectivityResult.none,
|
||||
orElse: () => ConnectivityResult.none);
|
||||
|
||||
switch (status) {
|
||||
case ConnectivityResult.wifi:
|
||||
return Colors.green;
|
||||
case ConnectivityResult.mobile:
|
||||
return Colors.blue;
|
||||
case ConnectivityResult.ethernet:
|
||||
return Colors.purple;
|
||||
case ConnectivityResult.bluetooth:
|
||||
return Colors.indigo;
|
||||
case ConnectivityResult.vpn:
|
||||
return Colors.orange;
|
||||
default:
|
||||
return theme.colorScheme.error;
|
||||
}
|
||||
}
|
||||
}
|
||||
71
app/lib/presentation/widgets/custom_button.dart
Normal file
71
app/lib/presentation/widgets/custom_button.dart
Normal file
@@ -0,0 +1,71 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class CustomButton extends StatelessWidget {
|
||||
final VoidCallback? onPressed;
|
||||
final String text;
|
||||
final IconData? icon;
|
||||
final bool isLoading;
|
||||
final double? width;
|
||||
final Color? backgroundColor;
|
||||
final Color? textColor;
|
||||
|
||||
const CustomButton({
|
||||
super.key,
|
||||
required this.onPressed,
|
||||
required this.text,
|
||||
this.icon,
|
||||
this.isLoading = false,
|
||||
this.width,
|
||||
this.backgroundColor,
|
||||
this.textColor,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return SizedBox(
|
||||
width: width,
|
||||
child: ElevatedButton(
|
||||
onPressed: isLoading ? null : onPressed,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: backgroundColor ?? theme.colorScheme.primary,
|
||||
foregroundColor: textColor ?? Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
elevation: 2,
|
||||
),
|
||||
child: isLoading
|
||||
? SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
textColor ?? Colors.white,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
159
app/lib/presentation/widgets/custom_text_field.dart
Normal file
159
app/lib/presentation/widgets/custom_text_field.dart
Normal file
@@ -0,0 +1,159 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
class CustomTextField extends StatelessWidget {
|
||||
final TextEditingController controller;
|
||||
final String label;
|
||||
final String? hintText;
|
||||
final IconData? prefixIcon;
|
||||
final Widget? suffixIcon;
|
||||
final bool obscureText;
|
||||
final TextInputType keyboardType;
|
||||
final String? Function(String?)? validator;
|
||||
final List<TextInputFormatter>? inputFormatters;
|
||||
final int? maxLines;
|
||||
final int? minLines;
|
||||
final bool readOnly;
|
||||
final VoidCallback? onTap;
|
||||
final Function(String)? onChanged;
|
||||
final bool autofocus;
|
||||
final FocusNode? focusNode;
|
||||
final String? errorText;
|
||||
final Color? fillColor;
|
||||
final String? helperText;
|
||||
final Function(String)? onFieldSubmitted;
|
||||
|
||||
const CustomTextField({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.label,
|
||||
this.hintText,
|
||||
this.prefixIcon,
|
||||
this.suffixIcon,
|
||||
this.obscureText = false,
|
||||
this.keyboardType = TextInputType.text,
|
||||
this.validator,
|
||||
this.inputFormatters,
|
||||
this.maxLines = 1,
|
||||
this.minLines,
|
||||
this.readOnly = false,
|
||||
this.onTap,
|
||||
this.onChanged,
|
||||
this.autofocus = false,
|
||||
this.focusNode,
|
||||
this.errorText,
|
||||
this.fillColor,
|
||||
this.helperText,
|
||||
this.onFieldSubmitted,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (label.isNotEmpty) ...[
|
||||
Text(
|
||||
label,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: theme.colorScheme.onBackground,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
// Ajouter un Container avec une ombre pour créer un effet d'élévation
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: TextFormField(
|
||||
controller: controller,
|
||||
obscureText: obscureText,
|
||||
keyboardType: keyboardType,
|
||||
validator: validator,
|
||||
inputFormatters: inputFormatters,
|
||||
maxLines: maxLines,
|
||||
minLines: minLines,
|
||||
readOnly: readOnly,
|
||||
onTap: onTap,
|
||||
onChanged: onChanged,
|
||||
onFieldSubmitted: onFieldSubmitted,
|
||||
autofocus: autofocus,
|
||||
focusNode: focusNode,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: theme.colorScheme.onBackground,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
hintStyle: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: theme.colorScheme.onBackground.withOpacity(0.5),
|
||||
),
|
||||
errorText: errorText,
|
||||
helperText: helperText,
|
||||
helperStyle: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onBackground.withOpacity(0.6),
|
||||
),
|
||||
prefixIcon: prefixIcon != null
|
||||
? Icon(prefixIcon, color: theme.colorScheme.primary)
|
||||
: null,
|
||||
suffixIcon: suffixIcon,
|
||||
// Couleur de fond différente selon l'état (lecture seule ou éditable)
|
||||
fillColor: fillColor ??
|
||||
(readOnly
|
||||
? const Color(0xFFF8F9FA) // Gris plus clair pour readOnly
|
||||
: const Color(
|
||||
0xFFECEFF1)), // Gris plus foncé pour éditable
|
||||
filled: true,
|
||||
// Ajouter une élévation avec une petite ombre
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
// Ajouter une ombre pour créer un effet d'élévation
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
gapPadding: 0,
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: theme.colorScheme.primary,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: theme.colorScheme.error,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
focusedErrorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: theme.colorScheme.error,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
186
app/lib/presentation/widgets/dashboard_app_bar.dart
Normal file
186
app/lib/presentation/widgets/dashboard_app_bar.dart
Normal file
@@ -0,0 +1,186 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/app.dart';
|
||||
import 'package:geosector_app/presentation/widgets/connectivity_indicator.dart';
|
||||
import 'package:geosector_app/presentation/widgets/profile_dialog.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
/// AppBar personnalisée pour les tableaux de bord
|
||||
class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
/// Le titre principal de l'AppBar (généralement le nom de l'application)
|
||||
final String title;
|
||||
|
||||
/// Le titre de la page actuelle (optionnel)
|
||||
final String? pageTitle;
|
||||
|
||||
/// Actions supplémentaires à afficher dans l'AppBar
|
||||
final List<Widget>? additionalActions;
|
||||
|
||||
/// Indique si le bouton "Nouveau passage" doit être affiché
|
||||
final bool showNewPassageButton;
|
||||
|
||||
/// Callback appelé lorsque le bouton "Nouveau passage" est pressé
|
||||
final VoidCallback? onNewPassagePressed;
|
||||
|
||||
/// Indique si l'utilisateur est un administrateur
|
||||
final bool isAdmin;
|
||||
|
||||
/// Callback appelé lorsque le bouton de déconnexion est pressé
|
||||
final VoidCallback? onLogoutPressed;
|
||||
|
||||
const DashboardAppBar({
|
||||
Key? key,
|
||||
required this.title,
|
||||
this.pageTitle,
|
||||
this.additionalActions,
|
||||
this.showNewPassageButton = true,
|
||||
this.onNewPassagePressed,
|
||||
this.isAdmin = false,
|
||||
this.onLogoutPressed,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return AppBar(
|
||||
title: _buildTitle(context),
|
||||
backgroundColor: theme.colorScheme.primary,
|
||||
foregroundColor: theme.colorScheme.onPrimary,
|
||||
elevation: 4,
|
||||
leading: _buildLogo(),
|
||||
actions: _buildActions(context),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construction du logo dans l'AppBar
|
||||
Widget _buildLogo() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Image.asset(
|
||||
'assets/images/logo-geosector-1024.png',
|
||||
width: 40,
|
||||
height: 40,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construction des actions de l'AppBar
|
||||
List<Widget> _buildActions(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final List<Widget> actions = [];
|
||||
|
||||
// Ajouter l'indicateur de connectivité
|
||||
actions.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 12.0),
|
||||
child: const ConnectivityIndicator(
|
||||
showErrorMessage: false,
|
||||
showConnectionType: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Ajouter les actions supplémentaires si elles existent
|
||||
if (additionalActions != null && additionalActions!.isNotEmpty) {
|
||||
actions.addAll(additionalActions!);
|
||||
} else if (showNewPassageButton) {
|
||||
// Ajouter le bouton "Nouveau passage" en haut à droite
|
||||
actions.add(
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.add_location_alt, color: Colors.white),
|
||||
label: const Text('Nouveau passage',
|
||||
style: TextStyle(color: Colors.white)),
|
||||
onPressed: onNewPassagePressed,
|
||||
style: TextButton.styleFrom(
|
||||
backgroundColor: theme.colorScheme.secondary,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Ajouter le bouton "Mon compte"
|
||||
actions.add(
|
||||
IconButton(
|
||||
icon: const Icon(Icons.person),
|
||||
tooltip: 'Mon compte',
|
||||
onPressed: () {
|
||||
// Afficher la boîte de dialogue de profil avec l'utilisateur actuel
|
||||
final user = userRepository.currentUser;
|
||||
if (user != null) {
|
||||
ProfileDialog.show(context, user);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('Erreur: Utilisateur non trouvé'),
|
||||
backgroundColor: theme.colorScheme.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Ajouter le bouton de déconnexion
|
||||
actions.add(
|
||||
IconButton(
|
||||
icon: const Icon(Icons.logout),
|
||||
tooltip: 'Déconnexion',
|
||||
onPressed: onLogoutPressed ??
|
||||
() {
|
||||
// Si aucun callback n'est fourni, utiliser le userRepository global
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Déconnexion'),
|
||||
content:
|
||||
const Text('Voulez-vous vraiment vous déconnecter ?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
Navigator.of(context).pop();
|
||||
// Utiliser directement userRepository pour la déconnexion
|
||||
// qui gère à la fois le nettoyage des données et la redirection
|
||||
await userRepository.logoutWithUI(context);
|
||||
// La redirection est gérée dans logoutWithUI
|
||||
},
|
||||
child: const Text('Déconnexion'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
actions.add(const SizedBox(width: 8)); // Espacement à droite
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
/// Construction du titre de l'AppBar
|
||||
Widget _buildTitle(BuildContext context) {
|
||||
// Si aucun titre de page n'est fourni, afficher simplement le titre principal
|
||||
if (pageTitle == null) {
|
||||
return Text(title);
|
||||
}
|
||||
|
||||
// Construire un titre composé en fonction du rôle de l'utilisateur
|
||||
final String prefix = isAdmin ? 'Administration' : title;
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(prefix),
|
||||
const Text(' - '),
|
||||
Text(pageTitle!),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
|
||||
}
|
||||
144
app/lib/presentation/widgets/dashboard_layout.dart
Normal file
144
app/lib/presentation/widgets/dashboard_layout.dart
Normal file
@@ -0,0 +1,144 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/presentation/widgets/dashboard_app_bar.dart';
|
||||
import 'package:geosector_app/presentation/widgets/responsive_navigation.dart';
|
||||
|
||||
/// Layout commun pour les tableaux de bord utilisateur et administrateur
|
||||
/// Combine DashboardAppBar et ResponsiveNavigation
|
||||
class DashboardLayout extends StatelessWidget {
|
||||
/// Le contenu principal à afficher
|
||||
final Widget body;
|
||||
|
||||
/// Le titre de la page
|
||||
final String title;
|
||||
|
||||
/// L'index de la page sélectionnée
|
||||
final int selectedIndex;
|
||||
|
||||
/// Callback appelé lorsqu'un élément de navigation est sélectionné
|
||||
final Function(int) onDestinationSelected;
|
||||
|
||||
/// Liste des destinations de navigation
|
||||
final List<NavigationDestination> destinations;
|
||||
|
||||
/// Actions supplémentaires à afficher dans l'AppBar
|
||||
final List<Widget>? additionalActions;
|
||||
|
||||
/// Indique si le bouton "Nouveau passage" doit être affiché
|
||||
final bool showNewPassageButton;
|
||||
|
||||
/// Callback appelé lorsque le bouton "Nouveau passage" est pressé
|
||||
final VoidCallback? onNewPassagePressed;
|
||||
|
||||
/// Widgets à afficher en bas de la sidebar
|
||||
final List<Widget>? sidebarBottomItems;
|
||||
|
||||
/// Indique si l'utilisateur est un administrateur
|
||||
final bool isAdmin;
|
||||
|
||||
/// Callback appelé lorsque le bouton de déconnexion est pressé
|
||||
final VoidCallback? onLogoutPressed;
|
||||
|
||||
const DashboardLayout({
|
||||
Key? key,
|
||||
required this.body,
|
||||
required this.title,
|
||||
required this.selectedIndex,
|
||||
required this.onDestinationSelected,
|
||||
required this.destinations,
|
||||
this.additionalActions,
|
||||
this.showNewPassageButton = true,
|
||||
this.onNewPassagePressed,
|
||||
this.sidebarBottomItems,
|
||||
this.isAdmin = false,
|
||||
this.onLogoutPressed,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
try {
|
||||
debugPrint('Building DashboardLayout');
|
||||
|
||||
// Vérifier que les destinations ne sont pas vides
|
||||
if (destinations.isEmpty) {
|
||||
debugPrint('ERREUR: destinations est vide dans DashboardLayout');
|
||||
return const Scaffold(
|
||||
body: Center(
|
||||
child: Text('Erreur: Aucune destination de navigation disponible'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Vérifier que selectedIndex est valide
|
||||
if (selectedIndex < 0 || selectedIndex >= destinations.length) {
|
||||
debugPrint('ERREUR: selectedIndex invalide dans DashboardLayout');
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child:
|
||||
Text('Erreur: Index de navigation invalide ($selectedIndex)'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors
|
||||
.transparent, // Fond transparent pour laisser voir le AdminBackground
|
||||
appBar: DashboardAppBar(
|
||||
title: title,
|
||||
pageTitle: destinations[selectedIndex].label,
|
||||
additionalActions: additionalActions,
|
||||
showNewPassageButton: showNewPassageButton,
|
||||
onNewPassagePressed: onNewPassagePressed,
|
||||
isAdmin: isAdmin,
|
||||
onLogoutPressed: onLogoutPressed,
|
||||
),
|
||||
body: ResponsiveNavigation(
|
||||
title:
|
||||
title, // Même si le titre n'est pas affiché dans la navigation, il est utilisé pour la cohérence
|
||||
body: body,
|
||||
selectedIndex: selectedIndex,
|
||||
onDestinationSelected: onDestinationSelected,
|
||||
destinations: destinations,
|
||||
// Ne pas afficher le bouton "Nouveau passage" dans la navigation car il est déjà dans l'AppBar
|
||||
showNewPassageButton: false,
|
||||
onNewPassagePressed: onNewPassagePressed,
|
||||
sidebarBottomItems: sidebarBottomItems,
|
||||
isAdmin: isAdmin,
|
||||
// Ne pas afficher l'AppBar dans la navigation car nous utilisons DashboardAppBar
|
||||
showAppBar: false,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('ERREUR CRITIQUE dans DashboardLayout.build: $e');
|
||||
// Afficher une interface de secours en cas d'erreur
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Erreur - $title'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, color: Colors.red, size: 64),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Une erreur est survenue',
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text('Détails: $e'),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context)
|
||||
.pushNamedAndRemoveUntil('/', (route) => false);
|
||||
},
|
||||
child: const Text('Retour à l\'accueil'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
# Documentation des Widgets Amicale
|
||||
|
||||
Cette documentation explique comment utiliser les widgets `AmicaleRowWidget` et `AmicaleTableWidget` pour afficher et gérer les données des amicales dans l'application.
|
||||
|
||||
## AmicaleRowWidget
|
||||
|
||||
Le widget `AmicaleRowWidget` représente une ligne dans un tableau d'amicales. Il affiche les informations d'une amicale avec les colonnes suivantes :
|
||||
|
||||
- ID
|
||||
- Nom
|
||||
- Code Postal
|
||||
- Région
|
||||
- Actions (boutons selon les droits de l'utilisateur)
|
||||
|
||||
### Propriétés
|
||||
|
||||
| Propriété | Type | Description |
|
||||
| ------------- | --------------- | ---------------------------------------------------------------------------------- |
|
||||
| `amicale` | `AmicaleModel` | **Obligatoire**. L'objet amicale à afficher. |
|
||||
| `onEdit` | `VoidCallback?` | Fonction appelée lorsque l'utilisateur clique sur le bouton d'édition. |
|
||||
| `onDelete` | `VoidCallback?` | Fonction appelée lorsque l'utilisateur clique sur le bouton de suppression. |
|
||||
| `isAlternate` | `bool` | Indique si la ligne doit avoir une couleur de fond alternée. Par défaut à `false`. |
|
||||
|
||||
### Gestion des droits d'accès
|
||||
|
||||
Le widget gère automatiquement l'affichage des boutons d'action en fonction du rôle de l'utilisateur :
|
||||
|
||||
- Le bouton d'édition (crayon) est visible pour tous les utilisateurs avec un rôle > 1
|
||||
- Le bouton de suppression (corbeille) est visible uniquement pour les utilisateurs avec un rôle > 2
|
||||
|
||||
### Exemple d'utilisation
|
||||
|
||||
```dart
|
||||
AmicaleRowWidget(
|
||||
amicale: amicale,
|
||||
isAlternate: index % 2 == 1, // Alterner les couleurs
|
||||
onEdit: () {
|
||||
// Code pour gérer l'édition
|
||||
},
|
||||
onDelete: () {
|
||||
// Code pour gérer la suppression
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
## AmicaleTableWidget
|
||||
|
||||
Le widget `AmicaleTableWidget` affiche un tableau complet d'amicales avec un en-tête et des lignes. Il utilise le widget `AmicaleRowWidget` pour afficher chaque ligne.
|
||||
|
||||
### Propriétés
|
||||
|
||||
| Propriété | Type | Description |
|
||||
| -------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `amicales` | `List<AmicaleModel>` | **Obligatoire**. La liste des amicales à afficher. |
|
||||
| `onEdit` | `Function(AmicaleModel)?` | Fonction appelée lorsque l'utilisateur clique sur le bouton d'édition d'une amicale. |
|
||||
| `onDelete` | `Function(AmicaleModel)?` | Fonction appelée lorsque l'utilisateur clique sur le bouton de suppression d'une amicale. |
|
||||
| `isLoading` | `bool` | Indique si les données sont en cours de chargement. Affiche un indicateur de chargement si `true`. Par défaut à `false`. |
|
||||
| `emptyMessage` | `String?` | Message à afficher lorsque la liste des amicales est vide. |
|
||||
|
||||
### États du tableau
|
||||
|
||||
Le widget gère automatiquement différents états :
|
||||
|
||||
1. **Chargement** : Affiche un indicateur de chargement circulaire lorsque `isLoading` est `true`.
|
||||
2. **Liste vide** : Affiche un message lorsque la liste des amicales est vide.
|
||||
3. **Affichage normal** : Affiche la liste des amicales avec des lignes alternées.
|
||||
|
||||
### Exemple d'utilisation
|
||||
|
||||
```dart
|
||||
AmicaleTableWidget(
|
||||
amicales: _amicales,
|
||||
isLoading: _isLoading,
|
||||
onEdit: (amicale) {
|
||||
// Code pour gérer l'édition de l'amicale
|
||||
},
|
||||
onDelete: (amicale) {
|
||||
// Code pour gérer la suppression de l'amicale
|
||||
},
|
||||
emptyMessage: 'Aucune amicale trouvée. Veuillez en créer une nouvelle.',
|
||||
)
|
||||
```
|
||||
|
||||
## Intégration avec AmicaleRepository
|
||||
|
||||
Pour utiliser ces widgets avec le repository des amicales, vous devez :
|
||||
|
||||
1. Récupérer les amicales depuis le repository :
|
||||
|
||||
```dart
|
||||
final amicaleRepository = Provider.of<AmicaleRepository>(context, listen: false);
|
||||
final amicales = amicaleRepository.getAllAmicales();
|
||||
```
|
||||
|
||||
2. Gérer les actions d'édition et de suppression :
|
||||
|
||||
```dart
|
||||
void _handleEdit(AmicaleModel amicale) {
|
||||
// Naviguer vers la page d'édition ou afficher une boîte de dialogue
|
||||
}
|
||||
|
||||
Future<void> _handleDelete(AmicaleModel amicale) async {
|
||||
// Afficher une confirmation puis supprimer
|
||||
final amicaleRepository = Provider.of<AmicaleRepository>(context, listen: false);
|
||||
await amicaleRepository.deleteAmicale(amicale.id);
|
||||
|
||||
// Recharger la liste
|
||||
setState(() {
|
||||
_amicales = amicaleRepository.getAllAmicales();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Exemple complet
|
||||
|
||||
Un exemple complet d'utilisation est disponible dans le fichier `app/lib/presentation/widgets/examples/amicale_table_example.dart`.
|
||||
|
||||
## Personnalisation
|
||||
|
||||
### Styles
|
||||
|
||||
Les widgets utilisent les styles du thème de l'application pour la cohérence visuelle. Vous pouvez personnaliser l'apparence en modifiant le thème ou en surchargeant les styles dans votre implémentation.
|
||||
|
||||
### Colonnes et flexibilité
|
||||
|
||||
Les colonnes du tableau ont des valeurs de flex prédéfinies pour une mise en page optimale :
|
||||
|
||||
- ID : flex 1
|
||||
- Nom : flex 4
|
||||
- Code Postal : flex 2
|
||||
- Région : flex 3
|
||||
- Actions : flex 2
|
||||
|
||||
Vous pouvez ajuster ces valeurs en modifiant le code source si nécessaire.
|
||||
|
||||
## Bonnes pratiques
|
||||
|
||||
1. **Gestion des erreurs** : Ajoutez toujours une gestion des erreurs lors de l'interaction avec le repository.
|
||||
2. **Confirmation des actions** : Demandez toujours une confirmation avant de supprimer une amicale.
|
||||
3. **Actualisation des données** : Prévoyez un moyen de rafraîchir les données (bouton ou pull-to-refresh).
|
||||
4. **Pagination** : Pour les grandes listes, envisagez d'implémenter une pagination.
|
||||
204
app/lib/presentation/widgets/docs/entite_form_documentation.md
Normal file
204
app/lib/presentation/widgets/docs/entite_form_documentation.md
Normal file
@@ -0,0 +1,204 @@
|
||||
# Documentation du Widget EntiteForm
|
||||
|
||||
Cette documentation décrit le widget `EntiteForm` créé pour la création et la modification des entités (amicales) dans l'application GeoSector.
|
||||
|
||||
## Description
|
||||
|
||||
Le widget `EntiteForm` est un formulaire complet permettant de créer ou modifier une entité (amicale). Il gère l'affichage de tous les champs nécessaires, la validation des données et les restrictions d'accès basées sur le rôle de l'utilisateur.
|
||||
|
||||
## Propriétés
|
||||
|
||||
- `amicale` (AmicaleModel?, optionnel) : Le modèle d'amicale à modifier. Si null, le formulaire sera en mode création.
|
||||
- `onSubmit` (Function(AmicaleModel)?, optionnel) : Callback appelé lorsque le formulaire est soumis avec succès.
|
||||
- `readOnly` (bool, défaut: false) : Si true, tous les champs du formulaire seront en lecture seule.
|
||||
|
||||
## Champs du formulaire
|
||||
|
||||
Le formulaire inclut les champs suivants :
|
||||
|
||||
### Informations générales
|
||||
|
||||
- **Nom** : Nom de l'amicale (obligatoire)
|
||||
|
||||
### Adresse
|
||||
|
||||
- **Adresse ligne 1** : Première ligne d'adresse
|
||||
- **Adresse ligne 2** : Seconde ligne d'adresse (optionnelle)
|
||||
- **Code Postal** : Code postal (validation pour 5 chiffres)
|
||||
- **Ville** : Nom de la ville
|
||||
- **Région** : Sélection de la région via un dropdown
|
||||
|
||||
### Contact
|
||||
|
||||
- **Téléphone fixe** : Numéro de téléphone fixe (validation pour 10 chiffres)
|
||||
- **Téléphone mobile** : Numéro de téléphone mobile (validation pour 10 chiffres)
|
||||
- **Email** : Adresse email (obligatoire, avec validation de format)
|
||||
|
||||
### Informations avancées (visibles uniquement pour les administrateurs ou si déjà remplies)
|
||||
|
||||
- **GPS Latitude** : Coordonnée GPS latitude
|
||||
- **GPS Longitude** : Coordonnée GPS longitude
|
||||
- **Stripe ID** : Identifiant Stripe pour les paiements
|
||||
|
||||
### Options
|
||||
|
||||
- **Mode démo** : Indique si l'amicale est en mode démo
|
||||
- **Copie des mails reçus** : Indique si l'amicale reçoit une copie des emails
|
||||
- **Accepte les SMS** : Indique si l'amicale accepte les SMS
|
||||
- **Actif** : Indique si l'amicale est active
|
||||
|
||||
## Restrictions d'accès
|
||||
|
||||
Certains champs sont soumis à des restrictions d'accès basées sur le rôle de l'utilisateur :
|
||||
|
||||
- Les champs suivants sont en lecture seule pour les utilisateurs avec un rôle ≤ 2 :
|
||||
- fkRegion/libRegion
|
||||
- gpsLat
|
||||
- gpsLng
|
||||
- stripeId
|
||||
- chkDemo
|
||||
- chkActive
|
||||
|
||||
## Exemple d'utilisation
|
||||
|
||||
Un exemple complet d'utilisation est disponible dans le fichier `app/lib/presentation/widgets/examples/entite_form_example.dart`.
|
||||
|
||||
### Utilisation simple
|
||||
|
||||
```dart
|
||||
// Création d'une nouvelle amicale
|
||||
EntiteForm(
|
||||
onSubmit: (amicale) {
|
||||
// Gérer la soumission
|
||||
print('Nouvelle amicale: ${amicale.name}');
|
||||
},
|
||||
)
|
||||
|
||||
// Modification d'une amicale existante
|
||||
EntiteForm(
|
||||
amicale: amicaleExistante,
|
||||
onSubmit: (amicale) {
|
||||
// Gérer la soumission
|
||||
print('Amicale modifiée: ${amicale.name}');
|
||||
},
|
||||
)
|
||||
|
||||
// Affichage en lecture seule
|
||||
EntiteForm(
|
||||
amicale: amicaleExistante,
|
||||
readOnly: true,
|
||||
)
|
||||
```
|
||||
|
||||
### Utilisation avec gestion d'état
|
||||
|
||||
```dart
|
||||
class _MyWidgetState extends State<MyWidget> {
|
||||
AmicaleModel? _amicale;
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadAmicale();
|
||||
}
|
||||
|
||||
Future<void> _loadAmicale() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
if (widget.amicaleId != null) {
|
||||
// Récupérer l'amicale depuis le repository
|
||||
final amicaleRepository = Provider.of<AmicaleRepository>(context, listen: false);
|
||||
final amicale = amicaleRepository.getAmicaleById(widget.amicaleId!);
|
||||
|
||||
setState(() {
|
||||
_amicale = amicale;
|
||||
_isLoading = false;
|
||||
});
|
||||
} else {
|
||||
// Création d'une nouvelle amicale
|
||||
setState(() {
|
||||
_amicale = null;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du chargement de l\'amicale: $e');
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _handleSubmit(AmicaleModel amicale) async {
|
||||
try {
|
||||
final amicaleRepository = Provider.of<AmicaleRepository>(context, listen: false);
|
||||
|
||||
// Sauvegarder l'amicale
|
||||
final savedAmicale = await amicaleRepository.saveAmicale(amicale);
|
||||
|
||||
// Afficher un message de confirmation
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Amicale ${savedAmicale.name} sauvegardée avec succès'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la sauvegarde de l\'amicale: $e');
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Erreur lors de la sauvegarde: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: EntiteForm(
|
||||
amicale: _amicale,
|
||||
onSubmit: _handleSubmit,
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Intégration avec le système de rôles
|
||||
|
||||
Le widget utilise le `UserRepository` pour déterminer le rôle de l'utilisateur actuel et appliquer les restrictions d'accès en conséquence. Assurez-vous que le `UserRepository` est disponible dans l'arbre des widgets via un `Provider`.
|
||||
|
||||
```dart
|
||||
// Dans le widget parent
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
Provider<UserRepository>(
|
||||
create: (context) => userRepository,
|
||||
),
|
||||
Provider<AmicaleRepository>(
|
||||
create: (context) => amicaleRepository,
|
||||
),
|
||||
],
|
||||
child: MyWidget(),
|
||||
);
|
||||
```
|
||||
|
||||
## Personnalisation
|
||||
|
||||
Le widget utilise le thème de l'application pour le style. Vous pouvez personnaliser l'apparence en modifiant le thème ou en étendant le widget pour créer votre propre version personnalisée.
|
||||
|
||||
## Validation des données
|
||||
|
||||
Le formulaire inclut une validation pour les champs suivants :
|
||||
|
||||
- **Nom** : Ne peut pas être vide
|
||||
- **Code Postal** : Doit contenir 5 chiffres s'il est rempli
|
||||
- **Téléphone fixe** : Doit contenir 10 chiffres s'il est rempli
|
||||
- **Téléphone mobile** : Doit contenir 10 chiffres s'il est rempli
|
||||
- **Email** : Ne peut pas être vide et doit contenir un '@' et un '.'
|
||||
@@ -0,0 +1,160 @@
|
||||
# Documentation du Widget EntiteForm avec RegionRepository
|
||||
|
||||
Cette documentation explique comment utiliser le widget `EntiteForm` avec le `RegionRepository` pour afficher et gérer les régions dans le formulaire d'entité.
|
||||
|
||||
## Intégration du RegionRepository
|
||||
|
||||
Le widget `EntiteForm` est conçu pour fonctionner avec le `RegionRepository` afin de récupérer la liste des régions disponibles pour le champ de sélection de région. Voici comment l'intégrer :
|
||||
|
||||
### 1. Initialisation du RegionRepository
|
||||
|
||||
Le `RegionRepository` doit être initialisé et fourni au widget `EntiteForm` via un `Provider`. Voici un exemple d'initialisation :
|
||||
|
||||
```dart
|
||||
final regionRepository = RegionRepository();
|
||||
await regionRepository.init();
|
||||
```
|
||||
|
||||
### 2. Fournir le RegionRepository via Provider
|
||||
|
||||
Pour que le widget `EntiteForm` puisse accéder au `RegionRepository`, vous devez le fournir via un `Provider` :
|
||||
|
||||
```dart
|
||||
MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<RegionRepository>.value(value: regionRepository),
|
||||
// Autres providers si nécessaire
|
||||
],
|
||||
child: EntiteForm(
|
||||
amicale: amicale,
|
||||
onSubmit: handleSubmit,
|
||||
readOnly: false,
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
### 3. Mise à jour des régions depuis l'API
|
||||
|
||||
Lorsque l'API renvoie les données des régions dans la réponse de login, vous devez les mettre à jour dans le `RegionRepository` :
|
||||
|
||||
```dart
|
||||
// Dans le service qui gère la connexion
|
||||
void handleLoginResponse(Map<String, dynamic> response) {
|
||||
// Autres traitements...
|
||||
|
||||
// Mise à jour des régions si présentes dans la réponse
|
||||
if (response.containsKey('regions') && response['regions'] is List) {
|
||||
final regionRepository = Provider.of<RegionRepository>(context, listen: false);
|
||||
regionRepository.updateRegionsFromApi(response['regions']);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Fonctionnement avec les restrictions d'accès
|
||||
|
||||
Le widget `EntiteForm` gère automatiquement les restrictions d'accès basées sur le rôle de l'utilisateur :
|
||||
|
||||
- Pour les utilisateurs avec un rôle ≤ 2, le champ de sélection de région est en lecture seule
|
||||
- Pour les utilisateurs avec un rôle > 2, le champ de sélection de région est modifiable
|
||||
|
||||
## Filtrage des régions selon le code postal
|
||||
|
||||
Le `RegionRepository` offre une méthode `getRegionByPostalCode` qui permet de filtrer les régions en fonction du code postal :
|
||||
|
||||
```dart
|
||||
// Récupérer la région correspondant au code postal
|
||||
final codePostal = '75001';
|
||||
final region = regionRepository.getRegionByPostalCode(codePostal);
|
||||
if (region != null) {
|
||||
// Utiliser la région trouvée
|
||||
print('Région trouvée : ${region.libelle}');
|
||||
}
|
||||
```
|
||||
|
||||
Cette fonctionnalité est particulièrement utile pour pré-remplir le champ de région lorsque l'utilisateur entre un code postal.
|
||||
|
||||
## Exemple complet d'utilisation
|
||||
|
||||
Un exemple complet d'utilisation est disponible dans le fichier `app/lib/presentation/widgets/examples/entite_form_with_regions_example.dart`.
|
||||
|
||||
### Exemple simplifié
|
||||
|
||||
```dart
|
||||
class MyWidget extends StatefulWidget {
|
||||
@override
|
||||
State<MyWidget> createState() => _MyWidgetState();
|
||||
}
|
||||
|
||||
class _MyWidgetState extends State<MyWidget> {
|
||||
late RegionRepository _regionRepository;
|
||||
AmicaleModel? _amicale;
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_regionRepository = RegionRepository();
|
||||
_initData();
|
||||
}
|
||||
|
||||
Future<void> _initData() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
// Initialiser le repository des régions
|
||||
await _regionRepository.init();
|
||||
|
||||
// Charger l'amicale si nécessaire
|
||||
// ...
|
||||
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de l\'initialisation: $e');
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _handleSubmit(AmicaleModel amicale) {
|
||||
// Traiter la soumission du formulaire
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<RegionRepository>.value(value: _regionRepository),
|
||||
],
|
||||
child: Scaffold(
|
||||
appBar: AppBar(title: Text('Formulaire d\'entité')),
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: EntiteForm(
|
||||
amicale: _amicale,
|
||||
onSubmit: _handleSubmit,
|
||||
readOnly: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Mise à jour du code postal et de la région
|
||||
|
||||
Pour mettre à jour automatiquement la région lorsque l'utilisateur change le code postal, vous pouvez étendre le widget `EntiteForm` ou créer un wrapper qui écoute les changements du champ de code postal et met à jour la région en conséquence.
|
||||
|
||||
## Remarques importantes
|
||||
|
||||
1. Assurez-vous que le `RegionRepository` est initialisé avant d'afficher le formulaire.
|
||||
2. Le widget `EntiteForm` s'adapte automatiquement au rôle de l'utilisateur pour les restrictions d'accès.
|
||||
3. Les régions sont filtrées en fonction du code postal de l'amicale pour les utilisateurs avec un rôle ≤ 2.
|
||||
4. Pour les utilisateurs avec un rôle > 2, toutes les régions sont disponibles dans le dropdown.
|
||||
@@ -0,0 +1,207 @@
|
||||
# Documentation des Widgets Membre
|
||||
|
||||
Cette documentation décrit les widgets créés pour afficher et gérer les données des membres dans l'application GeoSector.
|
||||
|
||||
## Widgets disponibles
|
||||
|
||||
### 1. MembreRowWidget
|
||||
|
||||
Widget qui représente une ligne individuelle dans un tableau de membres. Il affiche les informations d'un membre et des boutons d'action pour l'édition et la suppression.
|
||||
|
||||
#### Propriétés
|
||||
|
||||
- `membre` (MembreModel, requis) : Le modèle de membre à afficher
|
||||
- `onEdit` (Function()?, optionnel) : Callback appelé lorsque le bouton d'édition est pressé
|
||||
- `onDelete` (Function()?, optionnel) : Callback appelé lorsque le bouton de suppression est pressé
|
||||
|
||||
#### Colonnes affichées
|
||||
|
||||
- ID : Identifiant unique du membre
|
||||
- Prénom (firstName) : Prénom du membre
|
||||
- Nom (name) : Nom de famille du membre
|
||||
- Secteur (sectName) : Nom du secteur auquel le membre est associé
|
||||
- Rôle (fkRole) : Rôle du membre (affiché sous forme de texte : User, Admin, Super)
|
||||
- Actions : Boutons d'édition et de suppression
|
||||
|
||||
### 2. MembreTableWidget
|
||||
|
||||
Widget qui affiche un tableau complet de membres avec en-tête et lignes. Il utilise le widget `MembreRowWidget` pour afficher chaque ligne.
|
||||
|
||||
#### Propriétés
|
||||
|
||||
- `membres` (List<MembreModel>, requis) : La liste des membres à afficher
|
||||
- `onEdit` (Function(MembreModel)?, optionnel) : Callback appelé lorsque le bouton d'édition est pressé pour un membre
|
||||
- `onDelete` (Function(MembreModel)?, optionnel) : Callback appelé lorsque le bouton de suppression est pressé pour un membre
|
||||
- `showHeader` (bool, défaut: true) : Indique si l'en-tête du tableau doit être affiché
|
||||
- `height` (double?, optionnel) : Hauteur du tableau (null pour prendre toute la hauteur disponible)
|
||||
- `padding` (EdgeInsetsGeometry?, optionnel) : Padding du tableau
|
||||
|
||||
## Exemple d'utilisation
|
||||
|
||||
Un exemple complet d'utilisation est disponible dans le fichier `app/lib/presentation/widgets/examples/membre_table_example.dart`.
|
||||
|
||||
### Utilisation simple
|
||||
|
||||
```dart
|
||||
// S'assurer que la boîte Hive est ouverte
|
||||
if (!Hive.isBoxOpen(AppKeys.membresBoxName)) {
|
||||
await Hive.openBox<MembreModel>(AppKeys.membresBoxName);
|
||||
}
|
||||
|
||||
// Récupérer les membres depuis la boîte Hive
|
||||
final membresBox = Hive.box<MembreModel>(AppKeys.membresBoxName);
|
||||
final membres = membresBox.values.toList();
|
||||
|
||||
// Afficher le tableau
|
||||
return MembreTableWidget(
|
||||
membres: membres,
|
||||
onEdit: (membre) {
|
||||
// Gérer l'édition
|
||||
},
|
||||
onDelete: (membre) {
|
||||
// Gérer la suppression
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
### Utilisation avec gestion d'état
|
||||
|
||||
```dart
|
||||
class _MyWidgetState extends State<MyWidget> {
|
||||
List<MembreModel> _membres = [];
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadMembres();
|
||||
}
|
||||
|
||||
Future<void> _loadMembres() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
// S'assurer que la boîte Hive est ouverte
|
||||
if (!Hive.isBoxOpen(AppKeys.membresBoxName)) {
|
||||
await Hive.openBox<MembreModel>(AppKeys.membresBoxName);
|
||||
}
|
||||
|
||||
// Récupérer les membres depuis la boîte Hive
|
||||
final membresBox = Hive.box<MembreModel>(AppKeys.membresBoxName);
|
||||
final membres = membresBox.values.toList();
|
||||
|
||||
setState(() {
|
||||
_membres = membres;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du chargement des membres: $e');
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: MembreTableWidget(
|
||||
membres: _membres,
|
||||
onEdit: _handleEdit,
|
||||
onDelete: _handleDelete,
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Gestion des événements
|
||||
|
||||
### Édition d'un membre
|
||||
|
||||
```dart
|
||||
void _handleEdit(MembreModel membre) {
|
||||
// Exemple de gestion de l'événement d'édition
|
||||
debugPrint('Édition du membre: ${membre.firstName} ${membre.name} (ID: ${membre.id})');
|
||||
|
||||
// Ici, vous pourriez ouvrir une boîte de dialogue ou naviguer vers une page d'édition
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Édition de membre'),
|
||||
content: Text('Vous avez demandé à éditer le membre ${membre.firstName} ${membre.name}'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Fermer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Suppression d'un membre
|
||||
|
||||
```dart
|
||||
void _handleDelete(MembreModel membre) {
|
||||
// Exemple de gestion de l'événement de suppression
|
||||
debugPrint('Suppression du membre: ${membre.firstName} ${membre.name} (ID: ${membre.id})');
|
||||
|
||||
// Demander confirmation avant de supprimer
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Confirmation de suppression'),
|
||||
content: Text('Êtes-vous sûr de vouloir supprimer le membre ${membre.firstName} ${membre.name} ?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
// Fermer la boîte de dialogue
|
||||
Navigator.of(context).pop();
|
||||
|
||||
try {
|
||||
// Supprimer le membre de la boîte Hive
|
||||
final membresBox = Hive.box<MembreModel>(AppKeys.membresBoxName);
|
||||
await membresBox.delete(membre.id);
|
||||
|
||||
// Mettre à jour l'état
|
||||
setState(() {
|
||||
_membres = _membres.where((m) => m.id != membre.id).toList();
|
||||
});
|
||||
|
||||
// Afficher un message de confirmation
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Membre ${membre.firstName} ${membre.name} supprimé'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la suppression du membre: $e');
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Erreur lors de la suppression: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text('Supprimer'),
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Personnalisation
|
||||
|
||||
Les widgets utilisent le thème de l'application pour le style. Vous pouvez personnaliser l'apparence en modifiant le thème ou en étendant les widgets pour créer vos propres versions personnalisées.
|
||||
644
app/lib/presentation/widgets/entite_form.dart
Normal file
644
app/lib/presentation/widgets/entite_form.dart
Normal file
@@ -0,0 +1,644 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:geosector_app/core/data/models/amicale_model.dart';
|
||||
import 'package:geosector_app/core/repositories/user_repository.dart';
|
||||
import 'package:geosector_app/core/repositories/region_repository.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'custom_text_field.dart';
|
||||
|
||||
class EntiteForm extends StatefulWidget {
|
||||
final AmicaleModel? amicale;
|
||||
final Function(AmicaleModel)? onSubmit;
|
||||
final bool readOnly;
|
||||
|
||||
const EntiteForm({
|
||||
Key? key,
|
||||
this.amicale,
|
||||
this.onSubmit,
|
||||
this.readOnly = false,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<EntiteForm> createState() => _EntiteFormState();
|
||||
}
|
||||
|
||||
class _EntiteFormState extends State<EntiteForm> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
// Controllers
|
||||
late final TextEditingController _nameController;
|
||||
late final TextEditingController _adresse1Controller;
|
||||
late final TextEditingController _adresse2Controller;
|
||||
late final TextEditingController _codePostalController;
|
||||
late final TextEditingController _villeController;
|
||||
late final TextEditingController _phoneController;
|
||||
late final TextEditingController _mobileController;
|
||||
late final TextEditingController _emailController;
|
||||
late final TextEditingController _gpsLatController;
|
||||
late final TextEditingController _gpsLngController;
|
||||
late final TextEditingController _stripeIdController;
|
||||
|
||||
// Form values
|
||||
int? _fkRegion;
|
||||
String? _libRegion;
|
||||
bool _chkDemo = false;
|
||||
bool _chkCopieMailRecu = false;
|
||||
bool _chkAcceptSms = false;
|
||||
bool _chkActive = true;
|
||||
bool _chkStripe = false;
|
||||
|
||||
// Liste des régions (sera chargée depuis le store)
|
||||
List<Map<String, dynamic>> _regions = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// Initialize controllers with amicale data if available
|
||||
final amicale = widget.amicale;
|
||||
_nameController = TextEditingController(text: amicale?.name ?? '');
|
||||
_adresse1Controller = TextEditingController(text: amicale?.adresse1 ?? '');
|
||||
_adresse2Controller = TextEditingController(text: amicale?.adresse2 ?? '');
|
||||
_codePostalController =
|
||||
TextEditingController(text: amicale?.codePostal ?? '');
|
||||
_villeController = TextEditingController(text: amicale?.ville ?? '');
|
||||
_phoneController = TextEditingController(text: amicale?.phone ?? '');
|
||||
_mobileController = TextEditingController(text: amicale?.mobile ?? '');
|
||||
_emailController = TextEditingController(text: amicale?.email ?? '');
|
||||
_gpsLatController = TextEditingController(text: amicale?.gpsLat ?? '');
|
||||
_gpsLngController = TextEditingController(text: amicale?.gpsLng ?? '');
|
||||
_stripeIdController = TextEditingController(text: amicale?.stripeId ?? '');
|
||||
|
||||
_fkRegion = amicale?.fkRegion;
|
||||
_libRegion = amicale?.libRegion;
|
||||
_chkDemo = amicale?.chkDemo ?? false;
|
||||
_chkCopieMailRecu = amicale?.chkCopieMailRecu ?? false;
|
||||
_chkAcceptSms = amicale?.chkAcceptSms ?? false;
|
||||
_chkActive = amicale?.chkActive ?? true;
|
||||
_chkStripe = amicale?.chkStripe ?? false;
|
||||
|
||||
// Charger les régions depuis le repository
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_loadRegions();
|
||||
});
|
||||
}
|
||||
|
||||
void _loadRegions() {
|
||||
try {
|
||||
final regionRepository =
|
||||
Provider.of<RegionRepository>(context, listen: false);
|
||||
if (!regionRepository.isLoaded) {
|
||||
// Initialiser le repository si ce n'est pas déjà fait
|
||||
regionRepository.init().then((_) {
|
||||
setState(() {
|
||||
_regions = regionRepository.getRegionsForDropdown();
|
||||
});
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
_regions = regionRepository.getRegionsForDropdown();
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du chargement des régions: $e');
|
||||
// Utiliser une liste vide en cas d'erreur
|
||||
setState(() {
|
||||
_regions = [];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_adresse1Controller.dispose();
|
||||
_adresse2Controller.dispose();
|
||||
_codePostalController.dispose();
|
||||
_villeController.dispose();
|
||||
_phoneController.dispose();
|
||||
_mobileController.dispose();
|
||||
_emailController.dispose();
|
||||
_gpsLatController.dispose();
|
||||
_gpsLngController.dispose();
|
||||
_stripeIdController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _submitForm() {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
final amicale = widget.amicale?.copyWith(
|
||||
name: _nameController.text,
|
||||
adresse1: _adresse1Controller.text,
|
||||
adresse2: _adresse2Controller.text,
|
||||
codePostal: _codePostalController.text,
|
||||
ville: _villeController.text,
|
||||
fkRegion: _fkRegion,
|
||||
libRegion: _libRegion,
|
||||
phone: _phoneController.text,
|
||||
mobile: _mobileController.text,
|
||||
email: _emailController.text,
|
||||
gpsLat: _gpsLatController.text,
|
||||
gpsLng: _gpsLngController.text,
|
||||
stripeId: _stripeIdController.text,
|
||||
chkDemo: _chkDemo,
|
||||
chkCopieMailRecu: _chkCopieMailRecu,
|
||||
chkAcceptSms: _chkAcceptSms,
|
||||
chkActive: _chkActive,
|
||||
chkStripe: _chkStripe,
|
||||
) ??
|
||||
AmicaleModel(
|
||||
id: 0, // Sera remplacé par l'API
|
||||
name: _nameController.text,
|
||||
adresse1: _adresse1Controller.text,
|
||||
adresse2: _adresse2Controller.text,
|
||||
codePostal: _codePostalController.text,
|
||||
ville: _villeController.text,
|
||||
fkRegion: _fkRegion,
|
||||
libRegion: _libRegion,
|
||||
phone: _phoneController.text,
|
||||
mobile: _mobileController.text,
|
||||
email: _emailController.text,
|
||||
gpsLat: _gpsLatController.text,
|
||||
gpsLng: _gpsLngController.text,
|
||||
stripeId: _stripeIdController.text,
|
||||
chkDemo: _chkDemo,
|
||||
chkCopieMailRecu: _chkCopieMailRecu,
|
||||
chkAcceptSms: _chkAcceptSms,
|
||||
chkActive: _chkActive,
|
||||
);
|
||||
|
||||
if (widget.onSubmit != null) {
|
||||
widget.onSubmit!(amicale);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final userRepository = Provider.of<UserRepository>(context, listen: false);
|
||||
final userRole = userRepository.getUserRole();
|
||||
|
||||
// Déterminer si l'utilisateur peut modifier les champs restreints
|
||||
final bool canEditRestrictedFields = userRole > 2;
|
||||
|
||||
// Lecture seule pour les champs restreints si l'utilisateur n'a pas les droits
|
||||
final bool restrictedFieldsReadOnly =
|
||||
widget.readOnly || !canEditRestrictedFields;
|
||||
|
||||
// Calculer la largeur maximale du formulaire pour les écrans larges
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final formMaxWidth = screenWidth > 800 ? 600.0 : screenWidth;
|
||||
|
||||
return Form(
|
||||
key: _formKey,
|
||||
child: Container(
|
||||
constraints: BoxConstraints(maxWidth: formMaxWidth),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Nom
|
||||
CustomTextField(
|
||||
controller: _nameController,
|
||||
label: "Nom",
|
||||
readOnly: widget.readOnly,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return "Veuillez entrer un nom";
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Bloc Adresse
|
||||
Text(
|
||||
"Adresse",
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.onBackground,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Adresse 1
|
||||
CustomTextField(
|
||||
controller: _adresse1Controller,
|
||||
label: "Adresse ligne 1",
|
||||
readOnly: widget.readOnly,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Adresse 2
|
||||
CustomTextField(
|
||||
controller: _adresse2Controller,
|
||||
label: "Adresse ligne 2",
|
||||
readOnly: widget.readOnly,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Code Postal et Ville
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Code Postal
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: CustomTextField(
|
||||
controller: _codePostalController,
|
||||
label: "Code Postal",
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(5),
|
||||
],
|
||||
readOnly: widget.readOnly,
|
||||
validator: (value) {
|
||||
if (value != null &&
|
||||
value.isNotEmpty &&
|
||||
value.length < 5) {
|
||||
return "Le code postal doit contenir 5 chiffres";
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// Ville
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: CustomTextField(
|
||||
controller: _villeController,
|
||||
label: "Ville",
|
||||
readOnly: widget.readOnly,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Région
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"Région",
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: theme.colorScheme.onBackground,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildRegionDropdown(restrictedFieldsReadOnly),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Contact
|
||||
Text(
|
||||
"Contact",
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.onBackground,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Téléphone fixe et mobile sur la même ligne
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Téléphone fixe
|
||||
Expanded(
|
||||
child: CustomTextField(
|
||||
controller: _phoneController,
|
||||
label: "Téléphone fixe",
|
||||
keyboardType: TextInputType.phone,
|
||||
readOnly: widget.readOnly,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(10),
|
||||
],
|
||||
validator: (value) {
|
||||
if (value != null &&
|
||||
value.isNotEmpty &&
|
||||
value.length < 10) {
|
||||
return "Le numéro de téléphone doit contenir 10 chiffres";
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// Téléphone mobile
|
||||
Expanded(
|
||||
child: CustomTextField(
|
||||
controller: _mobileController,
|
||||
label: "Téléphone mobile",
|
||||
keyboardType: TextInputType.phone,
|
||||
readOnly: widget.readOnly,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(10),
|
||||
],
|
||||
validator: (value) {
|
||||
if (value != null &&
|
||||
value.isNotEmpty &&
|
||||
value.length < 10) {
|
||||
return "Le numéro de mobile doit contenir 10 chiffres";
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Email
|
||||
CustomTextField(
|
||||
controller: _emailController,
|
||||
label: "Email",
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
readOnly: widget.readOnly,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return "Veuillez entrer l'adresse email";
|
||||
}
|
||||
if (!value.contains('@') || !value.contains('.')) {
|
||||
return "Veuillez entrer une adresse email valide";
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Informations avancées (visibles uniquement pour les administrateurs)
|
||||
if (canEditRestrictedFields ||
|
||||
(_gpsLatController.text.isNotEmpty ||
|
||||
_gpsLngController.text.isNotEmpty ||
|
||||
_stripeIdController.text.isNotEmpty)) ...[
|
||||
Text(
|
||||
"Informations avancées",
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.onBackground,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// GPS Latitude et Longitude
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// GPS Latitude
|
||||
Expanded(
|
||||
child: CustomTextField(
|
||||
controller: _gpsLatController,
|
||||
label: "GPS Latitude",
|
||||
keyboardType:
|
||||
const TextInputType.numberWithOptions(decimal: true),
|
||||
readOnly: restrictedFieldsReadOnly,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// GPS Longitude
|
||||
Expanded(
|
||||
child: CustomTextField(
|
||||
controller: _gpsLngController,
|
||||
label: "GPS Longitude",
|
||||
keyboardType:
|
||||
const TextInputType.numberWithOptions(decimal: true),
|
||||
readOnly: restrictedFieldsReadOnly,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Stripe Checkbox et Stripe ID sur la même ligne
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// Checkbox Stripe
|
||||
Checkbox(
|
||||
value: _chkStripe,
|
||||
onChanged: restrictedFieldsReadOnly
|
||||
? null
|
||||
: (value) {
|
||||
setState(() {
|
||||
_chkStripe = value!;
|
||||
});
|
||||
},
|
||||
activeColor: const Color(0xFF20335E),
|
||||
),
|
||||
Text(
|
||||
"Stripe activé",
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onBackground,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// Stripe ID
|
||||
Expanded(
|
||||
child: CustomTextField(
|
||||
controller: _stripeIdController,
|
||||
label: "Stripe ID",
|
||||
readOnly: restrictedFieldsReadOnly,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// Options
|
||||
Text(
|
||||
"Options",
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.onBackground,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Checkbox Demo
|
||||
_buildCheckboxOption(
|
||||
label: "Mode démo",
|
||||
value: _chkDemo,
|
||||
onChanged: restrictedFieldsReadOnly
|
||||
? null
|
||||
: (value) {
|
||||
setState(() {
|
||||
_chkDemo = value!;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Checkbox Copie Mail Reçu
|
||||
_buildCheckboxOption(
|
||||
label: "Copie des mails reçus",
|
||||
value: _chkCopieMailRecu,
|
||||
onChanged: widget.readOnly
|
||||
? null
|
||||
: (value) {
|
||||
setState(() {
|
||||
_chkCopieMailRecu = value!;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Checkbox Accept SMS
|
||||
_buildCheckboxOption(
|
||||
label: "Accepte les SMS",
|
||||
value: _chkAcceptSms,
|
||||
onChanged: widget.readOnly
|
||||
? null
|
||||
: (value) {
|
||||
setState(() {
|
||||
_chkAcceptSms = value!;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Checkbox Active
|
||||
_buildCheckboxOption(
|
||||
label: "Actif",
|
||||
value: _chkActive,
|
||||
onChanged: restrictedFieldsReadOnly
|
||||
? null
|
||||
: (value) {
|
||||
setState(() {
|
||||
_chkActive = value!;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 25),
|
||||
|
||||
// Bouton Enregistrer
|
||||
if (!widget.readOnly)
|
||||
Center(
|
||||
child: ElevatedButton(
|
||||
onPressed: _submitForm,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF20335E),
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24, vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
),
|
||||
minimumSize: const Size(200, 50),
|
||||
),
|
||||
child: const Text(
|
||||
'Enregistrer',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCheckboxOption({
|
||||
required String label,
|
||||
required bool value,
|
||||
required Function(bool?)? onChanged,
|
||||
}) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
activeColor: const Color(0xFF20335E),
|
||||
),
|
||||
Text(
|
||||
label,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onBackground,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRegionDropdown(bool readOnly) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
// Si en lecture seule, afficher simplement le texte
|
||||
if (readOnly && _libRegion != null) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Text(
|
||||
_libRegion!,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: theme.colorScheme.onBackground,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF4F5F6).withOpacity(0.85),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: const Color(0xFF20335E).withOpacity(0.1),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<int>(
|
||||
value: _fkRegion,
|
||||
isExpanded: true,
|
||||
hint: const Text("Sélectionnez une région"),
|
||||
icon: const Icon(
|
||||
Icons.keyboard_arrow_down,
|
||||
color: Color(0xFF20335E),
|
||||
),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: const Color(0xFF20335E),
|
||||
),
|
||||
dropdownColor: Colors.white,
|
||||
items: _regions
|
||||
.map<DropdownMenuItem<int>>((Map<String, dynamic> region) {
|
||||
return DropdownMenuItem<int>(
|
||||
value: region['id'] as int,
|
||||
child: Text(region['name'] as String),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: readOnly
|
||||
? null
|
||||
: (int? newValue) {
|
||||
setState(() {
|
||||
_fkRegion = newValue;
|
||||
// Trouver le libellé correspondant
|
||||
if (newValue != null) {
|
||||
final selectedRegion = _regions.firstWhere(
|
||||
(region) => region['id'] == newValue,
|
||||
orElse: () => {'id': newValue, 'name': ''},
|
||||
);
|
||||
_libRegion = selectedRegion['name'] as String;
|
||||
} else {
|
||||
_libRegion = null;
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
111
app/lib/presentation/widgets/environment_info_widget.dart
Normal file
111
app/lib/presentation/widgets/environment_info_widget.dart
Normal file
@@ -0,0 +1,111 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:geosector_app/core/services/api_service.dart';
|
||||
|
||||
/// Widget qui affiche les informations sur l'environnement actuel
|
||||
/// Utile pour le débogage
|
||||
class EnvironmentInfoWidget extends StatelessWidget {
|
||||
final bool showInDialog;
|
||||
|
||||
const EnvironmentInfoWidget({
|
||||
Key? key,
|
||||
this.showInDialog = false,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final apiService = Provider.of<ApiService>(context, listen: false);
|
||||
final environment = apiService.getCurrentEnvironment();
|
||||
final apiUrl = apiService.getCurrentApiUrl();
|
||||
final appIdentifier = apiService.getCurrentAppIdentifier();
|
||||
|
||||
final content = Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'🌍 Environnement GeoSector',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _getEnvironmentColor(environment)),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildInfoRow(context, 'Environnement', environment),
|
||||
const Divider(),
|
||||
_buildInfoRow(context, 'URL API', apiUrl),
|
||||
const Divider(),
|
||||
_buildInfoRow(context, 'App Identifier', appIdentifier),
|
||||
const SizedBox(height: 20),
|
||||
if (!showInDialog)
|
||||
Align(
|
||||
alignment: Alignment.center,
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Fermer'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (showInDialog) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoRow(BuildContext context, String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 120,
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _getEnvironmentColor(String environment) {
|
||||
switch (environment) {
|
||||
case 'DEV':
|
||||
return Colors.green;
|
||||
case 'REC':
|
||||
return Colors.orange;
|
||||
case 'PROD':
|
||||
return Colors.red;
|
||||
default:
|
||||
return Colors.grey;
|
||||
}
|
||||
}
|
||||
|
||||
/// Méthode statique pour afficher les informations sur l'environnement
|
||||
/// dans une boîte de dialogue
|
||||
static void show(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => const EnvironmentInfoWidget(),
|
||||
);
|
||||
}
|
||||
}
|
||||
234
app/lib/presentation/widgets/examples/amicale_table_example.dart
Normal file
234
app/lib/presentation/widgets/examples/amicale_table_example.dart
Normal file
@@ -0,0 +1,234 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/core/data/models/amicale_model.dart';
|
||||
import 'package:geosector_app/core/repositories/amicale_repository.dart';
|
||||
import 'package:geosector_app/presentation/widgets/amicale_table_widget.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
/// Exemple d'utilisation du widget AmicaleTableWidget
|
||||
///
|
||||
/// Ce widget montre comment intégrer le tableau d'amicales dans une page
|
||||
/// et comment gérer les actions d'édition et de suppression.
|
||||
class AmicaleTableExample extends StatefulWidget {
|
||||
const AmicaleTableExample({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<AmicaleTableExample> createState() => _AmicaleTableExampleState();
|
||||
}
|
||||
|
||||
class _AmicaleTableExampleState extends State<AmicaleTableExample> {
|
||||
bool _isLoading = true;
|
||||
List<AmicaleModel> _amicales = [];
|
||||
String? _errorMessage;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadAmicales();
|
||||
}
|
||||
|
||||
Future<void> _loadAmicales() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
// Récupérer les amicales depuis le repository
|
||||
final amicaleRepository =
|
||||
Provider.of<AmicaleRepository>(context, listen: false);
|
||||
final amicales = amicaleRepository.getAllAmicales();
|
||||
|
||||
setState(() {
|
||||
_amicales = amicales;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_errorMessage = 'Erreur lors du chargement des amicales: $e';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _handleEdit(AmicaleModel amicale) {
|
||||
// Afficher une boîte de dialogue de confirmation
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Modifier l\'amicale'),
|
||||
content: Text('Voulez-vous modifier l\'amicale ${amicale.name} ?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
// Naviguer vers la page de modification
|
||||
// Navigator.of(context).push(
|
||||
// MaterialPageRoute(
|
||||
// builder: (context) => EditAmicalePage(amicale: amicale),
|
||||
// ),
|
||||
// );
|
||||
},
|
||||
child: const Text('Modifier'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleDelete(AmicaleModel amicale) {
|
||||
// Afficher une boîte de dialogue de confirmation
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Supprimer l\'amicale'),
|
||||
content: Text(
|
||||
'Êtes-vous sûr de vouloir supprimer l\'amicale ${amicale.name} ?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.red,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
_deleteAmicale(amicale);
|
||||
},
|
||||
child: const Text('Supprimer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _deleteAmicale(AmicaleModel amicale) async {
|
||||
try {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
// Supprimer l'amicale via le repository
|
||||
final amicaleRepository =
|
||||
Provider.of<AmicaleRepository>(context, listen: false);
|
||||
await amicaleRepository.deleteAmicale(amicale.id);
|
||||
|
||||
// Recharger la liste
|
||||
await _loadAmicales();
|
||||
|
||||
// Afficher un message de succès
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Amicale ${amicale.name} supprimée avec succès'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_errorMessage = 'Erreur lors de la suppression: $e';
|
||||
});
|
||||
|
||||
// Afficher un message d'erreur
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Erreur: $_errorMessage'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Liste des amicales'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: _loadAmicales,
|
||||
tooltip: 'Actualiser',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Titre et description
|
||||
Text(
|
||||
'Gestion des amicales',
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Consultez, modifiez ou supprimez les amicales selon vos droits d\'accès.',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Message d'erreur si présent
|
||||
if (_errorMessage != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.red.withOpacity(0.3)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.error_outline, color: Colors.red),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_errorMessage!,
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Tableau des amicales
|
||||
Expanded(
|
||||
child: AmicaleTableWidget(
|
||||
amicales: _amicales,
|
||||
isLoading: _isLoading,
|
||||
onDelete: _handleDelete,
|
||||
emptyMessage:
|
||||
'Aucune amicale trouvée. Veuillez en créer une nouvelle.',
|
||||
readOnly: false, // Permettre la modification dans la modale
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () {
|
||||
// Naviguer vers la page de création
|
||||
// Navigator.of(context).push(
|
||||
// MaterialPageRoute(
|
||||
// builder: (context) => CreateAmicalePage(),
|
||||
// ),
|
||||
// );
|
||||
},
|
||||
tooltip: 'Ajouter une amicale',
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
195
app/lib/presentation/widgets/examples/entite_form_example.dart
Normal file
195
app/lib/presentation/widgets/examples/entite_form_example.dart
Normal file
@@ -0,0 +1,195 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/core/data/models/amicale_model.dart';
|
||||
import 'package:geosector_app/core/repositories/amicale_repository.dart';
|
||||
import 'package:geosector_app/core/repositories/user_repository.dart';
|
||||
import 'package:geosector_app/presentation/widgets/entite_form.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
/// Exemple d'utilisation du widget EntiteForm
|
||||
///
|
||||
/// Ce widget montre comment intégrer le formulaire d'entité dans une page
|
||||
/// et comment gérer les événements de soumission.
|
||||
class EntiteFormExample extends StatefulWidget {
|
||||
final int? amicaleId;
|
||||
final bool readOnly;
|
||||
|
||||
const EntiteFormExample({
|
||||
Key? key,
|
||||
this.amicaleId,
|
||||
this.readOnly = false,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<EntiteFormExample> createState() => _EntiteFormExampleState();
|
||||
}
|
||||
|
||||
class _EntiteFormExampleState extends State<EntiteFormExample> {
|
||||
AmicaleModel? _amicale;
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadAmicale();
|
||||
}
|
||||
|
||||
Future<void> _loadAmicale() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
if (widget.amicaleId != null) {
|
||||
// Récupérer l'amicale depuis le repository
|
||||
final amicaleRepository =
|
||||
Provider.of<AmicaleRepository>(context, listen: false);
|
||||
final amicale = amicaleRepository.getAmicaleById(widget.amicaleId!);
|
||||
|
||||
setState(() {
|
||||
_amicale = amicale;
|
||||
_isLoading = false;
|
||||
});
|
||||
} else {
|
||||
// Création d'une nouvelle amicale
|
||||
setState(() {
|
||||
_amicale = null;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du chargement de l\'amicale: $e');
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _handleSubmit(AmicaleModel amicale) async {
|
||||
try {
|
||||
final amicaleRepository =
|
||||
Provider.of<AmicaleRepository>(context, listen: false);
|
||||
|
||||
// Sauvegarder l'amicale
|
||||
final savedAmicale = await amicaleRepository.saveAmicale(amicale);
|
||||
|
||||
// Afficher un message de confirmation
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content:
|
||||
Text('Amicale ${savedAmicale.name} sauvegardée avec succès'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
|
||||
// Retourner à la page précédente
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la sauvegarde de l\'amicale: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Erreur lors de la sauvegarde: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final userRepository = Provider.of<UserRepository>(context, listen: false);
|
||||
final userRole = userRepository.getUserRole();
|
||||
final bool canCreate = userRole >
|
||||
1; // Seuls les utilisateurs avec rôle > 1 peuvent créer/modifier
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.amicaleId != null
|
||||
? (widget.readOnly
|
||||
? 'Détails de l\'amicale'
|
||||
: 'Modifier l\'amicale')
|
||||
: 'Nouvelle amicale'),
|
||||
actions: [
|
||||
if (!widget.readOnly && _amicale != null)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
onPressed: () => _showDeleteConfirmation(context),
|
||||
tooltip: 'Supprimer',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: !canCreate && _amicale == null
|
||||
? const Center(
|
||||
child: Text(
|
||||
'Vous n\'avez pas les droits pour créer une amicale'),
|
||||
)
|
||||
: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: EntiteForm(
|
||||
amicale: _amicale,
|
||||
onSubmit: widget.readOnly ? null : _handleSubmit,
|
||||
readOnly: widget.readOnly,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showDeleteConfirmation(BuildContext context) {
|
||||
if (_amicale == null) return;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Confirmation de suppression'),
|
||||
content: Text(
|
||||
'Êtes-vous sûr de vouloir supprimer l\'amicale ${_amicale!.name} ?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
Navigator.of(context).pop();
|
||||
|
||||
try {
|
||||
final amicaleRepository =
|
||||
Provider.of<AmicaleRepository>(context, listen: false);
|
||||
await amicaleRepository.deleteAmicale(_amicale!.id);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Amicale ${_amicale!.name} supprimée'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
|
||||
// Retourner à la page précédente
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la suppression de l\'amicale: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Erreur lors de la suppression: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: const Text('Supprimer'),
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/core/data/models/amicale_model.dart';
|
||||
import 'package:geosector_app/core/repositories/amicale_repository.dart';
|
||||
import 'package:geosector_app/core/repositories/region_repository.dart';
|
||||
import 'package:geosector_app/core/repositories/user_repository.dart';
|
||||
import 'package:geosector_app/presentation/widgets/entite_form.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
/// Exemple d'utilisation du widget EntiteForm avec le RegionRepository
|
||||
///
|
||||
/// Ce widget montre comment intégrer le formulaire d'entité dans une page
|
||||
/// et comment utiliser le RegionRepository pour charger les régions.
|
||||
class EntiteFormWithRegionsExample extends StatefulWidget {
|
||||
final int? amicaleId;
|
||||
final bool readOnly;
|
||||
|
||||
const EntiteFormWithRegionsExample({
|
||||
Key? key,
|
||||
this.amicaleId,
|
||||
this.readOnly = false,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<EntiteFormWithRegionsExample> createState() =>
|
||||
_EntiteFormWithRegionsExampleState();
|
||||
}
|
||||
|
||||
class _EntiteFormWithRegionsExampleState
|
||||
extends State<EntiteFormWithRegionsExample> {
|
||||
AmicaleModel? _amicale;
|
||||
bool _isLoading = true;
|
||||
late RegionRepository _regionRepository;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_regionRepository = RegionRepository();
|
||||
_initRepositories();
|
||||
}
|
||||
|
||||
Future<void> _initRepositories() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
// Initialiser le repository des régions
|
||||
await _regionRepository.init();
|
||||
|
||||
// Charger l'amicale si un ID est fourni
|
||||
if (widget.amicaleId != null) {
|
||||
final amicaleRepository =
|
||||
Provider.of<AmicaleRepository>(context, listen: false);
|
||||
final amicale = amicaleRepository.getAmicaleById(widget.amicaleId!);
|
||||
|
||||
setState(() {
|
||||
_amicale = amicale;
|
||||
_isLoading = false;
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de l\'initialisation: $e');
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _handleSubmit(AmicaleModel amicale) async {
|
||||
try {
|
||||
final amicaleRepository =
|
||||
Provider.of<AmicaleRepository>(context, listen: false);
|
||||
|
||||
// Sauvegarder l'amicale
|
||||
final savedAmicale = await amicaleRepository.saveAmicale(amicale);
|
||||
|
||||
// Afficher un message de confirmation
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content:
|
||||
Text('Amicale ${savedAmicale.name} sauvegardée avec succès'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
|
||||
// Retourner à la page précédente
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la sauvegarde de l\'amicale: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Erreur lors de la sauvegarde: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
// Fournir le RegionRepository pour qu'il soit accessible par le widget EntiteForm
|
||||
ChangeNotifierProvider<RegionRepository>.value(
|
||||
value: _regionRepository),
|
||||
],
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.amicaleId != null
|
||||
? (widget.readOnly
|
||||
? 'Détails de l\'amicale'
|
||||
: 'Modifier l\'amicale')
|
||||
: 'Nouvelle amicale'),
|
||||
),
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: EntiteForm(
|
||||
amicale: _amicale,
|
||||
onSubmit: widget.readOnly ? null : _handleSubmit,
|
||||
readOnly: widget.readOnly,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
167
app/lib/presentation/widgets/examples/membre_table_example.dart
Normal file
167
app/lib/presentation/widgets/examples/membre_table_example.dart
Normal file
@@ -0,0 +1,167 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/core/data/models/membre_model.dart';
|
||||
import 'package:geosector_app/presentation/widgets/membre_table_widget.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
|
||||
/// Exemple d'utilisation du widget MembreTableWidget
|
||||
///
|
||||
/// Ce widget montre comment intégrer le tableau de membres dans une page
|
||||
/// et comment gérer les événements d'édition et de suppression.
|
||||
class MembreTableExample extends StatefulWidget {
|
||||
const MembreTableExample({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<MembreTableExample> createState() => _MembreTableExampleState();
|
||||
}
|
||||
|
||||
class _MembreTableExampleState extends State<MembreTableExample> {
|
||||
List<MembreModel> _membres = [];
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadMembres();
|
||||
}
|
||||
|
||||
Future<void> _loadMembres() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
// S'assurer que la boîte Hive est ouverte
|
||||
if (!Hive.isBoxOpen(AppKeys.membresBoxName)) {
|
||||
await Hive.openBox<MembreModel>(AppKeys.membresBoxName);
|
||||
}
|
||||
|
||||
// Récupérer les membres depuis la boîte Hive
|
||||
final membresBox = Hive.box<MembreModel>(AppKeys.membresBoxName);
|
||||
final membres = membresBox.values.toList();
|
||||
|
||||
setState(() {
|
||||
_membres = membres;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du chargement des membres: $e');
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _handleEdit(MembreModel membre) {
|
||||
// Exemple de gestion de l'événement d'édition
|
||||
debugPrint(
|
||||
'Édition du membre: ${membre.firstName} ${membre.name} (ID: ${membre.id})');
|
||||
|
||||
// Ici, vous pourriez ouvrir une boîte de dialogue ou naviguer vers une page d'édition
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Édition de membre'),
|
||||
content: Text(
|
||||
'Vous avez demandé à éditer le membre ${membre.firstName} ${membre.name}'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Fermer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleDelete(MembreModel membre) {
|
||||
// Exemple de gestion de l'événement de suppression
|
||||
debugPrint(
|
||||
'Suppression du membre: ${membre.firstName} ${membre.name} (ID: ${membre.id})');
|
||||
|
||||
// Demander confirmation avant de supprimer
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Confirmation de suppression'),
|
||||
content: Text(
|
||||
'Êtes-vous sûr de vouloir supprimer le membre ${membre.firstName} ${membre.name} ?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
// Fermer la boîte de dialogue
|
||||
Navigator.of(context).pop();
|
||||
|
||||
try {
|
||||
// Supprimer le membre de la boîte Hive
|
||||
final membresBox =
|
||||
Hive.box<MembreModel>(AppKeys.membresBoxName);
|
||||
await membresBox.delete(membre.id);
|
||||
|
||||
// Mettre à jour l'état
|
||||
setState(() {
|
||||
_membres = _membres.where((m) => m.id != membre.id).toList();
|
||||
});
|
||||
|
||||
// Afficher un message de confirmation
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Membre ${membre.firstName} ${membre.name} supprimé'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la suppression du membre: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Erreur lors de la suppression: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: const Text('Supprimer'),
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Tableau des Membres'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: _loadMembres,
|
||||
tooltip: 'Rafraîchir',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: MembreTableWidget(
|
||||
membres: _membres,
|
||||
onEdit: _handleEdit,
|
||||
onDelete: _handleDelete,
|
||||
height:
|
||||
null, // Utiliser null pour que le widget prenne toute la hauteur disponible
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
110
app/lib/presentation/widgets/help_dialog.dart
Normal file
110
app/lib/presentation/widgets/help_dialog.dart
Normal file
@@ -0,0 +1,110 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Widget d'aide commun pour toute l'application
|
||||
/// Affiche une boîte de dialogue modale avec une aide contextuelle
|
||||
/// basée sur la page courante
|
||||
class HelpDialog extends StatelessWidget {
|
||||
/// Nom de la page courante pour laquelle l'aide est demandée
|
||||
final String currentPage;
|
||||
|
||||
const HelpDialog({
|
||||
Key? key,
|
||||
required this.currentPage,
|
||||
}) : super(key: key);
|
||||
|
||||
/// Affiche la boîte de dialogue d'aide
|
||||
static void show(BuildContext context, String currentPage) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => HelpDialog(currentPage: currentPage),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final size = MediaQuery.of(context).size;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
// Déterminer si nous sommes sur un appareil mobile ou un ordinateur de bureau
|
||||
final isDesktop = size.width > 900;
|
||||
|
||||
// Calculer la largeur de la boîte de dialogue
|
||||
// 90% de la largeur de l'écran pour les mobiles
|
||||
// 50% de la largeur de l'écran pour les ordinateurs de bureau (max 600px)
|
||||
final dialogWidth = isDesktop
|
||||
? size.width * 0.5 > 600
|
||||
? 600.0
|
||||
: size.width * 0.5
|
||||
: size.width * 0.9;
|
||||
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
// Définir la largeur de la boîte de dialogue
|
||||
child: Container(
|
||||
width: dialogWidth,
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Titre de l'aide avec le nom de la page courante
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.help_outline,
|
||||
color: theme.colorScheme.primary,
|
||||
size: 28,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Aide - Page $currentPage',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
tooltip: 'Fermer',
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(height: 32),
|
||||
// Contenu de l'aide (à personnaliser selon la page)
|
||||
Text(
|
||||
'Contenu d\'aide pour la page "$currentPage".',
|
||||
style: theme.textTheme.bodyLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Cette section sera personnalisée avec des instructions spécifiques pour chaque page de l\'application.',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
// Bouton pour fermer la boîte de dialogue
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
style: TextButton.styleFrom(
|
||||
backgroundColor: theme.colorScheme.primary,
|
||||
foregroundColor: theme.colorScheme.onPrimary,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
child: const Text('Fermer'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
113
app/lib/presentation/widgets/hive_reset_dialog.dart
Normal file
113
app/lib/presentation/widgets/hive_reset_dialog.dart
Normal file
@@ -0,0 +1,113 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Widget de dialogue pour informer l'utilisateur que les données locales ont été réinitialisées
|
||||
/// en raison d'une incompatibilité après une mise à jour de l'application.
|
||||
class HiveResetDialog extends StatelessWidget {
|
||||
/// Callback appelé lorsque l'utilisateur ferme le dialogue
|
||||
final VoidCallback? onClose;
|
||||
|
||||
const HiveResetDialog({
|
||||
Key? key,
|
||||
this.onClose,
|
||||
}) : super(key: key);
|
||||
|
||||
/// Affiche le dialogue de réinitialisation Hive
|
||||
static Future<void> show(BuildContext context,
|
||||
{VoidCallback? onClose}) async {
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible:
|
||||
false, // L'utilisateur doit appuyer sur un bouton pour fermer le dialogue
|
||||
builder: (BuildContext dialogContext) {
|
||||
return HiveResetDialog(onClose: onClose);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return AlertDialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.sync_problem,
|
||||
color: theme.colorScheme.error,
|
||||
size: 28,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'Données réinitialisées',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Suite à une mise à jour de l\'application, vos données locales ont dû être réinitialisées pour assurer la compatibilité.',
|
||||
style: theme.textTheme.bodyLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Que s\'est-il passé ?',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Une incompatibilité a été détectée entre la structure des données de la version précédente et celle de la version actuelle. Pour éviter tout problème, les données locales ont été effacées.',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Que dois-je faire ?',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Vous devez vous reconnecter à votre compte pour récupérer vos données depuis le serveur. Toutes vos données seront restaurées automatiquement après la connexion.',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Note : Si vous aviez des modifications non synchronisées, elles ont été perdues. Nous vous recommandons de synchroniser régulièrement vos données.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontStyle: FontStyle.italic,
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
if (onClose != null) {
|
||||
onClose!();
|
||||
}
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: theme.colorScheme.primary,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
),
|
||||
child: const Text('J\'ai compris'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
88
app/lib/presentation/widgets/loading_overlay.dart
Normal file
88
app/lib/presentation/widgets/loading_overlay.dart
Normal file
@@ -0,0 +1,88 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Widget d'overlay de chargement qui affiche un spinner centré avec un message optionnel
|
||||
/// Utilisé pour les opérations longues comme la connexion, déconnexion et synchronisation
|
||||
class LoadingOverlay extends StatelessWidget {
|
||||
final String? message;
|
||||
final Color backgroundColor;
|
||||
final Color spinnerColor;
|
||||
final Color textColor;
|
||||
final double spinnerSize;
|
||||
final double strokeWidth;
|
||||
|
||||
const LoadingOverlay({
|
||||
Key? key,
|
||||
this.message,
|
||||
this.backgroundColor = Colors.black54,
|
||||
this.spinnerColor = Colors.white,
|
||||
this.textColor = Colors.white,
|
||||
this.spinnerSize = 60.0,
|
||||
this.strokeWidth = 5.0,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
color: backgroundColor,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: spinnerSize,
|
||||
height: spinnerSize,
|
||||
child: CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(spinnerColor),
|
||||
strokeWidth: strokeWidth,
|
||||
),
|
||||
),
|
||||
if (message != null) ...[ // Afficher le texte seulement si message n'est pas null
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
message!,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: textColor,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Méthode statique pour afficher l'overlay de chargement
|
||||
static Future<T> show<T>({
|
||||
required BuildContext context,
|
||||
required Future<T> future,
|
||||
String? message,
|
||||
double spinnerSize = 60.0,
|
||||
double strokeWidth = 5.0,
|
||||
}) async {
|
||||
// Afficher l'overlay
|
||||
final overlayEntry = OverlayEntry(
|
||||
builder: (context) => LoadingOverlay(
|
||||
message: message,
|
||||
spinnerSize: spinnerSize,
|
||||
strokeWidth: strokeWidth,
|
||||
),
|
||||
);
|
||||
|
||||
Overlay.of(context).insert(overlayEntry);
|
||||
|
||||
try {
|
||||
// Attendre que le future se termine
|
||||
final result = await future;
|
||||
// Supprimer l'overlay
|
||||
overlayEntry.remove();
|
||||
return result;
|
||||
} catch (e) {
|
||||
// En cas d'erreur, supprimer l'overlay et relancer l'erreur
|
||||
overlayEntry.remove();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
199
app/lib/presentation/widgets/loading_progress_overlay.dart
Normal file
199
app/lib/presentation/widgets/loading_progress_overlay.dart
Normal file
@@ -0,0 +1,199 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:ui';
|
||||
|
||||
/// Widget d'overlay de chargement amélioré qui affiche une barre de progression
|
||||
/// avec un effet de flou sur l'arrière-plan et un message détaillé sur l'étape en cours
|
||||
class LoadingProgressOverlay extends StatefulWidget {
|
||||
final String? message;
|
||||
final double progress;
|
||||
final String? stepDescription;
|
||||
final Color backgroundColor;
|
||||
final Color progressColor;
|
||||
final Color textColor;
|
||||
final double blurAmount;
|
||||
final bool showPercentage;
|
||||
|
||||
const LoadingProgressOverlay({
|
||||
Key? key,
|
||||
this.message,
|
||||
required this.progress,
|
||||
this.stepDescription,
|
||||
this.backgroundColor = Colors.black54,
|
||||
this.progressColor = Colors.white,
|
||||
this.textColor = Colors.white,
|
||||
this.blurAmount = 5.0,
|
||||
this.showPercentage = true,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<LoadingProgressOverlay> createState() => _LoadingProgressOverlayState();
|
||||
}
|
||||
|
||||
class _LoadingProgressOverlayState extends State<LoadingProgressOverlay>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _animationController;
|
||||
late Animation<double> _progressAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_animationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
);
|
||||
_progressAnimation = Tween<double>(begin: 0, end: widget.progress).animate(
|
||||
CurvedAnimation(parent: _animationController, curve: Curves.easeInOut),
|
||||
);
|
||||
_animationController.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(LoadingProgressOverlay oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.progress != widget.progress) {
|
||||
_progressAnimation = Tween<double>(
|
||||
begin: oldWidget.progress,
|
||||
end: widget.progress,
|
||||
).animate(
|
||||
CurvedAnimation(parent: _animationController, curve: Curves.easeInOut),
|
||||
);
|
||||
_animationController.reset();
|
||||
_animationController.forward();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BackdropFilter(
|
||||
filter: ImageFilter.blur(
|
||||
sigmaX: widget.blurAmount, sigmaY: widget.blurAmount),
|
||||
child: Container(
|
||||
color: widget.backgroundColor,
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: MediaQuery.of(context).size.width * 0.85,
|
||||
padding: const EdgeInsets.all(30),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.9),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black45,
|
||||
blurRadius: 15,
|
||||
spreadRadius: 5,
|
||||
offset: const Offset(0, 5),
|
||||
),
|
||||
],
|
||||
border: Border.all(
|
||||
color: Colors.white.withOpacity(0.1),
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (widget.message != null) ...[
|
||||
Text(
|
||||
widget.message!,
|
||||
style: TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: widget.textColor,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
AnimatedBuilder(
|
||||
animation: _progressAnimation,
|
||||
builder: (context, child) {
|
||||
return Column(
|
||||
children: [
|
||||
LinearProgressIndicator(
|
||||
value: _progressAnimation.value,
|
||||
backgroundColor:
|
||||
widget.progressColor.withOpacity(0.3),
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
widget.progressColor),
|
||||
minHeight: 15,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
if (widget.showPercentage) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'${(_progressAnimation.value * 100).toInt()}%',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: widget.textColor,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
if (widget.stepDescription != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
widget.stepDescription!,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: widget.textColor.withOpacity(0.9),
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Classe utilitaire pour gérer l'overlay de chargement avec progression
|
||||
class LoadingProgressOverlayUtils {
|
||||
/// Méthode pour afficher l'overlay de chargement avec progression
|
||||
static OverlayEntry show({
|
||||
required BuildContext context,
|
||||
String? message,
|
||||
double progress = 0.0,
|
||||
String? stepDescription,
|
||||
double blurAmount = 5.0,
|
||||
bool showPercentage = true,
|
||||
}) {
|
||||
final overlayEntry = OverlayEntry(
|
||||
builder: (context) => LoadingProgressOverlay(
|
||||
message: message,
|
||||
progress: progress,
|
||||
stepDescription: stepDescription,
|
||||
blurAmount: blurAmount,
|
||||
showPercentage: showPercentage,
|
||||
),
|
||||
);
|
||||
|
||||
Overlay.of(context).insert(overlayEntry);
|
||||
return overlayEntry;
|
||||
}
|
||||
|
||||
/// Méthode pour mettre à jour l'overlay existant
|
||||
static void update({
|
||||
required OverlayEntry overlayEntry,
|
||||
String? message,
|
||||
required double progress,
|
||||
String? stepDescription,
|
||||
}) {
|
||||
overlayEntry.markNeedsBuild();
|
||||
}
|
||||
}
|
||||
204
app/lib/presentation/widgets/mapbox_map.dart
Normal file
204
app/lib/presentation/widgets/mapbox_map.dart
Normal file
@@ -0,0 +1,204 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder à l'instance globale de ApiService
|
||||
|
||||
/// Widget de carte réutilisable utilisant Mapbox
|
||||
///
|
||||
/// Ce widget encapsule un FlutterMap avec des tuiles Mapbox et fournit
|
||||
/// des fonctionnalités pour afficher des marqueurs, des polygones et des contrôles.
|
||||
class MapboxMap extends StatefulWidget {
|
||||
/// Position initiale de la carte
|
||||
final LatLng initialPosition;
|
||||
|
||||
/// Niveau de zoom initial
|
||||
final double initialZoom;
|
||||
|
||||
/// Liste des marqueurs à afficher
|
||||
final List<Marker>? markers;
|
||||
|
||||
/// Liste des polygones à afficher
|
||||
final List<Polygon>? polygons;
|
||||
|
||||
/// Contrôleur de carte externe (optionnel)
|
||||
final MapController? mapController;
|
||||
|
||||
/// Callback appelé lorsque la carte est déplacée
|
||||
final void Function(MapEvent)? onMapEvent;
|
||||
|
||||
/// Afficher les boutons de contrôle (zoom, localisation)
|
||||
final bool showControls;
|
||||
|
||||
/// Style de la carte Mapbox (optionnel)
|
||||
/// Si non spécifié, utilise le style par défaut 'mapbox/streets-v12'
|
||||
final String? mapStyle;
|
||||
|
||||
const MapboxMap({
|
||||
super.key,
|
||||
this.initialPosition = const LatLng(48.1173, -1.6778), // Rennes par défaut
|
||||
this.initialZoom = 13.0,
|
||||
this.markers,
|
||||
this.polygons,
|
||||
this.mapController,
|
||||
this.onMapEvent,
|
||||
this.showControls = true,
|
||||
this.mapStyle,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MapboxMap> createState() => _MapboxMapState();
|
||||
}
|
||||
|
||||
class _MapboxMapState extends State<MapboxMap> {
|
||||
/// Contrôleur de carte interne
|
||||
late final MapController _mapController;
|
||||
|
||||
/// Niveau de zoom actuel
|
||||
double _currentZoom = 13.0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_mapController = widget.mapController ?? MapController();
|
||||
_currentZoom = widget.initialZoom;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// Ne pas disposer le contrôleur s'il a été fourni de l'extérieur
|
||||
if (widget.mapController == null) {
|
||||
_mapController.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Construit un bouton de contrôle de carte
|
||||
Widget _buildMapButton({
|
||||
required IconData icon,
|
||||
required VoidCallback onPressed,
|
||||
}) {
|
||||
return Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.2),
|
||||
blurRadius: 6,
|
||||
offset: const Offset(0, 3),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: IconButton(
|
||||
icon: Icon(icon, size: 20),
|
||||
onPressed: onPressed,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Déterminer l'URL du template de tuiles Mapbox
|
||||
// Utiliser l'environnement actuel pour obtenir la bonne clé API
|
||||
final String environment = apiService.getCurrentEnvironment();
|
||||
final String mapboxToken = AppKeys.getMapboxApiKey(environment);
|
||||
final String mapStyle = widget.mapStyle ?? 'mapbox/streets-v11';
|
||||
final String urlTemplate =
|
||||
'https://api.mapbox.com/styles/v1/$mapStyle/tiles/256/{z}/{x}/{y}@2x?access_token=$mapboxToken';
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
// Carte principale
|
||||
FlutterMap(
|
||||
mapController: _mapController,
|
||||
options: MapOptions(
|
||||
initialCenter: widget.initialPosition,
|
||||
initialZoom: widget.initialZoom,
|
||||
onMapEvent: (event) {
|
||||
if (event is MapEventMove) {
|
||||
setState(() {
|
||||
// Dans flutter_map 8.1.1, nous devons utiliser le contrôleur pour obtenir le zoom actuel
|
||||
_currentZoom = _mapController.camera.zoom;
|
||||
});
|
||||
}
|
||||
|
||||
// Appeler le callback externe si fourni
|
||||
if (widget.onMapEvent != null) {
|
||||
widget.onMapEvent!(event);
|
||||
}
|
||||
},
|
||||
),
|
||||
children: [
|
||||
// Tuiles de la carte (Mapbox)
|
||||
TileLayer(
|
||||
urlTemplate: urlTemplate,
|
||||
userAgentPackageName: 'app.geosector.fr',
|
||||
maxNativeZoom: 19,
|
||||
additionalOptions: {
|
||||
'accessToken': mapboxToken,
|
||||
},
|
||||
),
|
||||
|
||||
// Polygones
|
||||
if (widget.polygons != null && widget.polygons!.isNotEmpty)
|
||||
PolygonLayer(polygons: widget.polygons!),
|
||||
|
||||
// Marqueurs
|
||||
if (widget.markers != null && widget.markers!.isNotEmpty)
|
||||
MarkerLayer(markers: widget.markers!),
|
||||
],
|
||||
),
|
||||
|
||||
// Boutons de contrôle
|
||||
if (widget.showControls)
|
||||
Positioned(
|
||||
bottom: 16,
|
||||
right: 16,
|
||||
child: Column(
|
||||
children: [
|
||||
// Bouton de zoom +
|
||||
_buildMapButton(
|
||||
icon: Icons.add,
|
||||
onPressed: () {
|
||||
_mapController.move(
|
||||
_mapController.camera.center,
|
||||
_mapController.camera.zoom + 1,
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Bouton de zoom -
|
||||
_buildMapButton(
|
||||
icon: Icons.remove,
|
||||
onPressed: () {
|
||||
_mapController.move(
|
||||
_mapController.camera.center,
|
||||
_mapController.camera.zoom - 1,
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Bouton de localisation
|
||||
_buildMapButton(
|
||||
icon: Icons.my_location,
|
||||
onPressed: () {
|
||||
_mapController.move(
|
||||
widget.initialPosition,
|
||||
15,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
129
app/lib/presentation/widgets/membre_row_widget.dart
Normal file
129
app/lib/presentation/widgets/membre_row_widget.dart
Normal file
@@ -0,0 +1,129 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/core/data/models/membre_model.dart';
|
||||
|
||||
class MembreRowWidget extends StatelessWidget {
|
||||
final MembreModel membre;
|
||||
final Function()? onEdit;
|
||||
final Function()? onDelete;
|
||||
|
||||
const MembreRowWidget({
|
||||
Key? key,
|
||||
required this.membre,
|
||||
this.onEdit,
|
||||
this.onDelete,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// ID
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: Text(
|
||||
membre.id.toString(),
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
|
||||
// Prénom (firstName)
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Text(
|
||||
membre.firstName,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
|
||||
// Nom (name)
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Text(
|
||||
membre.name,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
|
||||
// Secteur (sectName)
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Text(
|
||||
membre.sectName ?? '',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
|
||||
// Rôle (fkRole)
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: Text(
|
||||
_getRoleName(membre.fkRole),
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
|
||||
// Actions
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
// Bouton Edit
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit, size: 20),
|
||||
color: theme.colorScheme.primary,
|
||||
onPressed: onEdit,
|
||||
tooltip: 'Modifier',
|
||||
constraints: const BoxConstraints(),
|
||||
padding: const EdgeInsets.all(8),
|
||||
),
|
||||
|
||||
// Bouton Delete
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete, size: 20),
|
||||
color: theme.colorScheme.error,
|
||||
onPressed: onDelete,
|
||||
tooltip: 'Supprimer',
|
||||
constraints: const BoxConstraints(),
|
||||
padding: const EdgeInsets.all(8),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Méthode pour convertir l'ID de rôle en nom lisible
|
||||
String _getRoleName(int roleId) {
|
||||
switch (roleId) {
|
||||
case 1:
|
||||
return 'User';
|
||||
case 2:
|
||||
return 'Admin';
|
||||
case 3:
|
||||
return 'Super';
|
||||
default:
|
||||
return roleId.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
149
app/lib/presentation/widgets/membre_table_widget.dart
Normal file
149
app/lib/presentation/widgets/membre_table_widget.dart
Normal file
@@ -0,0 +1,149 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/core/data/models/membre_model.dart';
|
||||
import 'package:geosector_app/presentation/widgets/membre_row_widget.dart';
|
||||
|
||||
class MembreTableWidget extends StatelessWidget {
|
||||
final List<MembreModel> membres;
|
||||
final Function(MembreModel)? onEdit;
|
||||
final Function(MembreModel)? onDelete;
|
||||
final bool showHeader;
|
||||
final double? height;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
|
||||
const MembreTableWidget({
|
||||
Key? key,
|
||||
required this.membres,
|
||||
this.onEdit,
|
||||
this.onDelete,
|
||||
this.showHeader = true,
|
||||
this.height,
|
||||
this.padding,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Container(
|
||||
height: height,
|
||||
padding: padding ?? const EdgeInsets.all(16.0),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// En-tête du tableau
|
||||
if (showHeader)
|
||||
Padding(
|
||||
padding:
|
||||
const EdgeInsets.only(bottom: 16.0, left: 16.0, right: 16.0),
|
||||
child: Row(
|
||||
children: [
|
||||
// ID
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: Text(
|
||||
'ID',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Prénom (firstName)
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Text(
|
||||
'Prénom',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Nom (name)
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Text(
|
||||
'Nom',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Secteur (sectName)
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Text(
|
||||
'Secteur',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Rôle (fkRole)
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: Text(
|
||||
'Rôle',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Actions
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Text(
|
||||
'Actions',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.end,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Liste des membres
|
||||
Expanded(
|
||||
child: membres.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
'Aucun membre disponible',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
)
|
||||
: ListView.separated(
|
||||
itemCount: membres.length,
|
||||
separatorBuilder: (context, index) =>
|
||||
const SizedBox(height: 8.0),
|
||||
itemBuilder: (context, index) {
|
||||
final membre = membres[index];
|
||||
return MembreRowWidget(
|
||||
membre: membre,
|
||||
onEdit: onEdit != null ? () => onEdit!(membre) : null,
|
||||
onDelete:
|
||||
onDelete != null ? () => onDelete!(membre) : null,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
422
app/lib/presentation/widgets/passage_form.dart
Normal file
422
app/lib/presentation/widgets/passage_form.dart
Normal file
@@ -0,0 +1,422 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'custom_text_field.dart';
|
||||
|
||||
class PassageForm extends StatefulWidget {
|
||||
final Function(Map<String, dynamic>)? onSubmit;
|
||||
final Map<String, dynamic>? initialData;
|
||||
|
||||
const PassageForm({
|
||||
Key? key,
|
||||
this.onSubmit,
|
||||
this.initialData,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<PassageForm> createState() => _PassageFormState();
|
||||
}
|
||||
|
||||
class _PassageFormState extends State<PassageForm> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
// Controllers
|
||||
late final TextEditingController _villeController;
|
||||
late final TextEditingController _adresseController;
|
||||
late final TextEditingController _nomHabitantController;
|
||||
late final TextEditingController _emailController;
|
||||
late final TextEditingController _montantController;
|
||||
late final TextEditingController _commentairesController;
|
||||
|
||||
// Form values
|
||||
String _typeHabitat = 'Individuel';
|
||||
String _typeReglement = 'Espèces';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// Initialize controllers with initial data if available
|
||||
final data = widget.initialData ?? {};
|
||||
_villeController = TextEditingController(text: data['ville'] ?? '');
|
||||
_adresseController = TextEditingController(text: data['adresse'] ?? '');
|
||||
_nomHabitantController =
|
||||
TextEditingController(text: data['nomHabitant'] ?? '');
|
||||
_emailController = TextEditingController(text: data['email'] ?? '');
|
||||
_montantController = TextEditingController(text: data['montant'] ?? '');
|
||||
_commentairesController =
|
||||
TextEditingController(text: data['commentaires'] ?? '');
|
||||
|
||||
_typeHabitat = data['typeHabitat'] ?? 'Individuel';
|
||||
_typeReglement = data['typeReglement'] ?? 'Espèces';
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_villeController.dispose();
|
||||
_adresseController.dispose();
|
||||
_nomHabitantController.dispose();
|
||||
_emailController.dispose();
|
||||
_montantController.dispose();
|
||||
_commentairesController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _submitForm() {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
final formData = {
|
||||
'ville': _villeController.text,
|
||||
'adresse': _adresseController.text,
|
||||
'typeHabitat': _typeHabitat,
|
||||
'nomHabitant': _nomHabitantController.text,
|
||||
'email': _emailController.text,
|
||||
'montant': _montantController.text,
|
||||
'typeReglement': _typeReglement,
|
||||
'commentaires': _commentairesController.text,
|
||||
};
|
||||
|
||||
if (widget.onSubmit != null) {
|
||||
widget.onSubmit!(formData);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Ville
|
||||
CustomTextField(
|
||||
controller: _villeController,
|
||||
label: 'Ville',
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Veuillez entrer une ville';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Adresse
|
||||
CustomTextField(
|
||||
controller: _adresseController,
|
||||
label: 'Adresse',
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Veuillez entrer une adresse';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Type d'habitat
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"Type d'habitat",
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: theme.colorScheme.onBackground,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
_buildRadioOption(
|
||||
value: 'Individuel',
|
||||
groupValue: _typeHabitat,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_typeHabitat = value!;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 40),
|
||||
_buildRadioOption(
|
||||
value: 'Collectif',
|
||||
groupValue: _typeHabitat,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_typeHabitat = value!;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Nom de l'habitant
|
||||
CustomTextField(
|
||||
controller: _nomHabitantController,
|
||||
label: "Nom de l'habitant",
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return "Veuillez entrer le nom de l'habitant";
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Email de l'habitant
|
||||
CustomTextField(
|
||||
controller: _emailController,
|
||||
label: "Adresse email de l'habitant",
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return null; // Email optionnel
|
||||
}
|
||||
// Simple email validation
|
||||
if (!value.contains('@') || !value.contains('.')) {
|
||||
return "Veuillez entrer une adresse email valide";
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Montant et Type de règlement
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Montant reçu
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"Montant reçu",
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: theme.colorScheme.onBackground,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextFormField(
|
||||
controller: _montantController,
|
||||
keyboardType:
|
||||
TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(
|
||||
RegExp(r'^\d+\.?\d{0,2}')),
|
||||
],
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: theme.colorScheme.onBackground,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: '0.00 €',
|
||||
hintStyle: theme.textTheme.bodyLarge?.copyWith(
|
||||
color:
|
||||
theme.colorScheme.onBackground.withOpacity(0.5),
|
||||
),
|
||||
fillColor: const Color(0xFFF4F5F6),
|
||||
filled: true,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(
|
||||
color:
|
||||
theme.colorScheme.onBackground.withOpacity(0.1),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(
|
||||
color:
|
||||
theme.colorScheme.onBackground.withOpacity(0.1),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(
|
||||
color: theme.colorScheme.primary,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 16,
|
||||
),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Requis';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 20),
|
||||
// Type de règlement
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"Type de règlement",
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: theme.colorScheme.onBackground,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildDropdown(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Commentaires
|
||||
CustomTextField(
|
||||
controller: _commentairesController,
|
||||
label: "Commentaires",
|
||||
hintText: "Placeholder",
|
||||
maxLines: 3,
|
||||
),
|
||||
const SizedBox(height: 25),
|
||||
|
||||
// Titre de section
|
||||
Text(
|
||||
"Mise à jour du passage effectué",
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: const Color(0xFF20335E),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Bouton Enregistrer
|
||||
Center(
|
||||
child: ElevatedButton(
|
||||
onPressed: _submitForm,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF20335E),
|
||||
foregroundColor: Colors.white,
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
),
|
||||
minimumSize: const Size(200, 50),
|
||||
),
|
||||
child: const Text(
|
||||
'Enregistrer',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRadioOption({
|
||||
required String value,
|
||||
required String groupValue,
|
||||
required Function(String?) onChanged,
|
||||
}) {
|
||||
final theme = Theme.of(context);
|
||||
final isSelected = value == groupValue;
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Radio<String>(
|
||||
value: value,
|
||||
groupValue: groupValue,
|
||||
onChanged: onChanged,
|
||||
activeColor: const Color(0xFF20335E),
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onBackground,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDropdown() {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF4F5F6).withOpacity(0.85),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: const Color(0xFF20335E).withOpacity(0.1),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String>(
|
||||
value: _typeReglement,
|
||||
isExpanded: true,
|
||||
icon: const Icon(
|
||||
Icons.keyboard_arrow_down,
|
||||
color: Color(0xFF20335E),
|
||||
),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: const Color(0xFF20335E),
|
||||
),
|
||||
dropdownColor: Colors.white,
|
||||
items: <String>['Espèces', 'CB', 'Chèque']
|
||||
.map<DropdownMenuItem<String>>((String value) {
|
||||
return DropdownMenuItem<String>(
|
||||
value: value,
|
||||
child: Row(
|
||||
children: [
|
||||
_getPaymentIcon(value),
|
||||
const SizedBox(width: 8),
|
||||
Text(value),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (String? newValue) {
|
||||
setState(() {
|
||||
_typeReglement = newValue!;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _getPaymentIcon(String type) {
|
||||
switch (type) {
|
||||
case 'Espèces':
|
||||
return const Icon(Icons.payments_outlined,
|
||||
color: Color(0xFF20335E), size: 20);
|
||||
case 'CB':
|
||||
return const Icon(Icons.credit_card,
|
||||
color: Color(0xFF20335E), size: 20);
|
||||
case 'Chèque':
|
||||
return const Icon(Icons.account_balance_wallet_outlined,
|
||||
color: Color(0xFF20335E), size: 20);
|
||||
default:
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
}
|
||||
918
app/lib/presentation/widgets/passages/passages_list_widget.dart
Normal file
918
app/lib/presentation/widgets/passages/passages_list_widget.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
268
app/lib/presentation/widgets/profile_dialog.dart
Normal file
268
app/lib/presentation/widgets/profile_dialog.dart
Normal file
@@ -0,0 +1,268 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/app.dart';
|
||||
import 'package:geosector_app/core/data/models/user_model.dart';
|
||||
import 'package:geosector_app/presentation/widgets/user_form.dart';
|
||||
|
||||
class ProfileDialog extends StatefulWidget {
|
||||
final UserModel user;
|
||||
|
||||
const ProfileDialog({
|
||||
Key? key,
|
||||
required this.user,
|
||||
}) : super(key: key);
|
||||
|
||||
/// Méthode statique pour afficher la boîte de dialogue
|
||||
static Future<bool?> show(BuildContext context, UserModel user) {
|
||||
return showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => ProfileDialog(user: user),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<ProfileDialog> createState() => _ProfileDialogState();
|
||||
}
|
||||
|
||||
class _ProfileDialogState extends State<ProfileDialog> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late UserModel _user;
|
||||
bool _isLoading = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_user = widget.user;
|
||||
}
|
||||
|
||||
// Fonction pour capitaliser la première lettre de chaque mot
|
||||
String _capitalizeFirstLetter(String text) {
|
||||
if (text.isEmpty) return text;
|
||||
|
||||
return text.split(' ').map((word) {
|
||||
if (word.isEmpty) return word;
|
||||
return word[0].toUpperCase() + word.substring(1).toLowerCase();
|
||||
}).join(' ');
|
||||
}
|
||||
|
||||
// Fonction pour mettre en majuscule
|
||||
String _toUpperCase(String text) {
|
||||
return text.toUpperCase();
|
||||
}
|
||||
|
||||
// Fonction pour valider et soumettre le formulaire
|
||||
Future<void> _saveProfile(UserModel updatedUser) async {
|
||||
// Validation supplémentaire
|
||||
if (!_validateUser(updatedUser)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
// Formatage des données
|
||||
final formattedUser = updatedUser.copyWith(
|
||||
name: _toUpperCase(updatedUser.name ?? ''),
|
||||
firstName: _capitalizeFirstLetter(updatedUser.firstName ?? ''),
|
||||
);
|
||||
|
||||
// Sauvegarde de l'utilisateur
|
||||
await userRepository.saveUser(formattedUser);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Profil mis à jour avec succès'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
Navigator.of(context).pop(true); // Fermer la modale avec succès
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Erreur lors de la mise à jour du profil: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validation supplémentaire
|
||||
bool _validateUser(UserModel user) {
|
||||
// Vérifier que l'email est valide
|
||||
if (user.email.isEmpty ||
|
||||
!user.email.contains('@') ||
|
||||
!user.email.contains('.')) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Veuillez entrer une adresse email valide'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Vérifier que le nom ou le sectName est renseigné
|
||||
if ((user.name == null || user.name!.isEmpty) &&
|
||||
(user.sectName == null || user.sectName!.isEmpty)) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Le nom ou le nom du secteur doit être renseigné'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Vérifier que le téléphone fixe est valide s'il est renseigné
|
||||
if (user.phone != null &&
|
||||
user.phone!.isNotEmpty &&
|
||||
user.phone!.length != 10) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content:
|
||||
Text('Le numéro de téléphone fixe doit contenir 10 chiffres'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Vérifier que le téléphone mobile est valide s'il est renseigné
|
||||
if (user.mobile != null &&
|
||||
user.mobile!.isNotEmpty &&
|
||||
user.mobile!.length != 10) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content:
|
||||
Text('Le numéro de téléphone mobile doit contenir 10 chiffres'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
elevation: 0,
|
||||
backgroundColor: Colors.transparent,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 5),
|
||||
),
|
||||
],
|
||||
),
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: 600,
|
||||
maxHeight: 700,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Titre
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Mon compte',
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
tooltip: 'Fermer',
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
// Formulaire
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: UserForm(
|
||||
user: _user,
|
||||
onSubmit: _saveProfile,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Boutons
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed:
|
||||
_isLoading ? null : () => Navigator.of(context).pop(),
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20, vertical: 12),
|
||||
),
|
||||
child: const Text('Fermer'),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
ElevatedButton(
|
||||
onPressed: _isLoading
|
||||
? null
|
||||
: () {
|
||||
// Appeler directement la méthode onSubmit du UserForm
|
||||
// qui va déclencher la validation et la soumission
|
||||
_saveProfile(_user);
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: theme.colorScheme.primary,
|
||||
foregroundColor: theme.colorScheme.onPrimary,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20, vertical: 12),
|
||||
),
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor:
|
||||
AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
),
|
||||
)
|
||||
: const Text('Enregistrer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
716
app/lib/presentation/widgets/responsive_navigation.dart
Normal file
716
app/lib/presentation/widgets/responsive_navigation.dart
Normal file
@@ -0,0 +1,716 @@
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/core/repositories/user_repository.dart';
|
||||
import 'package:geosector_app/presentation/widgets/help_dialog.dart';
|
||||
import 'package:geosector_app/presentation/widgets/profile_dialog.dart';
|
||||
|
||||
/// Widget qui fournit une navigation responsive pour l'application.
|
||||
/// Affiche une sidebar en mode desktop et une bottomBar en mode mobile.
|
||||
class ResponsiveNavigation extends StatefulWidget {
|
||||
/// Le contenu principal à afficher
|
||||
final Widget body;
|
||||
|
||||
/// Le titre de la page
|
||||
final String title;
|
||||
|
||||
/// L'index de la page sélectionnée
|
||||
final int selectedIndex;
|
||||
|
||||
/// Callback appelé lorsqu'un élément de navigation est sélectionné
|
||||
final Function(int) onDestinationSelected;
|
||||
|
||||
/// Liste des destinations de navigation
|
||||
final List<NavigationDestination> destinations;
|
||||
|
||||
/// Actions supplémentaires à afficher dans l'AppBar
|
||||
final List<Widget>? additionalActions;
|
||||
|
||||
/// Indique si le bouton "Nouveau passage" doit être affiché
|
||||
final bool showNewPassageButton;
|
||||
|
||||
/// Callback appelé lorsque le bouton "Nouveau passage" est pressé
|
||||
final VoidCallback? onNewPassagePressed;
|
||||
|
||||
/// Clé de la boîte Hive pour sauvegarder les paramètres
|
||||
final String settingsBoxKey;
|
||||
|
||||
/// Clé pour sauvegarder l'état de la sidebar
|
||||
final String sidebarStateKey;
|
||||
|
||||
/// Widgets à afficher en bas de la sidebar
|
||||
final List<Widget>? sidebarBottomItems;
|
||||
|
||||
/// Indique si l'utilisateur est un administrateur
|
||||
final bool isAdmin;
|
||||
|
||||
/// Indique si l'AppBar doit être affiché
|
||||
final bool showAppBar;
|
||||
|
||||
const ResponsiveNavigation({
|
||||
Key? key,
|
||||
required this.body,
|
||||
required this.title,
|
||||
required this.selectedIndex,
|
||||
required this.onDestinationSelected,
|
||||
required this.destinations,
|
||||
this.additionalActions,
|
||||
this.showNewPassageButton = true,
|
||||
this.onNewPassagePressed,
|
||||
this.settingsBoxKey = AppKeys.settingsBoxName,
|
||||
this.sidebarStateKey = 'isSidebarMinimized',
|
||||
this.sidebarBottomItems,
|
||||
this.isAdmin = false,
|
||||
this.showAppBar = true,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<ResponsiveNavigation> createState() => _ResponsiveNavigationState();
|
||||
}
|
||||
|
||||
class _ResponsiveNavigationState extends State<ResponsiveNavigation> {
|
||||
/// État de la barre latérale (minimisée ou non)
|
||||
bool _isSidebarMinimized = false;
|
||||
|
||||
/// Référence à la boîte Hive pour les paramètres
|
||||
late Box _settingsBox;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initSettings();
|
||||
}
|
||||
|
||||
/// Initialiser la boîte de paramètres et charger les préférences
|
||||
Future<void> _initSettings() async {
|
||||
try {
|
||||
// Ouvrir la boîte de paramètres si elle n'est pas déjà ouverte
|
||||
if (!Hive.isBoxOpen(widget.settingsBoxKey)) {
|
||||
_settingsBox = await Hive.openBox(widget.settingsBoxKey);
|
||||
} else {
|
||||
_settingsBox = Hive.box(widget.settingsBoxKey);
|
||||
}
|
||||
|
||||
// Charger l'état de la barre latérale
|
||||
final sidebarState = _settingsBox.get(widget.sidebarStateKey);
|
||||
if (sidebarState != null && sidebarState is bool) {
|
||||
setState(() {
|
||||
_isSidebarMinimized = sidebarState;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du chargement des paramètres: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Sauvegarder l'état de la barre latérale
|
||||
void _saveSettings() {
|
||||
try {
|
||||
// Sauvegarder l'état de la barre latérale
|
||||
_settingsBox.put(widget.sidebarStateKey, _isSidebarMinimized);
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la sauvegarde des paramètres: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final size = MediaQuery.of(context).size;
|
||||
final isDesktop = size.width > 900;
|
||||
|
||||
return Scaffold(
|
||||
appBar: widget.showAppBar
|
||||
? AppBar(
|
||||
title: Text(widget.title),
|
||||
actions: _buildAppBarActions(context),
|
||||
)
|
||||
: null,
|
||||
body: isDesktop ? _buildDesktopLayout() : _buildMobileLayout(),
|
||||
bottomNavigationBar: (isDesktop) ? null : _buildBottomNavigationBar(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construction du layout pour les écrans de bureau (web)
|
||||
Widget _buildDesktopLayout() {
|
||||
return Row(
|
||||
children: [
|
||||
_buildSidebar(),
|
||||
Expanded(
|
||||
child: Container(
|
||||
color: Colors
|
||||
.transparent, // Fond transparent pour voir l'AdminBackground
|
||||
child: widget.body,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Construction du layout pour les écrans mobiles
|
||||
Widget _buildMobileLayout() {
|
||||
return Container(
|
||||
color: Colors.transparent, // Fond transparent pour voir l'AdminBackground
|
||||
child: widget.body,
|
||||
);
|
||||
}
|
||||
|
||||
/// Construction des actions de l'AppBar
|
||||
List<Widget> _buildAppBarActions(BuildContext context) {
|
||||
List<Widget> actions = [];
|
||||
|
||||
// Ajouter les actions supplémentaires si elles existent
|
||||
if (widget.additionalActions != null &&
|
||||
widget.additionalActions!.isNotEmpty) {
|
||||
actions.addAll(widget.additionalActions!);
|
||||
} else if (widget.showNewPassageButton && widget.selectedIndex == 0) {
|
||||
// Ajouter le bouton "Nouveau passage" en haut à droite pour la page d'accueil
|
||||
actions.add(
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.add_location_alt, color: Colors.white),
|
||||
label: const Text('Nouveau passage',
|
||||
style: TextStyle(color: Colors.white)),
|
||||
onPressed: widget.onNewPassagePressed ??
|
||||
() {
|
||||
// Fonction par défaut si onNewPassagePressed n'est pas fourni
|
||||
_showPassageForm(context);
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.secondary,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
),
|
||||
),
|
||||
);
|
||||
actions.add(const SizedBox(width: 16)); // Espacement à droite
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
/// Construction de la barre de navigation inférieure pour mobile
|
||||
Widget _buildBottomNavigationBar() {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return NavigationBar(
|
||||
selectedIndex: widget.selectedIndex,
|
||||
onDestinationSelected: widget.onDestinationSelected,
|
||||
backgroundColor: theme.colorScheme.surface,
|
||||
elevation: 8,
|
||||
destinations: widget.destinations,
|
||||
);
|
||||
}
|
||||
|
||||
/// Obtenir le nom complet de l'utilisateur (prénom + nom)
|
||||
String _getFullUserName(BuildContext context) {
|
||||
// Utiliser l'instance globale définie dans app.dart
|
||||
final user = userRepository.currentUser;
|
||||
|
||||
if (user == null) return 'Utilisateur';
|
||||
|
||||
String fullName = '';
|
||||
|
||||
// Ajouter le prénom si disponible
|
||||
if (user.firstName != null && user.firstName!.isNotEmpty) {
|
||||
fullName += user.firstName!;
|
||||
}
|
||||
|
||||
// Ajouter le nom
|
||||
if (user.name != null && user.name!.isNotEmpty) {
|
||||
// Ajouter un espace si le prénom est déjà présent
|
||||
if (fullName.isNotEmpty) {
|
||||
fullName += ' ';
|
||||
}
|
||||
fullName += user.name!;
|
||||
}
|
||||
|
||||
// Si aucun nom n'a été trouvé, utiliser 'Utilisateur' par défaut
|
||||
return fullName.isEmpty ? 'Utilisateur' : fullName;
|
||||
}
|
||||
|
||||
/// Obtenir les initiales du prénom et du nom de l'utilisateur
|
||||
String _getUserInitials(BuildContext context) {
|
||||
// Utiliser l'instance globale définie dans app.dart
|
||||
final user = userRepository.currentUser;
|
||||
|
||||
if (user == null) return 'U';
|
||||
|
||||
String initials = '';
|
||||
|
||||
// Ajouter l'initiale du prénom si disponible
|
||||
if (user.firstName != null && user.firstName!.isNotEmpty) {
|
||||
initials += user.firstName!.substring(0, 1).toUpperCase();
|
||||
}
|
||||
|
||||
// Ajouter l'initiale du nom
|
||||
if (user.name != null && user.name!.isNotEmpty) {
|
||||
initials += user.name!.substring(0, 1).toUpperCase();
|
||||
}
|
||||
|
||||
// Si aucune initiale n'a été trouvée, utiliser 'U' par défaut
|
||||
return initials.isEmpty ? 'U' : initials;
|
||||
}
|
||||
|
||||
/// Afficher le sectName entre parenthèses s'il existe
|
||||
Widget _buildSectNameText(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
// Utiliser l'instance globale définie dans app.dart
|
||||
final user = userRepository.currentUser;
|
||||
|
||||
// Si l'utilisateur n'a pas de sectName ou s'il est vide, retourner un widget vide
|
||||
if (user == null || user.sectName == null || user.sectName!.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
// Sinon, afficher le sectName entre parenthèses
|
||||
return Text(
|
||||
'(${user.sectName})',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
);
|
||||
}
|
||||
|
||||
/// Construction de la barre latérale pour la version web
|
||||
Widget _buildSidebar() {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Card(
|
||||
margin: EdgeInsets.zero,
|
||||
elevation: 4,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.zero,
|
||||
),
|
||||
child: Container(
|
||||
width: _isSidebarMinimized ? 70 : 250,
|
||||
color: theme.colorScheme.surface,
|
||||
child: Column(
|
||||
children: [
|
||||
// Bouton pour minimiser/maximiser la barre latérale
|
||||
Align(
|
||||
alignment: _isSidebarMinimized
|
||||
? Alignment.center
|
||||
: Alignment.centerRight,
|
||||
child: Padding(
|
||||
padding:
|
||||
EdgeInsets.only(top: 8, right: _isSidebarMinimized ? 0 : 8),
|
||||
child: IconButton(
|
||||
icon: Icon(_isSidebarMinimized
|
||||
? Icons.chevron_right
|
||||
: Icons.chevron_left),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_isSidebarMinimized = !_isSidebarMinimized;
|
||||
_saveSettings(); // Sauvegarder l'état de la barre latérale
|
||||
});
|
||||
},
|
||||
tooltip: _isSidebarMinimized ? 'Développer' : 'Réduire',
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (!_isSidebarMinimized)
|
||||
CircleAvatar(
|
||||
radius: 40,
|
||||
backgroundColor: theme.colorScheme.primary,
|
||||
child: Text(
|
||||
_getUserInitials(context),
|
||||
style: TextStyle(
|
||||
fontSize: 28,
|
||||
color: theme.colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (!_isSidebarMinimized) ...[
|
||||
Text(
|
||||
_getFullUserName(context),
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
// Afficher le sectName entre parenthèses s'il existe
|
||||
_buildSectNameText(context),
|
||||
Text(
|
||||
userRepository.currentUser?.email ?? '',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
] else
|
||||
const SizedBox(height: 8),
|
||||
const Divider(),
|
||||
|
||||
// Éléments de navigation
|
||||
for (int i = 0; i < widget.destinations.length; i++)
|
||||
_buildNavItem(
|
||||
i, widget.destinations[i].label, widget.destinations[i].icon),
|
||||
|
||||
const Spacer(),
|
||||
const Divider(),
|
||||
|
||||
// Éléments du bas de la sidebar
|
||||
if (widget.sidebarBottomItems != null && !_isSidebarMinimized)
|
||||
...widget.sidebarBottomItems!,
|
||||
|
||||
// Éléments par défaut du bas de la sidebar
|
||||
if (!_isSidebarMinimized)
|
||||
Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Text(
|
||||
'Paramètres',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
_SettingsItem(
|
||||
icon: Icons.person,
|
||||
title: 'Mon compte',
|
||||
subtitle: null,
|
||||
isSidebarMinimized: _isSidebarMinimized,
|
||||
onTap: () {
|
||||
// Afficher la boîte de dialogue de profil avec l'utilisateur actuel
|
||||
// Utiliser l'instance globale définie dans app.dart
|
||||
final user = userRepository.currentUser;
|
||||
if (user != null) {
|
||||
// Passer l'objet utilisateur complet
|
||||
ProfileDialog.show(context, user);
|
||||
} else {
|
||||
// Afficher un message d'erreur si l'utilisateur n'est pas trouvé
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('Erreur: Utilisateur non trouvé'),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
// Option "Amicale & membres" - uniquement pour les administrateurs avec le rôle 2 et en version web
|
||||
if (widget.isAdmin &&
|
||||
userRepository.currentUser?.role == 2 &&
|
||||
MediaQuery.of(context).size.width > 900)
|
||||
_SettingsItem(
|
||||
icon: Icons.people,
|
||||
title: 'Amicale & membres',
|
||||
isSidebarMinimized: _isSidebarMinimized,
|
||||
onTap: () {
|
||||
// Navigation vers le tableau de bord admin avec sélection de l'onglet "Amicale et membres"
|
||||
context.go('/admin');
|
||||
|
||||
// Sélectionner l'onglet "Amicale et membres" (index 5)
|
||||
// Nous devons sauvegarder cet index dans les paramètres pour que le tableau de bord
|
||||
// puisse le récupérer et sélectionner le bon onglet
|
||||
final settingsBox = Hive.box(AppKeys.settingsBoxName);
|
||||
settingsBox.put('adminSelectedPageIndex', 5);
|
||||
|
||||
// Notifier l'utilisateur que la page est en cours de chargement
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content:
|
||||
Text('Chargement de la page Amicale & membres...'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
|
||||
// Attendre un court instant pour permettre à la navigation de se terminer
|
||||
Future.delayed(const Duration(milliseconds: 300), () {
|
||||
// Forcer la sélection de l'onglet Amicale & membres
|
||||
if (widget.isAdmin && widget.selectedIndex != 5) {
|
||||
widget.onDestinationSelected(5);
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
|
||||
// Option "Opérations" - uniquement pour les administrateurs et en version web
|
||||
if (widget.isAdmin && MediaQuery.of(context).size.width > 900)
|
||||
_SettingsItem(
|
||||
icon: Icons.calendar_today,
|
||||
title: 'Opérations',
|
||||
isSidebarMinimized: _isSidebarMinimized,
|
||||
onTap: () {
|
||||
// Navigation vers le tableau de bord admin
|
||||
context.go('/admin');
|
||||
|
||||
// Note: Pas de page spécifique pour le moment, juste un placeholder
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Fonctionnalité à venir'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_SettingsItem(
|
||||
icon: Icons.help_outline,
|
||||
title: 'Aide',
|
||||
isSidebarMinimized: _isSidebarMinimized,
|
||||
onTap: () {
|
||||
// Afficher la boîte de dialogue d'aide avec le titre de la page courante
|
||||
HelpDialog.show(context, widget.title);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construction d'un élément de navigation pour la barre latérale
|
||||
Widget _buildNavItem(int index, String title, Widget icon) {
|
||||
final theme = Theme.of(context);
|
||||
final isSelected = widget.selectedIndex == index;
|
||||
final IconData? iconData = (icon is Icon) ? (icon as Icon).icon : null;
|
||||
|
||||
// Remplacer certains titres si l'interface est de type "user"
|
||||
String displayTitle = title;
|
||||
if (!widget.isAdmin) {
|
||||
if (title == "Accueil") {
|
||||
displayTitle = "Tableau de bord";
|
||||
} else if (title == "Stats") {
|
||||
displayTitle = "Statistiques";
|
||||
}
|
||||
}
|
||||
|
||||
if (_isSidebarMinimized) {
|
||||
// Version minimisée - afficher uniquement l'icône
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Tooltip(
|
||||
message: displayTitle,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
widget.onDestinationSelected(index);
|
||||
},
|
||||
child: Container(
|
||||
width: 50,
|
||||
height: 50,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? theme.colorScheme.primary.withOpacity(0.1)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: iconData != null
|
||||
? Icon(
|
||||
iconData,
|
||||
color: isSelected
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||
size: 24,
|
||||
)
|
||||
: icon,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Version normale avec texte et icône
|
||||
return ListTile(
|
||||
leading: iconData != null
|
||||
? Icon(
|
||||
iconData,
|
||||
color: isSelected
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||
)
|
||||
: icon,
|
||||
title: Text(
|
||||
displayTitle,
|
||||
style: TextStyle(
|
||||
color: isSelected
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.onSurface,
|
||||
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
tileColor:
|
||||
isSelected ? theme.colorScheme.primary.withOpacity(0.1) : null,
|
||||
onTap: () {
|
||||
widget.onDestinationSelected(index);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Affiche le formulaire de passage
|
||||
void _showPassageForm(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(
|
||||
'Nouveau passage',
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Adresse',
|
||||
prefixIcon: const Icon(Icons.location_on),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<int>(
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Type de passage',
|
||||
prefixIcon: const Icon(Icons.category),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
items: const [
|
||||
DropdownMenuItem(
|
||||
value: 1,
|
||||
child: Text('Effectué'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 2,
|
||||
child: Text('À finaliser'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 3,
|
||||
child: Text('Refusé'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 4,
|
||||
child: Text('Don'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 5,
|
||||
child: Text('Lot'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 6,
|
||||
child: Text('Maison vide'),
|
||||
),
|
||||
],
|
||||
onChanged: (value) {},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Commentaire',
|
||||
prefixIcon: const Icon(Icons.comment),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
maxLines: 3,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text(
|
||||
'Annuler',
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
// Enregistrer le passage
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('Passage enregistré avec succès'),
|
||||
backgroundColor: theme.colorScheme.primary,
|
||||
),
|
||||
);
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: theme.colorScheme.primary,
|
||||
foregroundColor: theme.colorScheme.onPrimary,
|
||||
),
|
||||
child: const Text('Enregistrer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Widget pour les éléments de paramètres
|
||||
class _SettingsItem extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final Widget? trailing;
|
||||
final VoidCallback onTap;
|
||||
final bool isSidebarMinimized;
|
||||
|
||||
const _SettingsItem({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
this.trailing,
|
||||
required this.onTap,
|
||||
required this.isSidebarMinimized,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
if (isSidebarMinimized) {
|
||||
// Version minimisée - afficher uniquement l'icône
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Tooltip(
|
||||
message: title,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: 50,
|
||||
height: 50,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
color: theme.colorScheme.primary,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Version normale avec texte et icône
|
||||
return ListTile(
|
||||
leading: Icon(
|
||||
icon,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
title: Text(title),
|
||||
subtitle: subtitle != null ? Text(subtitle!) : null,
|
||||
trailing: trailing,
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
218
app/lib/presentation/widgets/sector_distribution_card.dart
Normal file
218
app/lib/presentation/widgets/sector_distribution_card.dart
Normal file
@@ -0,0 +1,218 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive_flutter/hive_flutter.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/constants/app_keys.dart';
|
||||
import 'package:geosector_app/shared/app_theme.dart';
|
||||
|
||||
class SectorDistributionCard extends StatefulWidget {
|
||||
final String title;
|
||||
final double? height;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
final bool forceRefresh;
|
||||
|
||||
const SectorDistributionCard({
|
||||
Key? key,
|
||||
this.title = 'Répartition par secteur',
|
||||
this.height,
|
||||
this.padding,
|
||||
this.forceRefresh = false,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<SectorDistributionCard> createState() => _SectorDistributionCardState();
|
||||
}
|
||||
|
||||
class _SectorDistributionCardState extends State<SectorDistributionCard> {
|
||||
List<Map<String, dynamic>> sectorStats = [];
|
||||
bool isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadSectorData();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(SectorDistributionCard oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
// Recharger les données si forceRefresh est passé à true
|
||||
if (widget.forceRefresh && !oldWidget.forceRefresh) {
|
||||
_loadSectorData();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadSectorData() async {
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
// S'assurer que les boîtes Hive sont ouvertes
|
||||
if (!Hive.isBoxOpen(AppKeys.sectorsBoxName)) {
|
||||
await Hive.openBox<SectorModel>(AppKeys.sectorsBoxName);
|
||||
}
|
||||
|
||||
if (!Hive.isBoxOpen(AppKeys.passagesBoxName)) {
|
||||
await Hive.openBox<PassageModel>(AppKeys.passagesBoxName);
|
||||
}
|
||||
|
||||
// Récupérer tous les secteurs
|
||||
final sectorsBox = Hive.box<SectorModel>(AppKeys.sectorsBoxName);
|
||||
final List<SectorModel> sectors = sectorsBox.values.toList();
|
||||
|
||||
// Récupérer tous les passages
|
||||
final passagesBox = Hive.box<PassageModel>(AppKeys.passagesBoxName);
|
||||
final List<PassageModel> passages = passagesBox.values.toList();
|
||||
|
||||
// Compter les passages par secteur (en excluant ceux où fkType==2)
|
||||
final Map<int, int> sectorCounts = {};
|
||||
|
||||
for (final passage in passages) {
|
||||
// Exclure les passages où fkType==2
|
||||
if (passage.fkSector != null && passage.fkType != 2) {
|
||||
sectorCounts[passage.fkSector!] =
|
||||
(sectorCounts[passage.fkSector!] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Préparer les données pour l'affichage
|
||||
List<Map<String, dynamic>> stats = [];
|
||||
for (final sector in sectors) {
|
||||
final count = sectorCounts[sector.id] ?? 0;
|
||||
if (count > 0) {
|
||||
stats.add({
|
||||
'name': sector.libelle,
|
||||
'count': count,
|
||||
'color': sector.color.isEmpty
|
||||
? 0xFF4B77BE
|
||||
: int.tryParse(sector.color.replaceAll('#', '0xFF')) ??
|
||||
0xFF4B77BE,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setState(() {
|
||||
sectorStats = stats;
|
||||
isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du chargement des données de secteur: $e');
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: widget.height,
|
||||
padding: widget.padding ?? const EdgeInsets.all(AppTheme.spacingM),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
|
||||
boxShadow: AppTheme.cardShadow,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
widget.title,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
if (isLoading)
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
else
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh, size: 20),
|
||||
onPressed: _loadSectorData,
|
||||
tooltip: 'Rafraîchir',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingM),
|
||||
Expanded(
|
||||
child: isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: sectorStats.isEmpty
|
||||
? const Center(
|
||||
child: Text('Aucune donnée de secteur disponible'))
|
||||
: ListView.builder(
|
||||
itemCount: sectorStats.length,
|
||||
itemBuilder: (context, index) {
|
||||
final sector = sectorStats[index];
|
||||
return _buildSectorItem(
|
||||
context,
|
||||
sector['name'],
|
||||
sector['count'],
|
||||
Color(sector['color']),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectorItem(
|
||||
BuildContext context,
|
||||
String name,
|
||||
int count,
|
||||
Color color,
|
||||
) {
|
||||
final totalCount =
|
||||
sectorStats.fold(0, (sum, item) => sum + (item['count'] as int));
|
||||
final percentage = totalCount > 0 ? (count / totalCount) * 100 : 0;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppTheme.spacingS),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
name,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'$count (${percentage.toInt()}%)',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
LinearProgressIndicator(
|
||||
value: percentage / 100,
|
||||
backgroundColor: Colors.grey[200],
|
||||
valueColor: AlwaysStoppedAnimation<Color>(color),
|
||||
minHeight: 8,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
370
app/lib/presentation/widgets/user_form.dart
Normal file
370
app/lib/presentation/widgets/user_form.dart
Normal file
@@ -0,0 +1,370 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:geosector_app/core/data/models/user_model.dart';
|
||||
import 'custom_text_field.dart';
|
||||
|
||||
class UserForm extends StatefulWidget {
|
||||
final UserModel? user;
|
||||
final Function(UserModel)? onSubmit;
|
||||
final bool readOnly;
|
||||
|
||||
const UserForm({
|
||||
Key? key,
|
||||
this.user,
|
||||
this.onSubmit,
|
||||
this.readOnly = false,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<UserForm> createState() => _UserFormState();
|
||||
}
|
||||
|
||||
class _UserFormState extends State<UserForm> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
// Controllers
|
||||
late final TextEditingController _usernameController;
|
||||
late final TextEditingController _firstNameController;
|
||||
late final TextEditingController _nameController;
|
||||
late final TextEditingController _phoneController;
|
||||
late final TextEditingController _mobileController;
|
||||
late final TextEditingController _emailController;
|
||||
late final TextEditingController _dateNaissanceController;
|
||||
late final TextEditingController _dateEmbaucheController;
|
||||
|
||||
// Form values
|
||||
int _fkTitre = 1; // 1 = M., 2 = Mme
|
||||
DateTime? _dateNaissance;
|
||||
DateTime? _dateEmbauche;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// Initialize controllers with user data if available
|
||||
final user = widget.user;
|
||||
_usernameController = TextEditingController(text: user?.username ?? '');
|
||||
_firstNameController = TextEditingController(text: user?.firstName ?? '');
|
||||
_nameController = TextEditingController(text: user?.name ?? '');
|
||||
_phoneController = TextEditingController(text: user?.phone ?? '');
|
||||
_mobileController = TextEditingController(text: user?.mobile ?? '');
|
||||
_emailController = TextEditingController(text: user?.email ?? '');
|
||||
|
||||
_dateNaissance = user?.dateNaissance;
|
||||
_dateEmbauche = user?.dateEmbauche;
|
||||
|
||||
_dateNaissanceController = TextEditingController(
|
||||
text: _dateNaissance != null
|
||||
? DateFormat('dd/MM/yyyy').format(_dateNaissance!)
|
||||
: '');
|
||||
|
||||
_dateEmbaucheController = TextEditingController(
|
||||
text: _dateEmbauche != null
|
||||
? DateFormat('dd/MM/yyyy').format(_dateEmbauche!)
|
||||
: '');
|
||||
|
||||
_fkTitre = user?.fkTitre ?? 1;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_usernameController.dispose();
|
||||
_firstNameController.dispose();
|
||||
_nameController.dispose();
|
||||
_phoneController.dispose();
|
||||
_mobileController.dispose();
|
||||
_emailController.dispose();
|
||||
_dateNaissanceController.dispose();
|
||||
_dateEmbaucheController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// Méthode simplifiée pour sélectionner une date
|
||||
void _selectDate(BuildContext context, bool isDateNaissance) {
|
||||
// Utiliser un bloc try-catch pour capturer toutes les erreurs possibles
|
||||
try {
|
||||
// Afficher le sélecteur de date sans spécifier de locale
|
||||
showDatePicker(
|
||||
context: context,
|
||||
initialDate: DateTime.now(), // Toujours utiliser la date actuelle
|
||||
firstDate: DateTime(1900),
|
||||
lastDate: DateTime.now(),
|
||||
// Ne pas spécifier de locale pour éviter les problèmes
|
||||
).then((DateTime? picked) {
|
||||
// Vérifier si une date a été sélectionnée
|
||||
if (picked != null) {
|
||||
setState(() {
|
||||
// Mettre à jour la date et le texte du contrôleur
|
||||
if (isDateNaissance) {
|
||||
_dateNaissance = picked;
|
||||
_dateNaissanceController.text =
|
||||
DateFormat('dd/MM/yyyy').format(picked);
|
||||
} else {
|
||||
_dateEmbauche = picked;
|
||||
_dateEmbaucheController.text =
|
||||
DateFormat('dd/MM/yyyy').format(picked);
|
||||
}
|
||||
});
|
||||
}
|
||||
}).catchError((error) {
|
||||
// Gérer les erreurs spécifiques au sélecteur de date
|
||||
debugPrint('Erreur lors de la sélection de la date: $error');
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Erreur lors de la sélection de la date'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
});
|
||||
} catch (e) {
|
||||
// Gérer toutes les autres erreurs
|
||||
debugPrint('Exception lors de l\'affichage du sélecteur de date: $e');
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Impossible d\'afficher le sélecteur de date'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _submitForm() {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
final user = widget.user?.copyWith(
|
||||
firstName: _firstNameController.text,
|
||||
name: _nameController.text,
|
||||
phone: _phoneController.text,
|
||||
mobile: _mobileController.text,
|
||||
email: _emailController.text,
|
||||
fkTitre: _fkTitre,
|
||||
dateNaissance: _dateNaissance,
|
||||
dateEmbauche: _dateEmbauche,
|
||||
) ??
|
||||
UserModel(
|
||||
id: 0, // Sera remplacé par l'API
|
||||
firstName: _firstNameController.text,
|
||||
name: _nameController.text,
|
||||
phone: _phoneController.text,
|
||||
mobile: _mobileController.text,
|
||||
email: _emailController.text,
|
||||
fkTitre: _fkTitre,
|
||||
dateNaissance: _dateNaissance,
|
||||
dateEmbauche: _dateEmbauche,
|
||||
role: 1, // Valeur par défaut
|
||||
createdAt: DateTime.now(),
|
||||
lastSyncedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
if (widget.onSubmit != null) {
|
||||
widget.onSubmit!(user);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Nom d'utilisateur (en lecture seule)
|
||||
CustomTextField(
|
||||
controller: _usernameController,
|
||||
label: "Nom d'utilisateur",
|
||||
readOnly: true, // Toujours en lecture seule
|
||||
prefixIcon: Icons.account_circle,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Titre (M. ou Mme)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"Titre",
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: theme.colorScheme.onBackground,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
_buildRadioOption(
|
||||
value: 1,
|
||||
label: 'M.',
|
||||
groupValue: _fkTitre,
|
||||
onChanged: widget.readOnly
|
||||
? null
|
||||
: (value) {
|
||||
setState(() {
|
||||
_fkTitre = value!;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 40),
|
||||
_buildRadioOption(
|
||||
value: 2,
|
||||
label: 'Mme',
|
||||
groupValue: _fkTitre,
|
||||
onChanged: widget.readOnly
|
||||
? null
|
||||
: (value) {
|
||||
setState(() {
|
||||
_fkTitre = value!;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Prénom
|
||||
CustomTextField(
|
||||
controller: _firstNameController,
|
||||
label: "Prénom",
|
||||
readOnly: widget.readOnly,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return "Veuillez entrer le prénom";
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Nom
|
||||
CustomTextField(
|
||||
controller: _nameController,
|
||||
label: "Nom",
|
||||
readOnly: widget.readOnly,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return "Veuillez entrer le nom";
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Téléphone fixe
|
||||
CustomTextField(
|
||||
controller: _phoneController,
|
||||
label: "Téléphone fixe",
|
||||
keyboardType: TextInputType.phone,
|
||||
readOnly: widget.readOnly,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(10),
|
||||
],
|
||||
validator: (value) {
|
||||
if (value != null && value.isNotEmpty && value.length < 10) {
|
||||
return "Le numéro de téléphone doit contenir 10 chiffres";
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Téléphone mobile
|
||||
CustomTextField(
|
||||
controller: _mobileController,
|
||||
label: "Téléphone mobile",
|
||||
keyboardType: TextInputType.phone,
|
||||
readOnly: widget.readOnly,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(10),
|
||||
],
|
||||
validator: (value) {
|
||||
if (value != null && value.isNotEmpty && value.length < 10) {
|
||||
return "Le numéro de mobile doit contenir 10 chiffres";
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Email
|
||||
CustomTextField(
|
||||
controller: _emailController,
|
||||
label: "Email",
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
readOnly: widget.readOnly,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return "Veuillez entrer l'adresse email";
|
||||
}
|
||||
if (!value.contains('@') || !value.contains('.')) {
|
||||
return "Veuillez entrer une adresse email valide";
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Date de naissance
|
||||
CustomTextField(
|
||||
controller: _dateNaissanceController,
|
||||
label: "Date de naissance",
|
||||
readOnly: true,
|
||||
onTap: widget.readOnly ? null : () => _selectDate(context, true),
|
||||
suffixIcon: Icon(
|
||||
Icons.calendar_today,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Date d'embauche
|
||||
CustomTextField(
|
||||
controller: _dateEmbaucheController,
|
||||
label: "Date d'embauche",
|
||||
readOnly: true,
|
||||
onTap: widget.readOnly ? null : () => _selectDate(context, false),
|
||||
suffixIcon: Icon(
|
||||
Icons.calendar_today,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
// Espace en bas du formulaire
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRadioOption({
|
||||
required int value,
|
||||
required String label,
|
||||
required int groupValue,
|
||||
required Function(int?)? onChanged,
|
||||
}) {
|
||||
final theme = Theme.of(context);
|
||||
final isSelected = value == groupValue;
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Radio<int>(
|
||||
value: value,
|
||||
groupValue: groupValue,
|
||||
onChanged: onChanged,
|
||||
activeColor: const Color(0xFF20335E),
|
||||
),
|
||||
Text(
|
||||
label,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onBackground,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user