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
This commit is contained in:
@@ -0,0 +1,281 @@
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user