- Replace flutter_appauth with custom WebView implementation to resolve deep link issues - Add KeycloakWebViewAuthService with integrated WebView for seamless authentication - Configure Android manifest for HTTP cleartext traffic support - Add network security config for development environment (192.168.1.11) - Update Keycloak client to use HTTP callback endpoint (http://192.168.1.11:8080/auth/callback) - Remove obsolete keycloak_auth_service.dart and temporary scripts - Clean up dependencies and regenerate injection configuration - Tested successfully on multiple Android devices (Xiaomi 2201116TG, SM A725F) BREAKING CHANGE: Authentication flow now uses WebView instead of external browser - Users will see Keycloak login page within the app instead of browser redirect - Resolves ERR_CLEARTEXT_NOT_PERMITTED and deep link state management issues - Maintains full OIDC compliance with PKCE flow and secure token storage Technical improvements: - WebView with custom navigation delegate for callback handling - Automatic token extraction and user info parsing from JWT - Proper error handling and user feedback - Consistent authentication state management across app lifecycle
282 lines
8.2 KiB
Dart
282 lines
8.2 KiB
Dart
import 'package:flutter/material.dart';
|
|
import '../../../../core/models/membre_model.dart';
|
|
import '../../../../shared/theme/app_theme.dart';
|
|
|
|
/// Widget de statistiques pour la liste des membres
|
|
class MembresStatsOverview extends StatelessWidget {
|
|
final List<MembreModel> membres;
|
|
final String searchQuery;
|
|
|
|
const MembresStatsOverview({
|
|
super.key,
|
|
required this.membres,
|
|
this.searchQuery = '',
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final stats = _calculateStats();
|
|
|
|
return Container(
|
|
margin: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(12),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withOpacity(0.05),
|
|
blurRadius: 10,
|
|
offset: const Offset(0, 2),
|
|
),
|
|
],
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// En-tête
|
|
Row(
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: AppTheme.primaryColor.withOpacity(0.1),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Icon(
|
|
Icons.analytics,
|
|
color: AppTheme.primaryColor,
|
|
size: 20,
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
const Text(
|
|
'Vue d\'ensemble',
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w600,
|
|
color: AppTheme.textPrimary,
|
|
),
|
|
),
|
|
const Spacer(),
|
|
if (searchQuery.isNotEmpty)
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
decoration: BoxDecoration(
|
|
color: AppTheme.infoColor.withOpacity(0.1),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Text(
|
|
'Filtré',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: AppTheme.infoColor,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// Statistiques principales
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: _buildStatCard(
|
|
'Total',
|
|
stats['total'].toString(),
|
|
Icons.people,
|
|
AppTheme.primaryColor,
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: _buildStatCard(
|
|
'Actifs',
|
|
stats['actifs'].toString(),
|
|
Icons.check_circle,
|
|
AppTheme.successColor,
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: _buildStatCard(
|
|
'Âge moyen',
|
|
'${stats['ageMoyen']} ans',
|
|
Icons.cake,
|
|
AppTheme.warningColor,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
|
|
if (stats['total'] > 0) ...[
|
|
const SizedBox(height: 16),
|
|
|
|
// Statistiques détaillées
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: _buildDetailedStat(
|
|
'Nouveaux (30j)',
|
|
stats['nouveaux'].toString(),
|
|
stats['nouveauxPourcentage'],
|
|
AppTheme.infoColor,
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: _buildDetailedStat(
|
|
'Anciens (>1an)',
|
|
stats['anciens'].toString(),
|
|
stats['anciensPourcentage'],
|
|
AppTheme.secondaryColor,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Map<String, dynamic> _calculateStats() {
|
|
if (membres.isEmpty) {
|
|
return {
|
|
'total': 0,
|
|
'actifs': 0,
|
|
'ageMoyen': 0,
|
|
'nouveaux': 0,
|
|
'nouveauxPourcentage': 0.0,
|
|
'anciens': 0,
|
|
'anciensPourcentage': 0.0,
|
|
};
|
|
}
|
|
|
|
final now = DateTime.now();
|
|
final total = membres.length;
|
|
final actifs = membres.where((m) => m.statut.toUpperCase() == 'ACTIF').length;
|
|
|
|
// Calcul de l'âge moyen
|
|
final ages = membres.map((m) => m.age).where((age) => age > 0).toList();
|
|
final ageMoyen = ages.isNotEmpty ? (ages.reduce((a, b) => a + b) / ages.length).round() : 0;
|
|
|
|
// Nouveaux membres (moins de 30 jours)
|
|
final nouveaux = membres.where((m) {
|
|
final daysDiff = now.difference(m.dateAdhesion).inDays;
|
|
return daysDiff <= 30;
|
|
}).length;
|
|
final nouveauxPourcentage = total > 0 ? (nouveaux / total * 100) : 0.0;
|
|
|
|
// Anciens membres (plus d'un an)
|
|
final anciens = membres.where((m) {
|
|
final daysDiff = now.difference(m.dateAdhesion).inDays;
|
|
return daysDiff > 365;
|
|
}).length;
|
|
final anciensPourcentage = total > 0 ? (anciens / total * 100) : 0.0;
|
|
|
|
return {
|
|
'total': total,
|
|
'actifs': actifs,
|
|
'ageMoyen': ageMoyen,
|
|
'nouveaux': nouveaux,
|
|
'nouveauxPourcentage': nouveauxPourcentage,
|
|
'anciens': anciens,
|
|
'anciensPourcentage': anciensPourcentage,
|
|
};
|
|
}
|
|
|
|
Widget _buildStatCard(String label, String value, IconData icon, Color color) {
|
|
return Container(
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: color.withOpacity(0.1),
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(color: color.withOpacity(0.2)),
|
|
),
|
|
child: Column(
|
|
children: [
|
|
Icon(icon, color: color, size: 20),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
value,
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.bold,
|
|
color: color,
|
|
),
|
|
),
|
|
Text(
|
|
label,
|
|
style: const TextStyle(
|
|
fontSize: 12,
|
|
color: AppTheme.textSecondary,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildDetailedStat(String label, String value, double percentage, Color color) {
|
|
return Container(
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: Colors.grey[50],
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(color: Colors.grey[200]!),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Container(
|
|
width: 8,
|
|
height: 8,
|
|
decoration: BoxDecoration(
|
|
color: color,
|
|
shape: BoxShape.circle,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
label,
|
|
style: const TextStyle(
|
|
fontSize: 12,
|
|
color: AppTheme.textSecondary,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 4),
|
|
Row(
|
|
children: [
|
|
Text(
|
|
value,
|
|
style: const TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
color: AppTheme.textPrimary,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
'(${percentage.toStringAsFixed(1)}%)',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: color,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|