Files
unionflow-server-api/unionflow-mobile-apps/lib/features/members/presentation/widgets/stats_grid_card.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

244 lines
7.1 KiB
Dart

import 'package:flutter/material.dart';
import '../../../../shared/theme/app_theme.dart';
import '../../../../shared/theme/design_system.dart';
/// Grille de statistiques compacte pour mobile
class StatsGridCard extends StatefulWidget {
const StatsGridCard({
super.key,
required this.stats,
this.crossAxisCount = 2,
this.childAspectRatio = 1.2,
});
final Map<String, dynamic> stats;
final int crossAxisCount;
final double childAspectRatio;
@override
State<StatsGridCard> createState() => _StatsGridCardState();
}
class _StatsGridCardState extends State<StatsGridCard>
with TickerProviderStateMixin {
late List<AnimationController> _animationControllers;
late List<Animation<double>> _scaleAnimations;
late List<Animation<Offset>> _slideAnimations;
@override
void initState() {
super.initState();
_initializeAnimations();
}
void _initializeAnimations() {
const itemCount = 4; // Nombre de statistiques
_animationControllers = List.generate(
itemCount,
(index) => AnimationController(
duration: Duration(
milliseconds: DesignSystem.animationMedium.inMilliseconds + (index * 100),
),
vsync: this,
),
);
_scaleAnimations = _animationControllers.map((controller) {
return Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: controller,
curve: DesignSystem.animationCurveEnter,
));
}).toList();
_slideAnimations = _animationControllers.map((controller) {
return Tween<Offset>(
begin: const Offset(0, 0.5),
end: Offset.zero,
).animate(CurvedAnimation(
parent: controller,
curve: DesignSystem.animationCurveEnter,
));
}).toList();
// Démarrer les animations en cascade
for (int i = 0; i < _animationControllers.length; i++) {
Future.delayed(Duration(milliseconds: i * 100), () {
if (mounted) {
_animationControllers[i].forward();
}
});
}
}
@override
void dispose() {
for (final controller in _animationControllers) {
controller.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
final statsItems = [
_StatItem(
title: 'Total Membres',
value: widget.stats['totalMembres'].toString(),
icon: Icons.people,
color: AppTheme.primaryColor,
trend: '+${widget.stats['nouveauxCeMois']}',
trendPositive: true,
),
_StatItem(
title: 'Membres Actifs',
value: widget.stats['membresActifs'].toString(),
icon: Icons.person,
color: AppTheme.successColor,
trend: '${widget.stats['tauxActivite']}%',
trendPositive: widget.stats['tauxActivite'] >= 70,
),
_StatItem(
title: 'Nouveaux ce mois',
value: widget.stats['nouveauxCeMois'].toString(),
icon: Icons.person_add,
color: AppTheme.infoColor,
trend: 'Ce mois',
trendPositive: widget.stats['nouveauxCeMois'] > 0,
),
_StatItem(
title: 'Taux d\'activité',
value: '${widget.stats['tauxActivite']}%',
icon: Icons.trending_up,
color: AppTheme.warningColor,
trend: widget.stats['tauxActivite'] >= 70 ? 'Excellent' : 'Moyen',
trendPositive: widget.stats['tauxActivite'] >= 70,
),
];
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: widget.crossAxisCount,
childAspectRatio: widget.childAspectRatio,
crossAxisSpacing: DesignSystem.spacingMd,
mainAxisSpacing: DesignSystem.spacingMd,
),
itemCount: statsItems.length,
itemBuilder: (context, index) {
return AnimatedBuilder(
animation: _animationControllers[index],
builder: (context, child) {
return SlideTransition(
position: _slideAnimations[index],
child: ScaleTransition(
scale: _scaleAnimations[index],
child: _buildStatCard(statsItems[index]),
),
);
},
);
},
);
}
Widget _buildStatCard(_StatItem item) {
return Container(
padding: const EdgeInsets.all(DesignSystem.spacingMd),
decoration: BoxDecoration(
color: AppTheme.surfaceLight,
borderRadius: BorderRadius.circular(DesignSystem.radiusLg),
boxShadow: DesignSystem.shadowCard,
border: Border.all(
color: item.color.withOpacity(0.1),
width: 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
padding: const EdgeInsets.all(DesignSystem.spacingSm),
decoration: BoxDecoration(
color: item.color.withOpacity(0.1),
borderRadius: BorderRadius.circular(DesignSystem.radiusMd),
),
child: Icon(
item.icon,
color: item.color,
size: 20,
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: DesignSystem.spacingXs,
vertical: 2,
),
decoration: BoxDecoration(
color: item.trendPositive
? AppTheme.successColor.withOpacity(0.1)
: AppTheme.errorColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(DesignSystem.radiusSm),
),
child: Text(
item.trend,
style: DesignSystem.labelSmall.copyWith(
color: item.trendPositive
? AppTheme.successColor
: AppTheme.errorColor,
fontWeight: FontWeight.w600,
fontSize: 10,
),
),
),
],
),
const SizedBox(height: DesignSystem.spacingSm),
Text(
item.value,
style: DesignSystem.headlineMedium.copyWith(
fontWeight: FontWeight.w800,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: DesignSystem.spacingXs),
Text(
item.title,
style: DesignSystem.labelMedium.copyWith(
color: AppTheme.textSecondary,
fontWeight: FontWeight.w500,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
);
}
}
/// Modèle pour un élément de statistique
class _StatItem {
const _StatItem({
required this.title,
required this.value,
required this.icon,
required this.color,
required this.trend,
required this.trendPositive,
});
final String title;
final String value;
final IconData icon;
final Color color;
final String trend;
final bool trendPositive;
}