Files
unionflow-server-api/unionflow-mobile-apps/lib/features/members/presentation/widgets/membres_stats_overview.dart
DahoudG f89f6167cc feat(mobile): Implement Keycloak WebView authentication with HTTP callback
- 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
2025-09-15 01:44:16 +00:00

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,
),
),
],
),
],
),
);
}
}