🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
616 lines
21 KiB
Dart
Executable File
616 lines
21 KiB
Dart
Executable File
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
|
import 'package:flutter/material.dart';
|
|
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:hive_flutter/hive_flutter.dart';
|
|
import 'package:geosector_app/core/data/models/passage_model.dart';
|
|
import 'package:geosector_app/core/data/models/user_sector_model.dart';
|
|
import 'package:geosector_app/core/services/current_user_service.dart';
|
|
import 'package:go_router/go_router.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}, ...]
|
|
/// Si useValueListenable est true, ce paramètre est ignoré
|
|
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;
|
|
|
|
/// 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;
|
|
|
|
/// Utiliser ValueListenableBuilder pour la mise à jour automatique
|
|
final bool useValueListenable;
|
|
|
|
/// Afficher les boutons de sélection de période (7j, 14j, 21j)
|
|
final bool showPeriodButtons;
|
|
|
|
const ActivityChart({
|
|
super.key,
|
|
this.passageData,
|
|
this.periodType = 'Jour',
|
|
this.height = 350,
|
|
this.daysToShow = 15,
|
|
this.userId,
|
|
this.excludePassageTypes = const [2],
|
|
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.useValueListenable = true,
|
|
this.showPeriodButtons = false,
|
|
});
|
|
|
|
@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;
|
|
|
|
// Contrôleur de zoom pour le graphique
|
|
late ZoomPanBehavior _zoomPanBehavior;
|
|
|
|
// Période sélectionnée pour le filtre (7, 14 ou 21 jours)
|
|
late int _selectedDays;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_selectedDays = widget.daysToShow;
|
|
|
|
_animationController = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(milliseconds: 1500),
|
|
);
|
|
|
|
// Initialiser le contrôleur de zoom
|
|
_zoomPanBehavior = ZoomPanBehavior(
|
|
enablePinching: true,
|
|
enableDoubleTapZooming: true,
|
|
enablePanning: true,
|
|
zoomMode: ZoomMode.x,
|
|
);
|
|
|
|
_animationController.forward();
|
|
}
|
|
|
|
@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.useValueListenable
|
|
? false
|
|
: oldWidget.passageData != widget.passageData;
|
|
final bool filteringChanged = oldWidget.userId != widget.userId ||
|
|
!listEquals(
|
|
oldWidget.excludePassageTypes, widget.excludePassageTypes) ||
|
|
oldWidget.showAllPassages != widget.showAllPassages ||
|
|
oldWidget.useValueListenable != widget.useValueListenable;
|
|
|
|
// Si des paramètres importants ont changé, relancer l'animation
|
|
if (periodChanged || dataSourceChanged || filteringChanged) {
|
|
_animationController.reset();
|
|
_animationController.forward();
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_animationController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (widget.useValueListenable) {
|
|
return _buildWithValueListenable();
|
|
} else {
|
|
return _buildWithStaticData();
|
|
}
|
|
}
|
|
|
|
/// Construction du widget avec ValueListenableBuilder pour mise à jour automatique
|
|
Widget _buildWithValueListenable() {
|
|
return ValueListenableBuilder(
|
|
valueListenable:
|
|
Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
|
builder: (context, Box<PassageModel> passagesBox, child) {
|
|
final chartData = _calculateActivityData(passagesBox, _selectedDays);
|
|
return _buildChart(chartData);
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Construction du widget avec des données statiques
|
|
Widget _buildWithStaticData() {
|
|
if (widget.passageData == null) {
|
|
return SizedBox(
|
|
height: widget.height,
|
|
child: const Center(
|
|
child: Text('Aucune donnée fournie'),
|
|
),
|
|
);
|
|
}
|
|
|
|
final chartData = _prepareChartDataFromPassageData(widget.passageData!);
|
|
return _buildChart(chartData);
|
|
}
|
|
|
|
/// Calcule les données d'activité depuis la Hive box
|
|
List<ActivityData> _calculateActivityData(Box<PassageModel> passagesBox, int daysToShow) {
|
|
try {
|
|
final passages = passagesBox.values.toList();
|
|
final currentUser = userRepository.getCurrentUser();
|
|
|
|
// Pour les users : récupérer les secteurs assignés
|
|
Set<int>? userSectorIds;
|
|
if (!widget.showAllPassages && currentUser != null) {
|
|
final userSectors = userRepository.getUserSectors();
|
|
userSectorIds = userSectors.map((sector) => sector.id).toSet();
|
|
debugPrint('ActivityChart: Mode USER - Secteurs assignés: $userSectorIds');
|
|
} else {
|
|
debugPrint('ActivityChart: Mode ADMIN - Tous les passages');
|
|
}
|
|
|
|
// Calculer la date de début (nombre de jours en arrière)
|
|
final endDate = DateTime.now();
|
|
final startDate = endDate.subtract(Duration(days: daysToShow - 1));
|
|
|
|
debugPrint('ActivityChart: Période du ${DateFormat('yyyy-MM-dd').format(startDate)} au ${DateFormat('yyyy-MM-dd').format(endDate)}');
|
|
debugPrint('ActivityChart: Nombre total de passages: ${passages.length}');
|
|
|
|
// Préparer les données par date
|
|
final Map<String, Map<int, int>> dataByDate = {};
|
|
|
|
// Initialiser toutes les dates de la période
|
|
for (int i = 0; i < daysToShow; i++) {
|
|
final date = startDate.add(Duration(days: i));
|
|
final dateStr = DateFormat('yyyy-MM-dd').format(date);
|
|
dataByDate[dateStr] = {};
|
|
|
|
// Initialiser tous les types de passage possibles
|
|
for (final typeId in AppKeys.typesPassages.keys) {
|
|
dataByDate[dateStr]![typeId] = 0;
|
|
}
|
|
}
|
|
|
|
// Parcourir les passages et les compter par date et type
|
|
int includedCount = 0;
|
|
for (final passage in passages) {
|
|
// Appliquer les filtres
|
|
bool shouldInclude = true;
|
|
String excludeReason = '';
|
|
|
|
// Filtrer par secteurs assignés si nécessaire (pour les users)
|
|
if (userSectorIds != null && !userSectorIds.contains(passage.fkSector)) {
|
|
shouldInclude = false;
|
|
excludeReason = 'Secteur ${passage.fkSector} non assigné';
|
|
}
|
|
|
|
// Exclure les passages de type 2 (À finaliser) avec nbPassages = 0
|
|
if (shouldInclude && passage.fkType == 2 && passage.nbPassages == 0) {
|
|
shouldInclude = false;
|
|
excludeReason = 'Type 2 avec nbPassages=0';
|
|
}
|
|
|
|
// Vérifier si le passage est dans la période
|
|
final passageDate = passage.passedAt;
|
|
if (shouldInclude && (passageDate == null ||
|
|
passageDate.isBefore(startDate) ||
|
|
passageDate.isAfter(endDate))) {
|
|
shouldInclude = false;
|
|
excludeReason = passageDate == null
|
|
? 'Date null'
|
|
: 'Hors période (${DateFormat('yyyy-MM-dd').format(passageDate)})';
|
|
}
|
|
|
|
if (shouldInclude && passageDate != null) {
|
|
final dateStr = DateFormat('yyyy-MM-dd').format(passageDate);
|
|
if (dataByDate.containsKey(dateStr)) {
|
|
dataByDate[dateStr]![passage.fkType] =
|
|
(dataByDate[dateStr]![passage.fkType] ?? 0) + 1;
|
|
includedCount++;
|
|
}
|
|
} else if (!shouldInclude && userSectorIds != null) {
|
|
debugPrint('ActivityChart: Passage #${passage.id} exclu - $excludeReason (type=${passage.fkType}, secteur=${passage.fkSector}, date=${passageDate != null ? DateFormat('yyyy-MM-dd').format(passageDate) : 'null'})');
|
|
}
|
|
}
|
|
|
|
debugPrint('ActivityChart: Passages inclus dans le graphique: $includedCount');
|
|
|
|
// Convertir en liste d'ActivityData
|
|
final List<ActivityData> chartData = [];
|
|
dataByDate.forEach((dateStr, passagesByType) {
|
|
final dateParts = dateStr.split('-');
|
|
if (dateParts.length == 3) {
|
|
try {
|
|
final date = DateTime(
|
|
int.parse(dateParts[0]),
|
|
int.parse(dateParts[1]),
|
|
int.parse(dateParts[2]),
|
|
);
|
|
|
|
chartData.add(ActivityData(
|
|
date: date,
|
|
dateStr: dateStr,
|
|
passagesByType: passagesByType,
|
|
));
|
|
} catch (e) {
|
|
debugPrint('Erreur de conversion de date: $dateStr');
|
|
}
|
|
}
|
|
});
|
|
|
|
// Trier par date
|
|
chartData.sort((a, b) => a.date.compareTo(b.date));
|
|
return chartData;
|
|
} catch (e) {
|
|
debugPrint('Erreur lors du calcul des données d\'activité: $e');
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/// Prépare les données pour le graphique à partir des données de passage brutes (ancien système)
|
|
List<ActivityData> _prepareChartDataFromPassageData(
|
|
List<Map<String, dynamic>> passageData) {
|
|
try {
|
|
// 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
|
|
final List<ActivityData> 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) {
|
|
debugPrint('Erreur de conversion de date: $dateStr');
|
|
}
|
|
}
|
|
|
|
// Trier les données par date
|
|
chartData.sort((a, b) => a.date.compareTo(b.date));
|
|
return chartData;
|
|
} catch (e) {
|
|
debugPrint('Erreur lors de la préparation des données: $e');
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/// Construit le graphique avec les données fournies
|
|
Widget _buildChart(List<ActivityData> chartData) {
|
|
if (chartData.isEmpty) {
|
|
return SizedBox(
|
|
height: widget.height,
|
|
child: const Center(
|
|
child: Text('Aucune donnée disponible'),
|
|
),
|
|
);
|
|
}
|
|
|
|
return SizedBox(
|
|
height: widget.height,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// En-tête avec titre et boutons de filtre
|
|
if (widget.title.isNotEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.only(
|
|
left: 16.0, right: 16.0, top: 16.0, bottom: 8.0),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(
|
|
widget.title,
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
if (widget.showPeriodButtons)
|
|
Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
_buildPeriodButton(7),
|
|
const SizedBox(width: 4),
|
|
_buildPeriodButton(14),
|
|
const SizedBox(width: 4),
|
|
_buildPeriodButton(21),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
// Graphique
|
|
Expanded(
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(16.0, 8.0, 16.0, 16.0),
|
|
child: SfCartesianChart(
|
|
plotAreaBorderWidth: 0,
|
|
legend: const 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),
|
|
minimum: chartData.isNotEmpty ? chartData.first.date : null,
|
|
maximum: chartData.isNotEmpty ? chartData.last.date : null,
|
|
interval: 1,
|
|
),
|
|
primaryYAxis: const NumericAxis(
|
|
labelStyle: TextStyle(fontSize: 10),
|
|
axisLine: AxisLine(width: 0),
|
|
majorTickLines: MajorTickLines(size: 0),
|
|
majorGridLines: MajorGridLines(
|
|
width: 0.5,
|
|
color: Colors.grey,
|
|
dashArray: <double>[5, 5],
|
|
),
|
|
title: AxisTitle(
|
|
text: 'Passages',
|
|
textStyle: TextStyle(fontSize: 10, color: Colors.grey),
|
|
),
|
|
),
|
|
series: _buildSeries(chartData),
|
|
tooltipBehavior: TooltipBehavior(enable: true),
|
|
zoomPanBehavior: _zoomPanBehavior,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Construit les séries de données pour le graphique
|
|
List<CartesianSeries<ActivityData, DateTime>> _buildSeries(
|
|
List<ActivityData> chartData) {
|
|
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
|
|
final passageTypes = AppKeys.typesPassages.keys.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;
|
|
}
|
|
|
|
// Ajouter la série pour ce type si elle a des données
|
|
if (totalForType > 0) {
|
|
series.add(
|
|
StackedColumnSeries<ActivityData, DateTime>(
|
|
name: typeName,
|
|
dataSource: chartData,
|
|
xValueMapper: (ActivityData data, _) => data.date,
|
|
yValueMapper: (ActivityData data, _) {
|
|
return data.passagesByType[typeId] ?? 0;
|
|
},
|
|
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,
|
|
// Ajouter le callback de clic uniquement depuis home_page
|
|
onPointTap: widget.showPeriodButtons ? (ChartPointDetails details) {
|
|
_handlePointTap(details, typeId);
|
|
} : null,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
return series;
|
|
}
|
|
|
|
/// Gère le clic sur un point du graphique
|
|
void _handlePointTap(ChartPointDetails details, int typeId) {
|
|
if (details.pointIndex == null || details.pointIndex! < 0) return;
|
|
|
|
// Récupérer les données du point cliqué
|
|
final passageBox = Hive.box<PassageModel>(AppKeys.passagesBoxName);
|
|
final passages = passageBox.values.toList();
|
|
|
|
// Calculer la date de début (nombre de jours en arrière)
|
|
final endDate = DateTime.now();
|
|
final startDate = endDate.subtract(Duration(days: _selectedDays - 1));
|
|
|
|
// Créer les données d'activité
|
|
final chartData = _calculateActivityData(passageBox, _selectedDays);
|
|
|
|
if (details.pointIndex! >= chartData.length) return;
|
|
|
|
final clickedData = chartData[details.pointIndex!];
|
|
final clickedDate = clickedData.date;
|
|
|
|
// Réinitialiser tous les filtres sauf celui sélectionné
|
|
final settingsBox = Hive.box(AppKeys.settingsBoxName);
|
|
settingsBox.delete('history_selectedPaymentTypeId');
|
|
settingsBox.delete('history_selectedSectorId');
|
|
settingsBox.delete('history_selectedSectorName');
|
|
settingsBox.delete('history_selectedMemberId');
|
|
|
|
// Sauvegarder le type de passage et les dates (début et fin de journée)
|
|
settingsBox.put('history_selectedTypeId', typeId);
|
|
|
|
// Date de début : début de la journée cliquée
|
|
final startDateTime = DateTime(clickedDate.year, clickedDate.month, clickedDate.day, 0, 0, 0);
|
|
settingsBox.put('history_startDate', startDateTime.millisecondsSinceEpoch);
|
|
|
|
// Date de fin : fin de la journée cliquée
|
|
final endDateTime = DateTime(clickedDate.year, clickedDate.month, clickedDate.day, 23, 59, 59);
|
|
settingsBox.put('history_endDate', endDateTime.millisecondsSinceEpoch);
|
|
|
|
// Naviguer vers la page historique
|
|
final bool isAdmin = CurrentUserService.instance.shouldShowAdminUI;
|
|
context.go(isAdmin ? '/admin/history' : '/user/history');
|
|
}
|
|
|
|
/// Construit un bouton de sélection de période
|
|
Widget _buildPeriodButton(int days) {
|
|
final isSelected = _selectedDays == days;
|
|
return InkWell(
|
|
onTap: () {
|
|
setState(() {
|
|
_selectedDays = days;
|
|
_animationController.reset();
|
|
_animationController.forward();
|
|
});
|
|
widget.onPeriodChanged?.call(days);
|
|
},
|
|
borderRadius: BorderRadius.circular(4),
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
|
decoration: BoxDecoration(
|
|
color: isSelected
|
|
? Theme.of(context).colorScheme.primary
|
|
: Colors.grey.withValues(alpha: 0.1),
|
|
borderRadius: BorderRadius.circular(4),
|
|
border: Border.all(
|
|
color: isSelected
|
|
? Theme.of(context).colorScheme.primary
|
|
: Colors.grey.shade400,
|
|
width: 1,
|
|
),
|
|
),
|
|
child: Text(
|
|
'${days}j',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
|
color: isSelected ? Colors.white : Colors.grey.shade700,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|