Files
geo/app/lib/presentation/admin/admin_connexions_page.dart
Pierre 0687900564 fix: Récupérer l'opération active depuis la table operations
- Corrige l'erreur SQL 'Unknown column fk_operation in users'
- L'opération active est récupérée depuis operations.chk_active = 1
- Jointure avec users pour filtrer par entité de l'admin créateur
- Query: SELECT o.id FROM operations o INNER JOIN users u ON u.fk_entite = o.fk_entite WHERE u.id = ? AND o.chk_active = 1
2026-01-26 16:57:08 +01:00

1136 lines
35 KiB
Dart
Executable File

import 'package:flutter/material.dart';
import 'package:geosector_app/core/repositories/user_repository.dart';
import 'package:geosector_app/core/repositories/amicale_repository.dart';
import 'package:geosector_app/core/services/event_stats_service.dart';
import 'package:geosector_app/core/services/current_user_service.dart';
import 'package:geosector_app/core/data/models/event_stats_model.dart';
import 'package:geosector_app/core/data/models/amicale_model.dart';
import 'package:geosector_app/core/utils/api_exception.dart';
import 'package:intl/intl.dart';
/// Mode de sélection de la période
enum DateSelectionMode {
/// Une seule date
singleDay,
/// Plage de dates personnalisée
dateRange,
/// Derniers X jours
lastDays,
}
/// Page d'administration des connexions et événements.
///
/// Affiche les statistiques de connexion et d'activité :
/// - Pour les admins amicale : données de leur amicale uniquement
/// - Pour les super-admins : données globales avec filtre par amicale
class AdminConnexionsPage extends StatefulWidget {
final UserRepository userRepository;
final AmicaleRepository amicaleRepository;
const AdminConnexionsPage({
super.key,
required this.userRepository,
required this.amicaleRepository,
});
@override
State<AdminConnexionsPage> createState() => _AdminConnexionsPageState();
}
class _AdminConnexionsPageState extends State<AdminConnexionsPage> {
final _eventStatsService = EventStatsService.instance;
final _dateFormat = DateFormat('dd/MM/yyyy');
final _timeFormat = DateFormat('HH:mm');
// État de chargement
bool _isLoading = true;
String? _error;
// Données
EventSummary? _summary;
DailyStats? _dailyStats;
EventDetails? _details;
// Mode de sélection de période
DateSelectionMode _selectionMode = DateSelectionMode.lastDays;
// Pour mode "Jour unique"
DateTime _singleDate = DateTime.now();
// Pour mode "Période personnalisée"
DateTime _startDate = DateTime.now().subtract(const Duration(days: 6));
DateTime _endDate = DateTime.now();
// Pour mode "Derniers jours"
int _lastDays = 7;
// Filtre par type d'événement
String? _selectedEventType;
// Filtre par entité (super-admin uniquement)
int? _selectedEntityId;
List<AmicaleModel> _amicales = [];
bool _isSuperAdmin = false;
@override
void initState() {
super.initState();
_isSuperAdmin = CurrentUserService.instance.isSuperAdmin;
if (_isSuperAdmin) {
_loadAmicales();
}
_loadData();
}
/// Charge la liste des amicales pour le filtre super-admin
void _loadAmicales() {
try {
_amicales = widget.amicaleRepository.getAllAmicales();
// Trier par nom
_amicales.sort((a, b) => a.name.compareTo(b.name));
} catch (e) {
debugPrint('Erreur chargement amicales: $e');
}
}
/// Calcule les dates de début et fin selon le mode sélectionné
(DateTime, DateTime) _getDateRange() {
switch (_selectionMode) {
case DateSelectionMode.singleDay:
return (_singleDate, _singleDate);
case DateSelectionMode.dateRange:
return (_startDate, _endDate);
case DateSelectionMode.lastDays:
final end = DateTime.now();
final start = end.subtract(Duration(days: _lastDays - 1));
return (start, end);
}
}
/// Vérifie si on affiche une période (plusieurs jours)
bool get _isMultipleDays {
final (start, end) = _getDateRange();
return !start.isAtSameMomentAs(end) &&
(end.difference(start).inDays > 0 || _selectionMode != DateSelectionMode.singleDay);
}
Future<void> _loadData() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final (startDate, endDate) = _getDateRange();
// Charger les données selon le mode
if (_selectionMode == DateSelectionMode.singleDay) {
// Mode jour unique : résumé + détails du jour
final results = await Future.wait([
_eventStatsService.getSummary(
date: _singleDate,
entityId: _selectedEntityId,
),
_eventStatsService.getDetails(
date: _singleDate,
event: _selectedEventType,
entityId: _selectedEntityId,
limit: 50,
),
]);
setState(() {
_summary = results[0] as EventSummary;
_dailyStats = null; // Pas de graphique en mode jour unique
_details = results[1] as EventDetails;
_isLoading = false;
});
} else {
// Mode période : stats quotidiennes + détails du dernier jour
final results = await Future.wait([
_eventStatsService.getDailyStats(
from: startDate,
to: endDate,
entityId: _selectedEntityId,
),
_eventStatsService.getDetails(
date: endDate,
event: _selectedEventType,
entityId: _selectedEntityId,
limit: 50,
),
]);
final dailyStats = results[0] as DailyStats;
// Calculer le résumé agrégé depuis les stats quotidiennes
setState(() {
_dailyStats = dailyStats;
_summary = _computeAggregatedSummary(dailyStats);
_details = results[1] as EventDetails;
_isLoading = false;
});
}
} catch (e) {
setState(() {
_error = e is ApiException ? e.message : 'Erreur lors du chargement des données';
_isLoading = false;
});
}
}
/// Calcule un résumé agrégé à partir des stats quotidiennes
EventSummary _computeAggregatedSummary(DailyStats dailyStats) {
int authSuccess = 0;
int authFailed = 0;
int authLogout = 0;
int passagesCreated = 0;
double passagesAmount = 0;
int stripeSuccess = 0;
double stripeAmount = 0;
int totalEvents = 0;
final uniqueUsersSet = <int>{};
for (final day in dailyStats.days) {
totalEvents += day.totalCount;
// Auth events
if (day.events.containsKey('login_success')) {
authSuccess += day.events['login_success']!.count;
}
if (day.events.containsKey('login_failed')) {
authFailed += day.events['login_failed']!.count;
}
if (day.events.containsKey('logout')) {
authLogout += day.events['logout']!.count;
}
// Passages
if (day.events.containsKey('passage_created')) {
passagesCreated += day.events['passage_created']!.count;
passagesAmount += day.events['passage_created']!.sumAmount;
}
// Stripe
if (day.events.containsKey('stripe_payment_success')) {
stripeSuccess += day.events['stripe_payment_success']!.count;
stripeAmount += day.events['stripe_payment_success']!.sumAmount;
}
}
return EventSummary(
date: dailyStats.to,
stats: DayStats(
auth: AuthStats(success: authSuccess, failed: authFailed, logout: authLogout),
passages: PassageStats(created: passagesCreated, updated: 0, deleted: 0, amount: passagesAmount),
users: const UserStats(created: 0, updated: 0, deleted: 0),
sectors: const SectorStats(created: 0, updated: 0, deleted: 0),
stripe: StripeStats(created: 0, success: stripeSuccess, failed: 0, cancelled: 0, amount: stripeAmount),
),
totals: DayTotals(events: totalEvents, uniqueUsers: uniqueUsersSet.length),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final size = MediaQuery.of(context).size;
final isMobile = size.width <= 600;
return SafeArea(
child: RefreshIndicator(
onRefresh: _loadData,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: EdgeInsets.all(isMobile ? 12 : 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Titre
_buildTitle(theme),
const SizedBox(height: 16),
// Filtres de période
_buildPeriodFilters(theme, isMobile),
const SizedBox(height: 16),
// Contenu principal
if (_isLoading)
const Center(
child: Padding(
padding: EdgeInsets.all(48),
child: CircularProgressIndicator(),
),
)
else if (_error != null)
_buildErrorCard(theme)
else ...[
// Cards de résumé
_buildSummaryCards(theme, isMobile),
const SizedBox(height: 24),
// Graphique d'évolution (seulement si période > 1 jour)
if (_isMultipleDays) ...[
_buildChartSection(theme, isMobile),
const SizedBox(height: 24),
],
// Tableau des événements détaillés
_buildDetailsTable(theme, isMobile),
],
],
),
),
),
);
}
Widget _buildTitle(ThemeData theme) {
return Text(
'Connexions et activité',
style: theme.textTheme.headlineSmall?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.bold,
),
);
}
Widget _buildPeriodFilters(ThemeData theme, bool isMobile) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Sélecteur de mode
Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(
'Période :',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
SegmentedButton<DateSelectionMode>(
segments: const [
ButtonSegment(
value: DateSelectionMode.singleDay,
label: Text('Jour'),
icon: Icon(Icons.today, size: 18),
),
ButtonSegment(
value: DateSelectionMode.dateRange,
label: Text('Période'),
icon: Icon(Icons.date_range, size: 18),
),
ButtonSegment(
value: DateSelectionMode.lastDays,
label: Text('Derniers jours'),
icon: Icon(Icons.history, size: 18),
),
],
selected: {_selectionMode},
onSelectionChanged: (values) {
setState(() => _selectionMode = values.first);
_loadData();
},
style: const ButtonStyle(
visualDensity: VisualDensity.compact,
),
),
],
),
const SizedBox(height: 12),
// Contrôles spécifiques selon le mode
Wrap(
spacing: 12,
runSpacing: 12,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
// Sélecteurs de date selon le mode
..._buildDateSelectors(theme),
// Séparateur vertical
if (!isMobile)
Container(
height: 36,
width: 1,
color: theme.colorScheme.outlineVariant,
),
// Filtre par type d'événement
_buildEventTypeFilter(theme),
// Filtre par entité (super-admin uniquement)
if (_isSuperAdmin) ...[
if (!isMobile)
Container(
height: 36,
width: 1,
color: theme.colorScheme.outlineVariant,
),
_buildEntityFilter(theme),
],
],
),
],
),
),
);
}
List<Widget> _buildDateSelectors(ThemeData theme) {
switch (_selectionMode) {
case DateSelectionMode.singleDay:
return [
_buildDateButton(
theme: theme,
label: 'Date',
date: _singleDate,
onTap: () => _selectDate(
initialDate: _singleDate,
onSelected: (date) {
setState(() => _singleDate = date);
_loadData();
},
),
),
];
case DateSelectionMode.dateRange:
return [
_buildDateButton(
theme: theme,
label: 'Du',
date: _startDate,
onTap: () => _selectDate(
initialDate: _startDate,
lastDate: _endDate,
onSelected: (date) {
setState(() => _startDate = date);
_loadData();
},
),
),
const Icon(Icons.arrow_forward, size: 16),
_buildDateButton(
theme: theme,
label: 'Au',
date: _endDate,
onTap: () => _selectDate(
initialDate: _endDate,
firstDate: _startDate,
onSelected: (date) {
setState(() => _endDate = date);
_loadData();
},
),
),
];
case DateSelectionMode.lastDays:
return [
SegmentedButton<int>(
segments: const [
ButtonSegment(value: 7, label: Text('7 jours')),
ButtonSegment(value: 14, label: Text('14 jours')),
ButtonSegment(value: 21, label: Text('21 jours')),
ButtonSegment(value: 30, label: Text('30 jours')),
],
selected: {_lastDays},
onSelectionChanged: (values) {
setState(() => _lastDays = values.first);
_loadData();
},
style: const ButtonStyle(
visualDensity: VisualDensity.compact,
),
),
];
}
}
Widget _buildDateButton({
required ThemeData theme,
required String label,
required DateTime date,
required VoidCallback onTap,
}) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
border: Border.all(color: theme.colorScheme.outline),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'$label : ',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
Icon(Icons.calendar_today, size: 16, color: theme.colorScheme.primary),
const SizedBox(width: 6),
Text(
_dateFormat.format(date),
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
],
),
),
);
}
Future<void> _selectDate({
required DateTime initialDate,
DateTime? firstDate,
DateTime? lastDate,
required void Function(DateTime) onSelected,
}) async {
final picked = await showDatePicker(
context: context,
initialDate: initialDate,
firstDate: firstDate ?? DateTime(2024),
lastDate: lastDate ?? DateTime.now(),
locale: const Locale('fr', 'FR'),
);
if (picked != null) {
onSelected(picked);
}
}
Widget _buildEventTypeFilter(ThemeData theme) {
return DropdownButtonHideUnderline(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
border: Border.all(color: theme.colorScheme.outline),
borderRadius: BorderRadius.circular(8),
),
child: DropdownButton<String?>(
value: _selectedEventType,
hint: const Text('Tous les événements'),
items: const [
DropdownMenuItem(value: null, child: Text('Tous')),
DropdownMenuItem(value: 'login_success', child: Text('Connexions')),
DropdownMenuItem(value: 'login_failed', child: Text('Échecs connexion')),
DropdownMenuItem(value: 'passage_created', child: Text('Passages créés')),
DropdownMenuItem(value: 'stripe_payment_success', child: Text('Paiements Stripe')),
],
onChanged: (value) {
setState(() => _selectedEventType = value);
_loadData();
},
),
),
);
}
Widget _buildEntityFilter(ThemeData theme) {
return DropdownButtonHideUnderline(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
border: Border.all(color: theme.colorScheme.outline),
borderRadius: BorderRadius.circular(8),
color: _selectedEntityId != null
? theme.colorScheme.primaryContainer.withOpacity(0.3)
: null,
),
child: DropdownButton<int?>(
value: _selectedEntityId,
hint: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.business, size: 16, color: theme.colorScheme.primary),
const SizedBox(width: 6),
const Text('Toutes les amicales'),
],
),
selectedItemBuilder: (context) {
return [
// Item pour "Toutes"
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.business, size: 16, color: theme.colorScheme.primary),
const SizedBox(width: 6),
const Text('Toutes les amicales'),
],
),
// Items pour chaque amicale
..._amicales.map((amicale) => Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.business, size: 16, color: theme.colorScheme.primary),
const SizedBox(width: 6),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 150),
child: Text(
amicale.name,
overflow: TextOverflow.ellipsis,
),
),
],
)),
];
},
items: [
const DropdownMenuItem<int?>(
value: null,
child: Text('Toutes les amicales'),
),
..._amicales.map((amicale) => DropdownMenuItem<int?>(
value: amicale.id,
child: Text(
amicale.name,
overflow: TextOverflow.ellipsis,
),
)),
],
onChanged: (value) {
setState(() => _selectedEntityId = value);
_loadData();
},
),
),
);
}
Widget _buildErrorCard(ThemeData theme) {
return Card(
color: theme.colorScheme.errorContainer,
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(Icons.error_outline, color: theme.colorScheme.error),
const SizedBox(width: 12),
Expanded(
child: Text(
_error!,
style: TextStyle(color: theme.colorScheme.onErrorContainer),
),
),
TextButton(
onPressed: _loadData,
child: const Text('Réessayer'),
),
],
),
),
);
}
Widget _buildSummaryCards(ThemeData theme, bool isMobile) {
if (_summary == null) return const SizedBox.shrink();
final stats = _summary!.stats;
final totals = _summary!.totals;
final (startDate, endDate) = _getDateRange();
// Libellé de la période
final periodLabel = _selectionMode == DateSelectionMode.singleDay
? _dateFormat.format(_singleDate)
: '${_dateFormat.format(startDate)} - ${_dateFormat.format(endDate)}';
// Disposition responsive : 2 colonnes sur mobile, 4 sur desktop
final crossAxisCount = isMobile ? 2 : 4;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Période affichée
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
'Résumé : $periodLabel',
style: theme.textTheme.titleSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
GridView.count(
crossAxisCount: crossAxisCount,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: isMobile ? 1.3 : 1.8,
children: [
// Connexions
_buildStatCard(
theme: theme,
icon: Icons.login,
iconColor: Colors.green,
title: 'Connexions',
value: stats.auth.success.toString(),
subtitle: '${stats.auth.failed} échecs',
),
// Passages
_buildStatCard(
theme: theme,
icon: Icons.receipt_long,
iconColor: Colors.blue,
title: 'Passages',
value: stats.passages.created.toString(),
subtitle: '${_formatAmount(stats.passages.amount)} collectés',
),
// Paiements Stripe
_buildStatCard(
theme: theme,
icon: Icons.credit_card,
iconColor: Colors.purple,
title: 'Paiements',
value: stats.stripe.success.toString(),
subtitle: _formatAmount(stats.stripe.amount),
),
// Utilisateurs actifs
_buildStatCard(
theme: theme,
icon: Icons.people,
iconColor: Colors.orange,
title: 'Événements',
value: totals.events.toString(),
subtitle: '${totals.uniqueUsers} utilisateurs',
),
],
),
],
);
}
Widget _buildStatCard({
required ThemeData theme,
required IconData icon,
required Color iconColor,
required String title,
required String value,
required String subtitle,
}) {
return Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: iconColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, color: iconColor, size: 20),
),
const SizedBox(width: 8),
Expanded(
child: Text(
title,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
Text(
value,
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.onSurface,
),
),
Text(
subtitle,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
overflow: TextOverflow.ellipsis,
),
],
),
),
);
}
Widget _buildChartSection(ThemeData theme, bool isMobile) {
if (_dailyStats == null || _dailyStats!.days.isEmpty) {
return Card(
child: Padding(
padding: const EdgeInsets.all(24),
child: Center(
child: Text(
'Aucune donnée pour cette période',
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
),
);
}
final (startDate, endDate) = _getDateRange();
final daysDiff = endDate.difference(startDate).inDays + 1;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Évolution sur $daysDiff jours',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
SizedBox(
height: isMobile ? 200 : 250,
child: _buildSimpleBarChart(theme),
),
],
),
),
);
}
Widget _buildSimpleBarChart(ThemeData theme) {
final days = _dailyStats!.days;
if (days.isEmpty) return const SizedBox.shrink();
// Trouver le max pour le scaling
final maxCount = days.map((d) => d.totalCount).reduce((a, b) => a > b ? a : b);
final scale = maxCount > 0 ? 1.0 / maxCount : 1.0;
return LayoutBuilder(
builder: (context, constraints) {
final barWidth = (constraints.maxWidth - (days.length - 1) * 4) / days.length;
final clampedBarWidth = barWidth.clamp(8.0, 40.0);
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
crossAxisAlignment: CrossAxisAlignment.end,
children: days.map((day) {
final height = (constraints.maxHeight - 30) * day.totalCount * scale;
return Tooltip(
message: '${_dateFormat.format(day.date)}\n${day.totalCount} événements',
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Container(
width: clampedBarWidth,
height: height.clamp(4.0, constraints.maxHeight - 30),
decoration: BoxDecoration(
color: theme.colorScheme.primary,
borderRadius: const BorderRadius.vertical(top: Radius.circular(4)),
),
),
const SizedBox(height: 4),
SizedBox(
width: clampedBarWidth + 8,
child: Text(
DateFormat('dd').format(day.date),
style: theme.textTheme.labelSmall,
textAlign: TextAlign.center,
overflow: TextOverflow.clip,
),
),
],
),
);
}).toList(),
);
},
);
}
Widget _buildDetailsTable(ThemeData theme, bool isMobile) {
final (_, endDate) = _getDateRange();
final detailsDateLabel = _selectionMode == DateSelectionMode.singleDay
? _dateFormat.format(_singleDate)
: _dateFormat.format(endDate);
if (_details == null || _details!.events.isEmpty) {
return Card(
child: Padding(
padding: const EdgeInsets.all(24),
child: Center(
child: Text(
'Aucun événement pour le $detailsDateLabel',
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
),
);
}
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Détail des événements',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(
detailsDateLabel,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
Text(
'${_details!.pagination.total} événements',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
const SizedBox(height: 16),
// Table responsive
if (isMobile)
_buildMobileEventsList(theme)
else
_buildDesktopEventsTable(theme),
// Pagination
if (_details!.pagination.hasMore)
Padding(
padding: const EdgeInsets.only(top: 16),
child: Center(
child: TextButton.icon(
onPressed: _loadMoreDetails,
icon: const Icon(Icons.expand_more),
label: const Text('Charger plus'),
),
),
),
],
),
),
);
}
Widget _buildMobileEventsList(ThemeData theme) {
return ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: _details!.events.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) {
final event = _details!.events[index];
return ListTile(
contentPadding: EdgeInsets.zero,
leading: _buildEventIcon(event.event),
title: Text(
EventTypes.getLabel(event.event),
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
subtitle: Text(
'${event.username ?? 'Anonyme'} - ${_timeFormat.format(event.timestamp)}',
style: theme.textTheme.bodySmall,
),
trailing: event.platform != null
? _buildPlatformChip(event.platform!, theme)
: null,
);
},
);
}
Widget _buildDesktopEventsTable(ThemeData theme) {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: DataTable(
headingRowHeight: 40,
dataRowMinHeight: 40,
dataRowMaxHeight: 50,
columnSpacing: 24,
columns: const [
DataColumn(label: Text('Heure')),
DataColumn(label: Text('Type')),
DataColumn(label: Text('Utilisateur')),
DataColumn(label: Text('Plateforme')),
DataColumn(label: Text('IP')),
DataColumn(label: Text('Détails')),
],
rows: _details!.events.map((event) {
return DataRow(
cells: [
DataCell(Text(_timeFormat.format(event.timestamp))),
DataCell(Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildEventIcon(event.event),
const SizedBox(width: 8),
Text(EventTypes.getLabel(event.event)),
],
)),
DataCell(Text(event.username ?? 'Anonyme')),
DataCell(event.platform != null
? _buildPlatformChip(event.platform!, theme)
: const Text('-')),
DataCell(Text(event.ip ?? '-')),
DataCell(Text(event.reason ?? '-')),
],
);
}).toList(),
),
);
}
Widget _buildEventIcon(String eventType) {
IconData icon;
Color color;
switch (eventType) {
case 'login_success':
icon = Icons.login;
color = Colors.green;
break;
case 'login_failed':
icon = Icons.error_outline;
color = Colors.red;
break;
case 'logout':
icon = Icons.logout;
color = Colors.orange;
break;
case 'passage_created':
icon = Icons.add_circle_outline;
color = Colors.blue;
break;
case 'passage_updated':
icon = Icons.edit;
color = Colors.teal;
break;
case 'passage_deleted':
icon = Icons.delete_outline;
color = Colors.red;
break;
case 'stripe_payment_success':
icon = Icons.credit_card;
color = Colors.green;
break;
case 'stripe_payment_failed':
icon = Icons.credit_card_off;
color = Colors.red;
break;
default:
icon = Icons.circle;
color = Colors.grey;
}
return Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(6),
),
child: Icon(icon, size: 16, color: color),
);
}
Widget _buildPlatformChip(String platform, ThemeData theme) {
IconData icon;
switch (platform.toLowerCase()) {
case 'ios':
icon = Icons.phone_iphone;
break;
case 'android':
icon = Icons.phone_android;
break;
case 'web':
icon = Icons.computer;
break;
default:
icon = Icons.devices;
}
return Chip(
avatar: Icon(icon, size: 14),
label: Text(
platform.toUpperCase(),
style: theme.textTheme.labelSmall,
),
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
);
}
Future<void> _loadMoreDetails() async {
if (_details == null || !_details!.pagination.hasMore) return;
final (_, endDate) = _getDateRange();
final detailsDate = _selectionMode == DateSelectionMode.singleDay
? _singleDate
: endDate;
try {
final moreDetails = await _eventStatsService.getDetails(
date: detailsDate,
event: _selectedEventType,
entityId: _selectedEntityId,
limit: 50,
offset: _details!.events.length,
);
setState(() {
_details = EventDetails(
date: _details!.date,
events: [..._details!.events, ...moreDetails.events],
pagination: moreDetails.pagination,
);
});
} catch (e) {
if (mounted) {
ApiException.showError(context, e);
}
}
}
String _formatAmount(double amount) {
return NumberFormat.currency(locale: 'fr_FR', symbol: '').format(amount);
}
}