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:
DahoudG
2025-09-15 01:44:16 +00:00
parent 73459b3092
commit f89f6167cc
290 changed files with 34563 additions and 3528 deletions

View File

@@ -0,0 +1,117 @@
import 'package:flutter/material.dart';
import '../../../../../shared/theme/app_theme.dart';
/// Widget de carte d'action réutilisable pour les membres
class MembersActionCardWidget extends StatelessWidget {
final String title;
final String subtitle;
final IconData icon;
final Color color;
final VoidCallback onTap;
final String? badge;
const MembersActionCardWidget({
super.key,
required this.title,
required this.subtitle,
required this.icon,
required this.color,
required this.onTap,
this.badge,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Icône avec badge optionnel
Stack(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
icon,
color: color,
size: 18,
),
),
if (badge != null)
Positioned(
right: -2,
top: -2,
child: Container(
padding: const EdgeInsets.all(2),
decoration: const BoxDecoration(
color: AppTheme.errorColor,
shape: BoxShape.circle,
),
constraints: const BoxConstraints(
minWidth: 16,
minHeight: 16,
),
child: Text(
badge!,
style: const TextStyle(
color: Colors.white,
fontSize: 8,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
),
),
],
),
const SizedBox(height: 8),
// Titre
Text(
title,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
// Sous-titre
Text(
subtitle,
style: const TextStyle(
fontSize: 10,
color: AppTheme.textSecondary,
),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
);
}
}

View File

@@ -0,0 +1,163 @@
import 'package:flutter/material.dart';
import '../../../../../shared/theme/app_theme.dart';
/// Widget d'élément d'activité réutilisable pour les membres
class MembersActivityItemWidget extends StatelessWidget {
final String title;
final String description;
final String time;
final IconData icon;
final Color color;
final String? memberName;
final String? memberAvatar;
final VoidCallback? onTap;
const MembersActivityItemWidget({
super.key,
required this.title,
required this.description,
required this.time,
required this.icon,
required this.color,
this.memberName,
this.memberAvatar,
this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: AppTheme.primaryColor.withOpacity(0.1),
width: 1,
),
),
child: Row(
children: [
// Icône d'activité
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
icon,
color: color,
size: 16,
),
),
const SizedBox(width: 12),
// Contenu principal
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Titre
Text(
title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 2),
// Description
Text(
description,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
// Nom du membre si fourni
if (memberName != null) ...[
const SizedBox(height: 4),
Row(
children: [
// Avatar du membre
if (memberAvatar != null)
Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: AppTheme.primaryColor.withOpacity(0.2),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.person,
size: 10,
color: AppTheme.primaryColor,
),
)
else
Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: color.withOpacity(0.2),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
Icons.person,
size: 10,
color: color,
),
),
const SizedBox(width: 6),
Text(
memberName!,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: color,
),
),
],
),
],
],
),
),
// Temps et indicateur
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
time,
style: const TextStyle(
fontSize: 10,
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 4),
Container(
width: 6,
height: 6,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
),
],
),
],
),
),
);
}
}

View File

@@ -0,0 +1,311 @@
import 'package:flutter/material.dart';
import '../../../../../shared/theme/app_theme.dart';
/// Widget de filtres avancés pour le dashboard des membres
class MembersAdvancedFiltersWidget extends StatefulWidget {
final Function(Map<String, dynamic>) onFiltersChanged;
final Map<String, dynamic> initialFilters;
const MembersAdvancedFiltersWidget({
super.key,
required this.onFiltersChanged,
this.initialFilters = const {},
});
@override
State<MembersAdvancedFiltersWidget> createState() => _MembersAdvancedFiltersWidgetState();
}
class _MembersAdvancedFiltersWidgetState extends State<MembersAdvancedFiltersWidget>
with TickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _fadeAnimation;
Map<String, dynamic> _filters = {};
bool _isExpanded = false;
// Options de filtres
final List<String> _statusOptions = ['Tous', 'Actif', 'Inactif', 'Suspendu'];
final List<String> _ageRanges = ['Tous', '18-30', '31-45', '46-60', '60+'];
final List<String> _genderOptions = ['Tous', 'Homme', 'Femme'];
final List<String> _roleOptions = ['Tous', 'Membre', 'Responsable', 'Bureau'];
final List<String> _timeRanges = ['7 jours', '30 jours', '3 mois', '6 mois', '1 an'];
@override
void initState() {
super.initState();
_filters = Map.from(widget.initialFilters);
_animationController = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_fadeAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: _animationController, curve: Curves.easeInOut),
);
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
void _toggleExpanded() {
setState(() {
_isExpanded = !_isExpanded;
if (_isExpanded) {
_animationController.forward();
} else {
_animationController.reverse();
}
});
}
void _updateFilter(String key, dynamic value) {
setState(() {
_filters[key] = value;
});
widget.onFiltersChanged(_filters);
}
void _resetFilters() {
setState(() {
_filters.clear();
});
widget.onFiltersChanged(_filters);
}
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: 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(
children: [
// En-tête des filtres
InkWell(
onTap: _toggleExpanded,
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
child: Container(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: AppTheme.primaryColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(6),
),
child: const Icon(
Icons.tune,
color: AppTheme.primaryColor,
size: 16,
),
),
const SizedBox(width: 12),
const Expanded(
child: Text(
'Filtres Avancés',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
),
if (_filters.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: AppTheme.primaryColor,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'${_filters.length}',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 8),
AnimatedRotation(
turns: _isExpanded ? 0.5 : 0.0,
duration: const Duration(milliseconds: 300),
child: const Icon(
Icons.keyboard_arrow_down,
color: AppTheme.textSecondary,
),
),
],
),
),
),
// Contenu des filtres
AnimatedContainer(
duration: const Duration(milliseconds: 300),
height: _isExpanded ? null : 0,
child: _isExpanded
? FadeTransition(
opacity: _fadeAnimation,
child: _buildFiltersContent(),
)
: const SizedBox.shrink(),
),
],
),
);
}
Widget _buildFiltersContent() {
return Container(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Divider(height: 1),
const SizedBox(height: 16),
// Période
_buildFilterSection(
'Période',
Icons.date_range,
_buildChipFilter('timeRange', _timeRanges),
),
const SizedBox(height: 16),
// Statut
_buildFilterSection(
'Statut',
Icons.verified_user,
_buildChipFilter('status', _statusOptions),
),
const SizedBox(height: 16),
// Tranche d'âge
_buildFilterSection(
'Âge',
Icons.cake,
_buildChipFilter('ageRange', _ageRanges),
),
const SizedBox(height: 16),
// Genre
_buildFilterSection(
'Genre',
Icons.people_outline,
_buildChipFilter('gender', _genderOptions),
),
const SizedBox(height: 16),
// Rôle
_buildFilterSection(
'Rôle',
Icons.admin_panel_settings,
_buildChipFilter('role', _roleOptions),
),
const SizedBox(height: 20),
// Boutons d'action
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _resetFilters,
icon: const Icon(Icons.clear_all, size: 16),
label: const Text('Réinitialiser'),
style: OutlinedButton.styleFrom(
foregroundColor: AppTheme.textSecondary,
side: BorderSide(color: AppTheme.textSecondary.withOpacity(0.3)),
),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton.icon(
onPressed: () => _toggleExpanded(),
icon: const Icon(Icons.check, size: 16),
label: const Text('Appliquer'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white,
),
),
),
],
),
],
),
);
}
Widget _buildFilterSection(String title, IconData icon, Widget content) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, size: 16, color: AppTheme.textSecondary),
const SizedBox(width: 8),
Text(
title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppTheme.textPrimary,
),
),
],
),
const SizedBox(height: 8),
content,
],
);
}
Widget _buildChipFilter(String filterKey, List<String> options) {
return Wrap(
spacing: 8,
runSpacing: 4,
children: options.map((option) {
final isSelected = _filters[filterKey] == option;
return FilterChip(
label: Text(
option,
style: TextStyle(
fontSize: 12,
color: isSelected ? Colors.white : AppTheme.textSecondary,
),
),
selected: isSelected,
onSelected: (selected) {
if (selected) {
_updateFilter(filterKey, option);
} else {
_updateFilter(filterKey, null);
}
},
backgroundColor: Colors.grey[100],
selectedColor: AppTheme.primaryColor,
checkmarkColor: Colors.white,
side: BorderSide(
color: isSelected ? AppTheme.primaryColor : Colors.grey[300]!,
width: 1,
),
);
}).toList(),
);
}
}

View File

@@ -0,0 +1,564 @@
import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import '../../../../../shared/theme/app_theme.dart';
/// Widget de section d'analyses pour les membres
class MembersAnalyticsWidget extends StatelessWidget {
const MembersAnalyticsWidget({super.key});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Titre de section
const Row(
children: [
Icon(
Icons.analytics,
color: AppTheme.primaryColor,
size: 20,
),
SizedBox(width: 8),
Text(
'Analyses & Tendances',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
],
),
const SizedBox(height: 16),
// Grille de graphiques
GridView.count(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
crossAxisCount: 1,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
childAspectRatio: 1.4,
children: [
// Évolution des inscriptions
_buildMemberGrowthChart(),
// Répartition par âge
_buildAgeDistributionChart(),
// Activité mensuelle
_buildMonthlyActivityChart(),
],
),
],
);
}
/// Graphique d'évolution des inscriptions
Widget _buildMemberGrowthChart() {
return Container(
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(6),
decoration: BoxDecoration(
color: AppTheme.primaryColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(6),
),
child: const Icon(
Icons.trending_up,
color: AppTheme.primaryColor,
size: 16,
),
),
const SizedBox(width: 8),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Évolution des Inscriptions',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
SizedBox(height: 2),
Text(
'Croissance sur 6 mois • +24.7%',
style: TextStyle(
fontSize: 11,
color: AppTheme.textSecondary,
),
),
],
),
),
],
),
const SizedBox(height: 16),
// Graphique linéaire
Expanded(
child: LineChart(
LineChartData(
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: 50,
getDrawingHorizontalLine: (value) {
return FlLine(
color: AppTheme.primaryColor.withOpacity(0.1),
strokeWidth: 1,
);
},
),
titlesData: FlTitlesData(
show: true,
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 30,
interval: 1,
getTitlesWidget: (double value, TitleMeta meta) {
const months = ['Jan', 'Fév', 'Mar', 'Avr', 'Mai', 'Juin'];
if (value.toInt() >= 0 && value.toInt() < months.length) {
return SideTitleWidget(
axisSide: meta.axisSide,
child: Text(
months[value.toInt()],
style: const TextStyle(
color: AppTheme.textSecondary,
fontSize: 10,
),
),
);
}
return const Text('');
},
),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
interval: 50,
reservedSize: 40,
getTitlesWidget: (double value, TitleMeta meta) {
return SideTitleWidget(
axisSide: meta.axisSide,
child: Text(
'${value.toInt()}',
style: const TextStyle(
color: AppTheme.textSecondary,
fontSize: 10,
),
),
);
},
),
),
),
borderData: FlBorderData(show: false),
minX: 0,
maxX: 5,
minY: 0,
maxY: 300,
lineBarsData: [
LineChartBarData(
spots: const [
FlSpot(0, 180), // Janvier: 180 nouveaux
FlSpot(1, 195), // Février: 195 nouveaux
FlSpot(2, 210), // Mars: 210 nouveaux
FlSpot(3, 235), // Avril: 235 nouveaux
FlSpot(4, 265), // Mai: 265 nouveaux
FlSpot(5, 285), // Juin: 285 nouveaux
],
isCurved: true,
gradient: LinearGradient(
colors: [
AppTheme.primaryColor,
AppTheme.primaryColor.withOpacity(0.7),
],
),
barWidth: 3,
isStrokeCapRound: true,
dotData: FlDotData(
show: true,
getDotPainter: (spot, percent, barData, index) {
return FlDotCirclePainter(
radius: 4,
color: AppTheme.primaryColor,
strokeWidth: 2,
strokeColor: Colors.white,
);
},
),
belowBarData: BarAreaData(
show: true,
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
AppTheme.primaryColor.withOpacity(0.2),
AppTheme.primaryColor.withOpacity(0.05),
],
),
),
),
],
),
),
),
],
),
);
}
/// Graphique de répartition par âge
Widget _buildAgeDistributionChart() {
return Container(
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(6),
decoration: BoxDecoration(
color: AppTheme.successColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(6),
),
child: const Icon(
Icons.cake,
color: AppTheme.successColor,
size: 16,
),
),
const SizedBox(width: 8),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Répartition par Âge',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
SizedBox(height: 2),
Text(
'Distribution par tranches d\'âge',
style: TextStyle(
fontSize: 11,
color: AppTheme.textSecondary,
),
),
],
),
),
],
),
const SizedBox(height: 16),
// Graphique en camembert
Expanded(
child: Row(
children: [
// Graphique
Expanded(
flex: 2,
child: PieChart(
PieChartData(
sectionsSpace: 2,
centerSpaceRadius: 40,
sections: [
PieChartSectionData(
color: AppTheme.primaryColor,
value: 42,
title: '42%',
radius: 50,
titleStyle: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
PieChartSectionData(
color: AppTheme.successColor,
value: 38,
title: '38%',
radius: 50,
titleStyle: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
PieChartSectionData(
color: AppTheme.warningColor,
value: 15,
title: '15%',
radius: 50,
titleStyle: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
PieChartSectionData(
color: AppTheme.errorColor,
value: 5,
title: '5%',
radius: 50,
titleStyle: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
],
),
),
),
// Légende
Expanded(
flex: 1,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildAgeLegend('18-30 ans', '524', AppTheme.primaryColor),
const SizedBox(height: 8),
_buildAgeLegend('31-45 ans', '474', AppTheme.successColor),
const SizedBox(height: 8),
_buildAgeLegend('46-60 ans', '187', AppTheme.warningColor),
const SizedBox(height: 8),
_buildAgeLegend('60+ ans', '62', AppTheme.errorColor),
],
),
),
],
),
),
],
),
);
}
/// Widget de légende pour les âges
Widget _buildAgeLegend(String label, String count, Color color) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
),
const SizedBox(width: 6),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontSize: 10,
fontWeight: FontWeight.w500,
color: AppTheme.textPrimary,
),
),
Text(
count,
style: const TextStyle(
fontSize: 9,
color: AppTheme.textSecondary,
),
),
],
),
),
],
);
}
/// Graphique d'activité mensuelle
Widget _buildMonthlyActivityChart() {
return Container(
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(6),
decoration: BoxDecoration(
color: AppTheme.infoColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(6),
),
child: const Icon(
Icons.timeline,
color: AppTheme.infoColor,
size: 16,
),
),
const SizedBox(width: 8),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Activité Mensuelle',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
SizedBox(height: 2),
Text(
'Connexions et interactions',
style: TextStyle(
fontSize: 11,
color: AppTheme.textSecondary,
),
),
],
),
),
],
),
const SizedBox(height: 16),
// Graphique en barres
Expanded(
child: BarChart(
BarChartData(
alignment: BarChartAlignment.spaceAround,
maxY: 1200,
barTouchData: BarTouchData(enabled: false),
titlesData: FlTitlesData(
show: true,
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 30,
getTitlesWidget: (double value, TitleMeta meta) {
const months = ['Jan', 'Fév', 'Mar', 'Avr', 'Mai', 'Juin'];
if (value.toInt() >= 0 && value.toInt() < months.length) {
return SideTitleWidget(
axisSide: meta.axisSide,
child: Text(
months[value.toInt()],
style: const TextStyle(
color: AppTheme.textSecondary,
fontSize: 10,
),
),
);
}
return const Text('');
},
),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 40,
interval: 200,
getTitlesWidget: (double value, TitleMeta meta) {
return SideTitleWidget(
axisSide: meta.axisSide,
child: Text(
'${value.toInt()}',
style: const TextStyle(
color: AppTheme.textSecondary,
fontSize: 10,
),
),
);
},
),
),
),
borderData: FlBorderData(show: false),
barGroups: [
BarChartGroupData(x: 0, barRods: [BarChartRodData(toY: 850, color: AppTheme.infoColor, width: 16)]),
BarChartGroupData(x: 1, barRods: [BarChartRodData(toY: 920, color: AppTheme.infoColor, width: 16)]),
BarChartGroupData(x: 2, barRods: [BarChartRodData(toY: 1050, color: AppTheme.infoColor, width: 16)]),
BarChartGroupData(x: 3, barRods: [BarChartRodData(toY: 980, color: AppTheme.infoColor, width: 16)]),
BarChartGroupData(x: 4, barRods: [BarChartRodData(toY: 1120, color: AppTheme.infoColor, width: 16)]),
BarChartGroupData(x: 5, barRods: [BarChartRodData(toY: 1089, color: AppTheme.infoColor, width: 16)]),
],
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: 200,
getDrawingHorizontalLine: (value) {
return FlLine(
color: AppTheme.infoColor.withOpacity(0.1),
strokeWidth: 1,
);
},
),
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,828 @@
import 'package:flutter/material.dart';
import '../../../../../core/models/membre_model.dart';
import '../../../../../shared/theme/app_theme.dart';
import 'members_interactive_card_widget.dart';
import 'members_stats_widget.dart';
/// Widget de liste de membres améliorée avec animations
class MembersEnhancedListWidget extends StatefulWidget {
final List<MembreModel> members;
final Function(MembreModel) onMemberTap;
final Function(MembreModel)? onMemberCall;
final Function(MembreModel)? onMemberMessage;
final Function(MembreModel)? onMemberEdit;
final bool isLoading;
final String? searchQuery;
final Map<String, dynamic> filters;
const MembersEnhancedListWidget({
super.key,
required this.members,
required this.onMemberTap,
this.onMemberCall,
this.onMemberMessage,
this.onMemberEdit,
this.isLoading = false,
this.searchQuery,
this.filters = const {},
});
@override
State<MembersEnhancedListWidget> createState() => _MembersEnhancedListWidgetState();
}
class _MembersEnhancedListWidgetState extends State<MembersEnhancedListWidget>
with TickerProviderStateMixin {
late AnimationController _listController;
late Animation<double> _listAnimation;
List<String> _selectedMembers = [];
String _sortBy = 'name';
bool _sortAscending = true;
String _viewMode = 'card'; // 'card', 'list', 'grid'
@override
void initState() {
super.initState();
_listController = AnimationController(
duration: const Duration(milliseconds: 600),
vsync: this,
);
_listAnimation = CurvedAnimation(
parent: _listController,
curve: Curves.easeOutQuart,
);
_listController.forward();
}
@override
void dispose() {
_listController.dispose();
super.dispose();
}
List<MembreModel> get _filteredAndSortedMembers {
List<MembreModel> filtered = List.from(widget.members);
// Appliquer les filtres
if (widget.filters.isNotEmpty) {
filtered = filtered.where((member) {
bool matches = true;
if (widget.filters['status'] != null && widget.filters['status'] != 'Tous') {
matches = matches && member.statut.toUpperCase() == widget.filters['status'].toUpperCase();
}
if (widget.filters['ageRange'] != null && widget.filters['ageRange'] != 'Tous') {
final ageRange = widget.filters['ageRange'] as String;
final age = member.age;
switch (ageRange) {
case '18-30':
matches = matches && age >= 18 && age <= 30;
break;
case '31-45':
matches = matches && age >= 31 && age <= 45;
break;
case '46-60':
matches = matches && age >= 46 && age <= 60;
break;
case '60+':
matches = matches && age > 60;
break;
}
}
return matches;
}).toList();
}
// Appliquer la recherche
if (widget.searchQuery != null && widget.searchQuery!.isNotEmpty) {
final query = widget.searchQuery!.toLowerCase();
filtered = filtered.where((member) {
return member.nomComplet.toLowerCase().contains(query) ||
member.numeroMembre.toLowerCase().contains(query) ||
member.email.toLowerCase().contains(query) ||
member.telephone.contains(query);
}).toList();
}
// Trier
filtered.sort((a, b) {
int comparison = 0;
switch (_sortBy) {
case 'name':
comparison = a.nomComplet.compareTo(b.nomComplet);
break;
case 'date':
comparison = a.dateAdhesion.compareTo(b.dateAdhesion);
break;
case 'age':
comparison = a.age.compareTo(b.age);
break;
case 'status':
comparison = a.statut.compareTo(b.statut);
break;
}
return _sortAscending ? comparison : -comparison;
});
return filtered;
}
void _toggleMemberSelection(String memberId) {
setState(() {
if (_selectedMembers.contains(memberId)) {
_selectedMembers.remove(memberId);
} else {
_selectedMembers.add(memberId);
}
});
}
void _clearSelection() {
setState(() {
_selectedMembers.clear();
});
}
void _changeSortBy(String sortBy) {
setState(() {
if (_sortBy == sortBy) {
_sortAscending = !_sortAscending;
} else {
_sortBy = sortBy;
_sortAscending = true;
}
});
}
void _changeViewMode(String viewMode) {
setState(() {
_viewMode = viewMode;
});
}
@override
Widget build(BuildContext context) {
final filteredMembers = _filteredAndSortedMembers;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// En-tête avec contrôles
_buildHeader(filteredMembers.length),
const SizedBox(height: 16),
// Statistiques des membres
if (!widget.isLoading && filteredMembers.isNotEmpty)
MembersStatsWidget(
members: filteredMembers,
searchQuery: widget.searchQuery ?? '',
filters: widget.filters,
),
// Barre de sélection (si des membres sont sélectionnés)
if (_selectedMembers.isNotEmpty)
_buildSelectionBar(),
// Liste des membres
if (widget.isLoading)
_buildLoadingState()
else if (filteredMembers.isEmpty)
_buildEmptyState()
else
_buildMembersList(filteredMembers),
],
);
}
Widget _buildHeader(int memberCount) {
return Container(
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(
children: [
// Titre et compteur
Row(
children: [
const Icon(
Icons.people,
color: AppTheme.primaryColor,
size: 20,
),
const SizedBox(width: 8),
Text(
'Membres ($memberCount)',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const Spacer(),
// Modes d'affichage
_buildViewModeToggle(),
],
),
const SizedBox(height: 12),
// Contrôles de tri
Row(
children: [
const Text(
'Trier par:',
style: TextStyle(
fontSize: 14,
color: AppTheme.textSecondary,
),
),
const SizedBox(width: 8),
_buildSortChip('name', 'Nom'),
const SizedBox(width: 4),
_buildSortChip('date', 'Date'),
const SizedBox(width: 4),
_buildSortChip('age', 'Âge'),
const SizedBox(width: 4),
_buildSortChip('status', 'Statut'),
],
),
],
),
);
}
Widget _buildViewModeToggle() {
return Container(
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildViewModeButton(Icons.view_agenda, 'card'),
_buildViewModeButton(Icons.view_list, 'list'),
_buildViewModeButton(Icons.grid_view, 'grid'),
],
),
);
}
Widget _buildViewModeButton(IconData icon, String mode) {
final isSelected = _viewMode == mode;
return InkWell(
onTap: () => _changeViewMode(mode),
borderRadius: BorderRadius.circular(6),
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: isSelected ? AppTheme.primaryColor : Colors.transparent,
borderRadius: BorderRadius.circular(6),
),
child: Icon(
icon,
size: 16,
color: isSelected ? Colors.white : AppTheme.textSecondary,
),
),
);
}
Widget _buildSortChip(String sortKey, String label) {
final isSelected = _sortBy == sortKey;
return InkWell(
onTap: () => _changeSortBy(sortKey),
borderRadius: BorderRadius.circular(16),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: isSelected ? AppTheme.primaryColor.withOpacity(0.1) : Colors.transparent,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isSelected ? AppTheme.primaryColor : Colors.grey[300]!,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: TextStyle(
fontSize: 12,
color: isSelected ? AppTheme.primaryColor : AppTheme.textSecondary,
fontWeight: isSelected ? FontWeight.w500 : FontWeight.normal,
),
),
if (isSelected) ...[
const SizedBox(width: 4),
Icon(
_sortAscending ? Icons.arrow_upward : Icons.arrow_downward,
size: 12,
color: AppTheme.primaryColor,
),
],
],
),
),
);
}
Widget _buildSelectionBar() {
return Container(
margin: const EdgeInsets.only(bottom: 16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppTheme.primaryColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppTheme.primaryColor.withOpacity(0.3)),
),
child: Row(
children: [
Icon(
Icons.check_circle,
color: AppTheme.primaryColor,
size: 20,
),
const SizedBox(width: 8),
Text(
'${_selectedMembers.length} membre(s) sélectionné(s)',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppTheme.primaryColor,
),
),
const Spacer(),
TextButton(
onPressed: _clearSelection,
child: const Text('Désélectionner'),
),
const SizedBox(width: 8),
ElevatedButton.icon(
onPressed: () {
// TODO: Actions groupées
},
icon: const Icon(Icons.more_horiz, size: 16),
label: const Text('Actions'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white,
),
),
],
),
);
}
Widget _buildLoadingState() {
return const Center(
child: Padding(
padding: EdgeInsets.all(32),
child: CircularProgressIndicator(),
),
);
}
Widget _buildEmptyState() {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
children: [
Icon(
widget.searchQuery?.isNotEmpty == true ? Icons.search_off : Icons.people_outline,
size: 64,
color: AppTheme.textHint,
),
const SizedBox(height: 16),
Text(
widget.searchQuery?.isNotEmpty == true
? 'Aucun membre trouvé'
: 'Aucun membre',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 8),
Text(
widget.searchQuery?.isNotEmpty == true
? 'Essayez avec d\'autres termes de recherche'
: 'Commencez par ajouter des membres',
style: const TextStyle(
fontSize: 14,
color: AppTheme.textSecondary,
),
textAlign: TextAlign.center,
),
],
),
),
);
}
Widget _buildMembersList(List<MembreModel> members) {
if (_viewMode == 'grid') {
return _buildGridView(members);
} else if (_viewMode == 'list') {
return _buildListView(members);
} else {
return _buildCardView(members);
}
}
Widget _buildCardView(List<MembreModel> members) {
return ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: members.length,
itemBuilder: (context, index) {
final member = members[index];
return Container(
margin: const EdgeInsets.only(bottom: 12),
child: MembersInteractiveCardWidget(
member: member,
isSelected: _selectedMembers.contains(member.id),
onTap: () {
if (_selectedMembers.isNotEmpty) {
_toggleMemberSelection(member.id!);
} else {
widget.onMemberTap(member);
}
},
onCall: widget.onMemberCall != null
? () => widget.onMemberCall!(member)
: null,
onMessage: widget.onMemberMessage != null
? () => widget.onMemberMessage!(member)
: null,
onEdit: widget.onMemberEdit != null
? () => widget.onMemberEdit!(member)
: null,
),
);
},
);
}
Widget _buildListView(List<MembreModel> members) {
return ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: members.length,
itemBuilder: (context, index) {
final member = members[index];
return Container(
margin: const EdgeInsets.only(bottom: 8),
child: _buildCompactMemberTile(member),
);
},
);
}
Widget _buildGridView(List<MembreModel> members) {
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 0.85,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemCount: members.length,
itemBuilder: (context, index) {
final member = members[index];
return _buildGridMemberCard(member);
},
);
}
Widget _buildCompactMemberTile(MembreModel member) {
final isSelected = _selectedMembers.contains(member.id);
return InkWell(
onTap: () {
if (_selectedMembers.isNotEmpty) {
_toggleMemberSelection(member.id!);
} else {
widget.onMemberTap(member);
}
},
onLongPress: () => _toggleMemberSelection(member.id!),
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: isSelected ? AppTheme.primaryColor.withOpacity(0.1) : Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSelected ? AppTheme.primaryColor : Colors.grey[200]!,
width: isSelected ? 2 : 1,
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Row(
children: [
// Avatar
Container(
width: 50,
height: 50,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
colors: [
AppTheme.primaryColor,
AppTheme.primaryColor.withOpacity(0.7),
],
),
),
child: Center(
child: Text(
member.nomComplet.split(' ').map((e) => e[0]).take(2).join(),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
),
),
const SizedBox(width: 12),
// Informations
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
member.nomComplet,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 4),
Text(
member.numeroMembre,
style: TextStyle(
fontSize: 14,
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 4),
Row(
children: [
Icon(
Icons.phone,
size: 14,
color: AppTheme.textHint,
),
const SizedBox(width: 4),
Text(
member.telephone,
style: TextStyle(
fontSize: 12,
color: AppTheme.textHint,
),
),
],
),
],
),
),
// Badge de statut
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: AppTheme.successColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text(
member.statutLibelle,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppTheme.successColor,
),
),
),
// Actions rapides
PopupMenuButton<String>(
icon: Icon(
Icons.more_vert,
color: AppTheme.textSecondary,
size: 20,
),
onSelected: (value) {
switch (value) {
case 'call':
widget.onMemberCall?.call(member);
break;
case 'message':
widget.onMemberMessage?.call(member);
break;
case 'edit':
widget.onMemberEdit?.call(member);
break;
}
},
itemBuilder: (context) => [
const PopupMenuItem(
value: 'call',
child: Row(
children: [
Icon(Icons.phone, size: 16),
SizedBox(width: 8),
Text('Appeler'),
],
),
),
const PopupMenuItem(
value: 'message',
child: Row(
children: [
Icon(Icons.message, size: 16),
SizedBox(width: 8),
Text('Message'),
],
),
),
const PopupMenuItem(
value: 'edit',
child: Row(
children: [
Icon(Icons.edit, size: 16),
SizedBox(width: 8),
Text('Modifier'),
],
),
),
],
),
],
),
),
);
}
Widget _buildGridMemberCard(MembreModel member) {
final isSelected = _selectedMembers.contains(member.id);
return InkWell(
onTap: () {
if (_selectedMembers.isNotEmpty) {
_toggleMemberSelection(member.id!);
} else {
widget.onMemberTap(member);
}
},
onLongPress: () => _toggleMemberSelection(member.id!),
borderRadius: BorderRadius.circular(16),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isSelected ? AppTheme.primaryColor.withOpacity(0.1) : Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isSelected ? AppTheme.primaryColor : Colors.grey[200]!,
width: isSelected ? 2 : 1,
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Column(
children: [
// Avatar
Container(
width: 60,
height: 60,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
colors: [
AppTheme.primaryColor,
AppTheme.primaryColor.withOpacity(0.7),
],
),
),
child: Center(
child: Text(
member.nomComplet.split(' ').map((e) => e[0]).take(2).join(),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 18,
),
),
),
),
const SizedBox(height: 12),
// Nom
Text(
member.nomComplet,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
// Numéro membre
Text(
member.numeroMembre,
style: TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 8),
// Badge de statut
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: AppTheme.successColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text(
member.statutLibelle,
style: const TextStyle(
fontSize: 10,
fontWeight: FontWeight.w500,
color: AppTheme.successColor,
),
),
),
const Spacer(),
// Actions rapides
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
IconButton(
onPressed: () => widget.onMemberCall?.call(member),
icon: const Icon(Icons.phone, size: 18),
style: IconButton.styleFrom(
backgroundColor: AppTheme.successColor.withOpacity(0.1),
foregroundColor: AppTheme.successColor,
minimumSize: const Size(32, 32),
),
),
IconButton(
onPressed: () => widget.onMemberMessage?.call(member),
icon: const Icon(Icons.message, size: 18),
style: IconButton.styleFrom(
backgroundColor: AppTheme.infoColor.withOpacity(0.1),
foregroundColor: AppTheme.infoColor,
minimumSize: const Size(32, 32),
),
),
IconButton(
onPressed: () => widget.onMemberEdit?.call(member),
icon: const Icon(Icons.edit, size: 18),
style: IconButton.styleFrom(
backgroundColor: AppTheme.warningColor.withOpacity(0.1),
foregroundColor: AppTheme.warningColor,
minimumSize: const Size(32, 32),
),
),
],
),
],
),
),
);
}
}

View File

@@ -0,0 +1,471 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../../../../core/models/membre_model.dart';
import '../../../../../shared/theme/app_theme.dart';
/// Carte membre interactive avec animations avancées
class MembersInteractiveCardWidget extends StatefulWidget {
final MembreModel member;
final VoidCallback? onTap;
final VoidCallback? onCall;
final VoidCallback? onMessage;
final VoidCallback? onEdit;
final bool isSelected;
final bool showActions;
const MembersInteractiveCardWidget({
super.key,
required this.member,
this.onTap,
this.onCall,
this.onMessage,
this.onEdit,
this.isSelected = false,
this.showActions = true,
});
@override
State<MembersInteractiveCardWidget> createState() => _MembersInteractiveCardWidgetState();
}
class _MembersInteractiveCardWidgetState extends State<MembersInteractiveCardWidget>
with TickerProviderStateMixin {
late AnimationController _hoverController;
late AnimationController _tapController;
late AnimationController _actionsController;
late Animation<double> _scaleAnimation;
late Animation<double> _elevationAnimation;
late Animation<double> _actionsAnimation;
late Animation<Offset> _slideAnimation;
bool _isHovered = false;
bool _showActions = false;
@override
void initState() {
super.initState();
_hoverController = AnimationController(
duration: const Duration(milliseconds: 200),
vsync: this,
);
_tapController = AnimationController(
duration: const Duration(milliseconds: 100),
vsync: this,
);
_actionsController = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_scaleAnimation = Tween<double>(begin: 1.0, end: 1.02).animate(
CurvedAnimation(parent: _hoverController, curve: Curves.easeOut),
);
_elevationAnimation = Tween<double>(begin: 2.0, end: 8.0).animate(
CurvedAnimation(parent: _hoverController, curve: Curves.easeOut),
);
_actionsAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: _actionsController, curve: Curves.elasticOut),
);
_slideAnimation = Tween<Offset>(
begin: const Offset(1.0, 0.0),
end: Offset.zero,
).animate(CurvedAnimation(parent: _actionsController, curve: Curves.easeOut));
}
@override
void dispose() {
_hoverController.dispose();
_tapController.dispose();
_actionsController.dispose();
super.dispose();
}
void _onHover(bool isHovered) {
setState(() {
_isHovered = isHovered;
});
if (isHovered) {
_hoverController.forward();
if (widget.showActions) {
_showActions = true;
_actionsController.forward();
}
} else {
_hoverController.reverse();
_showActions = false;
_actionsController.reverse();
}
}
void _onTapDown(TapDownDetails details) {
_tapController.forward();
HapticFeedback.lightImpact();
}
void _onTapUp(TapUpDetails details) {
_tapController.reverse();
}
void _onTapCancel() {
_tapController.reverse();
}
Color _getStatusColor() {
switch (widget.member.statut.toUpperCase()) {
case 'ACTIF':
return AppTheme.successColor;
case 'INACTIF':
return AppTheme.warningColor;
case 'SUSPENDU':
return AppTheme.errorColor;
default:
return AppTheme.textSecondary;
}
}
String _getInitials() {
final names = '${widget.member.prenom} ${widget.member.nom}'.split(' ');
return names.take(2).map((name) => name.isNotEmpty ? name[0].toUpperCase() : '').join();
}
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (_) => _onHover(true),
onExit: (_) => _onHover(false),
child: GestureDetector(
onTapDown: _onTapDown,
onTapUp: _onTapUp,
onTapCancel: _onTapCancel,
onTap: widget.onTap,
child: AnimatedBuilder(
animation: Listenable.merge([_hoverController, _tapController]),
builder: (context, child) {
return Transform.scale(
scale: _scaleAnimation.value * (1.0 - _tapController.value * 0.02),
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 6),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: widget.isSelected
? Border.all(color: AppTheme.primaryColor, width: 2)
: null,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: _elevationAnimation.value,
offset: Offset(0, _elevationAnimation.value / 2),
),
],
),
child: Stack(
children: [
// Contenu principal
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// En-tête avec avatar et statut
Row(
children: [
_buildAnimatedAvatar(),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.member.nomComplet,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Row(
children: [
Text(
widget.member.numeroMembre,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
const SizedBox(width: 8),
_buildStatusBadge(),
],
),
],
),
),
],
),
const SizedBox(height: 12),
// Informations de contact
_buildContactInfo(),
const SizedBox(height: 12),
// Informations supplémentaires
_buildAdditionalInfo(),
],
),
),
// Actions flottantes
if (_showActions && widget.showActions)
Positioned(
top: 8,
right: 8,
child: SlideTransition(
position: _slideAnimation,
child: ScaleTransition(
scale: _actionsAnimation,
child: _buildFloatingActions(),
),
),
),
// Indicateur de sélection
if (widget.isSelected)
Positioned(
top: 8,
left: 8,
child: Container(
padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(
color: AppTheme.primaryColor,
shape: BoxShape.circle,
),
child: const Icon(
Icons.check,
color: Colors.white,
size: 12,
),
),
),
],
),
),
);
},
),
),
);
}
Widget _buildAnimatedAvatar() {
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: _isHovered ? 52 : 48,
height: _isHovered ? 52 : 48,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
AppTheme.primaryColor,
AppTheme.primaryColor.withOpacity(0.7),
],
),
borderRadius: BorderRadius.circular(_isHovered ? 16 : 14),
boxShadow: [
BoxShadow(
color: AppTheme.primaryColor.withOpacity(0.3),
blurRadius: _isHovered ? 8 : 4,
offset: const Offset(0, 2),
),
],
),
child: Center(
child: Text(
_getInitials(),
style: TextStyle(
color: Colors.white,
fontSize: _isHovered ? 18 : 16,
fontWeight: FontWeight.bold,
),
),
),
);
}
Widget _buildStatusBadge() {
final statusColor = _getStatusColor();
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: statusColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: statusColor.withOpacity(0.3)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 6,
height: 6,
decoration: BoxDecoration(
color: statusColor,
shape: BoxShape.circle,
),
),
const SizedBox(width: 4),
Text(
widget.member.statutLibelle,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w500,
color: statusColor,
),
),
],
),
);
}
Widget _buildContactInfo() {
return Column(
children: [
_buildInfoRow(Icons.email_outlined, widget.member.email),
const SizedBox(height: 4),
_buildInfoRow(Icons.phone_outlined, widget.member.telephone),
],
);
}
Widget _buildInfoRow(IconData icon, String text) {
return Row(
children: [
Icon(
icon,
size: 14,
color: AppTheme.textSecondary,
),
const SizedBox(width: 6),
Expanded(
child: Text(
text,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
);
}
Widget _buildAdditionalInfo() {
return Row(
children: [
_buildInfoChip(
Icons.cake_outlined,
'${widget.member.age} ans',
AppTheme.infoColor,
),
const SizedBox(width: 8),
_buildInfoChip(
Icons.calendar_today_outlined,
'Depuis ${widget.member.dateAdhesion?.year ?? 'N/A'}',
AppTheme.successColor,
),
],
);
}
Widget _buildInfoChip(IconData icon, String text, Color color) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 12,
color: color,
),
const SizedBox(width: 4),
Text(
text,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w500,
color: color,
),
),
],
),
);
}
Widget _buildFloatingActions() {
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildActionButton(
Icons.phone,
AppTheme.successColor,
widget.onCall,
),
_buildActionButton(
Icons.message,
AppTheme.infoColor,
widget.onMessage,
),
_buildActionButton(
Icons.edit,
AppTheme.warningColor,
widget.onEdit,
),
],
),
);
}
Widget _buildActionButton(IconData icon, Color color, VoidCallback? onTap) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(16),
child: Container(
padding: const EdgeInsets.all(8),
child: Icon(
icon,
size: 16,
color: color,
),
),
);
}
}

View File

@@ -0,0 +1,169 @@
import 'package:flutter/material.dart';
import '../../../../../shared/theme/app_theme.dart';
/// Widget de carte KPI réutilisable pour les membres
class MembersKPICardWidget extends StatelessWidget {
final String title;
final String value;
final String subtitle;
final IconData icon;
final Color color;
final String? trend;
final bool? isPositiveTrend;
final List<String>? details;
final VoidCallback? onTap;
const MembersKPICardWidget({
super.key,
required this.title,
required this.value,
required this.subtitle,
required this.icon,
required this.color,
this.trend,
this.isPositiveTrend,
this.details,
this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
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 avec icône et titre
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
icon,
color: color,
size: 20,
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppTheme.textSecondary,
),
),
),
if (trend != null) ...[
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: (isPositiveTrend ?? true)
? AppTheme.successColor.withOpacity(0.1)
: AppTheme.errorColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
(isPositiveTrend ?? true)
? Icons.trending_up
: Icons.trending_down,
size: 12,
color: (isPositiveTrend ?? true)
? AppTheme.successColor
: AppTheme.errorColor,
),
const SizedBox(width: 2),
Text(
trend!,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: (isPositiveTrend ?? true)
? AppTheme.successColor
: AppTheme.errorColor,
),
),
],
),
),
],
],
),
const SizedBox(height: 12),
// Valeur principale
Text(
value,
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: color,
),
),
const SizedBox(height: 4),
// Sous-titre
Text(
subtitle,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
// Détails optionnels
if (details != null && details!.isNotEmpty) ...[
const SizedBox(height: 8),
...details!.take(2).map((detail) => Padding(
padding: const EdgeInsets.only(top: 2),
child: Row(
children: [
Container(
width: 4,
height: 4,
decoration: BoxDecoration(
color: color.withOpacity(0.6),
shape: BoxShape.circle,
),
),
const SizedBox(width: 6),
Expanded(
child: Text(
detail,
style: const TextStyle(
fontSize: 10,
color: AppTheme.textSecondary,
),
),
),
],
),
)),
],
],
),
),
);
}
}

View File

@@ -0,0 +1,200 @@
import 'package:flutter/material.dart';
import '../../../../../shared/theme/app_theme.dart';
import 'members_kpi_card_widget.dart';
/// Widget de section KPI pour le dashboard des membres
class MembersKPISectionWidget extends StatelessWidget {
const MembersKPISectionWidget({super.key});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Titre de section
const Row(
children: [
Icon(
Icons.analytics,
color: AppTheme.primaryColor,
size: 20,
),
SizedBox(width: 8),
Text(
'Indicateurs Clés',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
],
),
const SizedBox(height: 16),
// Grille de KPI
GridView.count(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
crossAxisCount: 2,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
childAspectRatio: 1.1,
children: [
// Total des membres
MembersKPICardWidget(
title: 'Total Membres',
value: '1,247',
subtitle: 'Membres enregistrés',
icon: Icons.people,
color: AppTheme.primaryColor,
trend: '+24.7%',
isPositiveTrend: true,
details: const [
'1,089 Actifs (87.3%)',
'158 Inactifs (12.7%)',
],
onTap: () => _showMemberDetails(context, 'total'),
),
// Nouveaux membres
MembersKPICardWidget(
title: 'Nouveaux Membres',
value: '47',
subtitle: 'Ce mois-ci',
icon: Icons.person_add,
color: AppTheme.successColor,
trend: '+15.2%',
isPositiveTrend: true,
details: const [
'28 Particuliers',
'19 Professionnels',
],
onTap: () => _showMemberDetails(context, 'nouveaux'),
),
// Membres actifs
MembersKPICardWidget(
title: 'Membres Actifs',
value: '1,089',
subtitle: 'Derniers 30 jours',
icon: Icons.trending_up,
color: AppTheme.infoColor,
trend: '+8.3%',
isPositiveTrend: true,
details: const [
'892 Très actifs',
'197 Modérément actifs',
],
onTap: () => _showMemberDetails(context, 'actifs'),
),
// Taux de rétention
MembersKPICardWidget(
title: 'Taux de Rétention',
value: '94.2%',
subtitle: 'Sur 12 mois',
icon: Icons.favorite,
color: AppTheme.warningColor,
trend: '+2.1%',
isPositiveTrend: true,
details: const [
'1,175 Fidèles',
'72 Nouveaux',
],
onTap: () => _showMemberDetails(context, 'retention'),
),
// Âge moyen
MembersKPICardWidget(
title: 'Âge Moyen',
value: '34.5',
subtitle: 'Années',
icon: Icons.cake,
color: AppTheme.errorColor,
trend: '+0.8',
isPositiveTrend: true,
details: const [
'18-30 ans: 42%',
'31-50 ans: 38%',
],
onTap: () => _showMemberDetails(context, 'age'),
),
// Répartition genre
MembersKPICardWidget(
title: 'Répartition Genre',
value: '52/48',
subtitle: 'Femmes/Hommes (%)',
icon: Icons.people_outline,
color: const Color(0xFF9C27B0),
details: const [
'649 Femmes (52%)',
'598 Hommes (48%)',
],
onTap: () => _showMemberDetails(context, 'genre'),
),
],
),
],
);
}
/// Affiche les détails d'un KPI spécifique
static void _showMemberDetails(BuildContext context, String type) {
String title = '';
String content = '';
switch (type) {
case 'total':
title = 'Total des Membres';
content = 'Détails de tous les membres enregistrés dans le système.';
break;
case 'nouveaux':
title = 'Nouveaux Membres';
content = 'Liste des membres qui ont rejoint ce mois-ci.';
break;
case 'actifs':
title = 'Membres Actifs';
content = 'Membres ayant une activité récente sur la plateforme.';
break;
case 'retention':
title = 'Taux de Rétention';
content = 'Pourcentage de membres restés actifs sur 12 mois.';
break;
case 'age':
title = 'Répartition par Âge';
content = 'Distribution des membres par tranches d\'âge.';
break;
case 'genre':
title = 'Répartition par Genre';
content = 'Distribution des membres par genre.';
break;
}
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(title),
content: Text(content),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Fermer'),
),
ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
// TODO: Naviguer vers la vue détaillée
},
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white,
),
child: const Text('Voir détails'),
),
],
),
);
}
}

View File

@@ -0,0 +1,519 @@
import 'package:flutter/material.dart';
import 'dart:async';
import '../../../../../shared/theme/app_theme.dart';
/// Widget de notifications en temps réel pour les membres
class MembersNotificationsWidget extends StatefulWidget {
const MembersNotificationsWidget({super.key});
@override
State<MembersNotificationsWidget> createState() => _MembersNotificationsWidgetState();
}
class _MembersNotificationsWidgetState extends State<MembersNotificationsWidget>
with TickerProviderStateMixin {
late AnimationController _pulseController;
late AnimationController _slideController;
late Animation<double> _pulseAnimation;
late Animation<Offset> _slideAnimation;
Timer? _notificationTimer;
List<Map<String, dynamic>> _notifications = [];
bool _hasUnreadNotifications = false;
bool _isExpanded = false;
@override
void initState() {
super.initState();
_pulseController = AnimationController(
duration: const Duration(milliseconds: 1000),
vsync: this,
);
_slideController = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_pulseAnimation = Tween<double>(begin: 1.0, end: 1.2).animate(
CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut),
);
_slideAnimation = Tween<Offset>(
begin: const Offset(0, -1),
end: Offset.zero,
).animate(CurvedAnimation(parent: _slideController, curve: Curves.easeOut));
_startNotificationSimulation();
_loadInitialNotifications();
}
@override
void dispose() {
_notificationTimer?.cancel();
_pulseController.dispose();
_slideController.dispose();
super.dispose();
}
void _loadInitialNotifications() {
_notifications = [
{
'id': '1',
'type': 'new_member',
'title': 'Nouveau membre inscrit',
'message': 'Marie Kouassi a rejoint la communauté',
'timestamp': DateTime.now().subtract(const Duration(minutes: 5)),
'isRead': false,
'icon': Icons.person_add,
'color': AppTheme.successColor,
'priority': 'high',
},
{
'id': '2',
'type': 'payment',
'title': 'Cotisation reçue',
'message': 'Jean Baptiste a payé sa cotisation mensuelle',
'timestamp': DateTime.now().subtract(const Duration(minutes: 15)),
'isRead': false,
'icon': Icons.payment,
'color': AppTheme.primaryColor,
'priority': 'medium',
},
{
'id': '3',
'type': 'reminder',
'title': 'Rappel automatique',
'message': '12 membres ont des cotisations en retard',
'timestamp': DateTime.now().subtract(const Duration(hours: 1)),
'isRead': true,
'icon': Icons.notification_important,
'color': AppTheme.warningColor,
'priority': 'medium',
},
];
_updateNotificationState();
}
void _startNotificationSimulation() {
_notificationTimer = Timer.periodic(const Duration(seconds: 30), (timer) {
_addRandomNotification();
});
}
void _addRandomNotification() {
final notifications = [
{
'type': 'new_member',
'title': 'Nouveau membre inscrit',
'message': 'Un nouveau membre a rejoint la communauté',
'icon': Icons.person_add,
'color': AppTheme.successColor,
'priority': 'high',
},
{
'type': 'update',
'title': 'Profil mis à jour',
'message': 'Un membre a modifié ses informations',
'icon': Icons.edit,
'color': AppTheme.infoColor,
'priority': 'low',
},
{
'type': 'activity',
'title': 'Activité détectée',
'message': 'Connexion d\'un membre inactif',
'icon': Icons.trending_up,
'color': AppTheme.successColor,
'priority': 'medium',
},
];
final randomNotification = notifications[DateTime.now().millisecond % notifications.length];
final newNotification = {
'id': DateTime.now().millisecondsSinceEpoch.toString(),
'timestamp': DateTime.now(),
'isRead': false,
...randomNotification,
};
setState(() {
_notifications.insert(0, newNotification);
if (_notifications.length > 20) {
_notifications = _notifications.take(20).toList();
}
});
_updateNotificationState();
_showNotificationAnimation();
}
void _updateNotificationState() {
final hasUnread = _notifications.any((notification) => !notification['isRead']);
if (hasUnread != _hasUnreadNotifications) {
setState(() {
_hasUnreadNotifications = hasUnread;
});
if (hasUnread) {
_pulseController.repeat(reverse: true);
} else {
_pulseController.stop();
_pulseController.reset();
}
}
}
void _showNotificationAnimation() {
_slideController.forward().then((_) {
Timer(const Duration(seconds: 3), () {
if (mounted) {
_slideController.reverse();
}
});
});
}
void _toggleExpanded() {
setState(() {
_isExpanded = !_isExpanded;
});
}
void _markAsRead(String notificationId) {
setState(() {
final index = _notifications.indexWhere((n) => n['id'] == notificationId);
if (index != -1) {
_notifications[index]['isRead'] = true;
}
});
_updateNotificationState();
}
void _markAllAsRead() {
setState(() {
for (var notification in _notifications) {
notification['isRead'] = true;
}
});
_updateNotificationState();
}
void _clearNotifications() {
setState(() {
_notifications.clear();
});
_updateNotificationState();
}
String _formatTimestamp(DateTime timestamp) {
final now = DateTime.now();
final difference = now.difference(timestamp);
if (difference.inMinutes < 1) {
return 'À l\'instant';
} else if (difference.inMinutes < 60) {
return 'Il y a ${difference.inMinutes}min';
} else if (difference.inHours < 24) {
return 'Il y a ${difference.inHours}h';
} else {
return 'Il y a ${difference.inDays}j';
}
}
@override
Widget build(BuildContext context) {
return Column(
children: [
// Notification flottante
if (_slideController.isAnimating || _slideController.isCompleted)
SlideTransition(
position: _slideAnimation,
child: _buildFloatingNotification(),
),
// Widget principal des notifications
Container(
margin: const EdgeInsets.only(bottom: 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(
children: [
// En-tête
InkWell(
onTap: _toggleExpanded,
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
child: Container(
padding: const EdgeInsets.all(16),
child: Row(
children: [
AnimatedBuilder(
animation: _pulseAnimation,
builder: (context, child) {
return Transform.scale(
scale: _hasUnreadNotifications ? _pulseAnimation.value : 1.0,
child: Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: _hasUnreadNotifications
? AppTheme.errorColor.withOpacity(0.1)
: AppTheme.primaryColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(6),
),
child: Icon(
Icons.notifications,
color: _hasUnreadNotifications
? AppTheme.errorColor
: AppTheme.primaryColor,
size: 16,
),
),
);
},
),
const SizedBox(width: 12),
const Expanded(
child: Text(
'Notifications',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
),
if (_notifications.where((n) => !n['isRead']).isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: AppTheme.errorColor,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'${_notifications.where((n) => !n['isRead']).length}',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 8),
AnimatedRotation(
turns: _isExpanded ? 0.5 : 0.0,
duration: const Duration(milliseconds: 300),
child: const Icon(
Icons.keyboard_arrow_down,
color: AppTheme.textSecondary,
),
),
],
),
),
),
// Liste des notifications
AnimatedContainer(
duration: const Duration(milliseconds: 300),
height: _isExpanded ? null : 0,
child: _isExpanded ? _buildNotificationsList() : const SizedBox.shrink(),
),
],
),
),
],
);
}
Widget _buildFloatingNotification() {
if (_notifications.isEmpty) return const SizedBox.shrink();
final notification = _notifications.first;
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: notification['color'].withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: notification['color'].withOpacity(0.3)),
),
child: Row(
children: [
Icon(
notification['icon'],
color: notification['color'],
size: 20,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
notification['title'],
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
Text(
notification['message'],
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
],
),
),
],
),
);
}
Widget _buildNotificationsList() {
return Container(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Column(
children: [
const Divider(height: 1),
const SizedBox(height: 12),
// Actions
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _markAllAsRead,
icon: const Icon(Icons.done_all, size: 16),
label: const Text('Tout marquer lu'),
style: OutlinedButton.styleFrom(
foregroundColor: AppTheme.textSecondary,
side: BorderSide(color: AppTheme.textSecondary.withOpacity(0.3)),
),
),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton.icon(
onPressed: _clearNotifications,
icon: const Icon(Icons.clear_all, size: 16),
label: const Text('Effacer tout'),
style: OutlinedButton.styleFrom(
foregroundColor: AppTheme.errorColor,
side: BorderSide(color: AppTheme.errorColor.withOpacity(0.3)),
),
),
),
],
),
const SizedBox(height: 16),
// Liste des notifications
...(_notifications.take(5).map((notification) => _buildNotificationItem(notification))),
if (_notifications.length > 5)
TextButton(
onPressed: () {
// TODO: Naviguer vers la page complète des notifications
},
child: Text(
'Voir toutes les notifications (${_notifications.length})',
style: const TextStyle(
fontSize: 12,
color: AppTheme.primaryColor,
),
),
),
],
),
);
}
Widget _buildNotificationItem(Map<String, dynamic> notification) {
return InkWell(
onTap: () => _markAsRead(notification['id']),
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: notification['color'].withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
notification['icon'],
color: notification['color'],
size: 16,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
notification['title'],
style: TextStyle(
fontSize: 14,
fontWeight: notification['isRead'] ? FontWeight.w500 : FontWeight.w600,
color: notification['isRead'] ? AppTheme.textSecondary : AppTheme.textPrimary,
),
),
),
if (!notification['isRead'])
Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: AppTheme.errorColor,
shape: BoxShape.circle,
),
),
],
),
const SizedBox(height: 2),
Text(
notification['message'],
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
_formatTimestamp(notification['timestamp']),
style: const TextStyle(
fontSize: 10,
color: AppTheme.textHint,
),
),
],
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,182 @@
import 'package:flutter/material.dart';
import '../../../../../shared/theme/app_theme.dart';
import '../../../../../shared/widgets/coming_soon_page.dart';
import '../../pages/membre_create_page.dart';
import 'members_action_card_widget.dart';
/// Widget de section d'actions rapides pour les membres
class MembersQuickActionsWidget extends StatelessWidget {
const MembersQuickActionsWidget({super.key});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Titre de section
const Row(
children: [
Icon(
Icons.flash_on,
color: AppTheme.primaryColor,
size: 20,
),
SizedBox(width: 8),
Text(
'Actions Rapides',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
],
),
const SizedBox(height: 16),
// Grille d'actions
GridView.count(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
crossAxisCount: 3,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
childAspectRatio: 1.0,
children: [
// Ajouter membre
MembersActionCardWidget(
title: 'Nouveau Membre',
subtitle: 'Inscription',
icon: Icons.person_add,
color: AppTheme.successColor,
onTap: () => _handleAction(context, 'add_member'),
),
// Rechercher membre
MembersActionCardWidget(
title: 'Rechercher',
subtitle: 'Trouver membre',
icon: Icons.search,
color: AppTheme.infoColor,
onTap: () => _handleAction(context, 'search_member'),
),
// Import/Export
MembersActionCardWidget(
title: 'Import/Export',
subtitle: 'Données',
icon: Icons.import_export,
color: AppTheme.warningColor,
onTap: () => _handleAction(context, 'import_export'),
),
// Envoyer message
MembersActionCardWidget(
title: 'Message Groupe',
subtitle: 'Communication',
icon: Icons.message,
color: AppTheme.primaryColor,
onTap: () => _handleAction(context, 'group_message'),
badge: '12',
),
// Statistiques
MembersActionCardWidget(
title: 'Statistiques',
subtitle: 'Analyses',
icon: Icons.bar_chart,
color: const Color(0xFF9C27B0),
onTap: () => _handleAction(context, 'statistics'),
),
// Rapports
MembersActionCardWidget(
title: 'Rapports',
subtitle: 'Documents',
icon: Icons.description,
color: AppTheme.errorColor,
onTap: () => _handleAction(context, 'reports'),
),
// Paramètres
MembersActionCardWidget(
title: 'Paramètres',
subtitle: 'Configuration',
icon: Icons.settings,
color: const Color(0xFF607D8B),
onTap: () => _handleAction(context, 'settings'),
),
// Sauvegarde
MembersActionCardWidget(
title: 'Sauvegarde',
subtitle: 'Backup',
icon: Icons.backup,
color: const Color(0xFF795548),
onTap: () => _handleAction(context, 'backup'),
),
// Support
MembersActionCardWidget(
title: 'Support',
subtitle: 'Aide',
icon: Icons.help_outline,
color: const Color(0xFF009688),
onTap: () => _handleAction(context, 'support'),
),
],
),
],
);
}
/// Gère les actions des cartes
static void _handleAction(BuildContext context, String action) {
switch (action) {
case 'add_member':
// Navigation vers la page de création de membre
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const MembreCreatePage(),
),
);
break;
case 'search_member':
_showComingSoon(context, 'Rechercher Membre', 'Recherche avancée dans la base de membres.', Icons.search, AppTheme.infoColor);
break;
case 'import_export':
_showComingSoon(context, 'Import/Export', 'Importer ou exporter les données des membres.', Icons.import_export, AppTheme.warningColor);
break;
case 'group_message':
_showComingSoon(context, 'Message Groupe', 'Envoyer un message à tous les membres ou à un groupe.', Icons.message, AppTheme.primaryColor);
break;
case 'statistics':
_showComingSoon(context, 'Statistiques', 'Analyses détaillées des données membres.', Icons.bar_chart, const Color(0xFF9C27B0));
break;
case 'reports':
_showComingSoon(context, 'Rapports', 'Génération de rapports personnalisés.', Icons.description, AppTheme.errorColor);
break;
case 'settings':
_showComingSoon(context, 'Paramètres', 'Configuration du module membres.', Icons.settings, const Color(0xFF607D8B));
break;
case 'backup':
_showComingSoon(context, 'Sauvegarde', 'Sauvegarde automatique des données.', Icons.backup, const Color(0xFF795548));
break;
case 'support':
_showComingSoon(context, 'Support', 'Aide et documentation du module.', Icons.help_outline, const Color(0xFF009688));
break;
}
}
static void _showComingSoon(BuildContext context, String title, String description, IconData icon, Color color) {
showDialog(
context: context,
builder: (context) => ComingSoonPage(
title: title,
description: description,
icon: icon,
color: color,
),
);
}
}

View File

@@ -0,0 +1,339 @@
import 'package:flutter/material.dart';
import '../../../../../shared/theme/app_theme.dart';
import 'members_activity_item_widget.dart';
/// Widget de section d'activités récentes pour les membres
class MembersRecentActivitiesWidget extends StatelessWidget {
const MembersRecentActivitiesWidget({super.key});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Titre de section avec bouton "Voir tout"
Row(
children: [
const Icon(
Icons.history,
color: AppTheme.primaryColor,
size: 20,
),
const SizedBox(width: 8),
const Expanded(
child: Text(
'Activités Récentes',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
),
TextButton(
onPressed: () => _showAllActivities(context),
child: const Text(
'Voir tout',
style: TextStyle(
fontSize: 12,
color: AppTheme.primaryColor,
),
),
),
],
),
const SizedBox(height: 16),
// Container des activités
Container(
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(
children: [
// Nouvelle inscription
MembersActivityItemWidget(
title: 'Nouvelle inscription',
description: 'Un nouveau membre a rejoint la communauté',
time: 'Il y a 2h',
icon: Icons.person_add,
color: AppTheme.successColor,
memberName: 'Marie Kouassi',
onTap: () => _showActivityDetails(context, 'inscription'),
),
// Mise à jour profil
MembersActivityItemWidget(
title: 'Profil mis à jour',
description: 'Informations personnelles modifiées',
time: 'Il y a 4h',
icon: Icons.edit,
color: AppTheme.infoColor,
memberName: 'Jean Baptiste',
onTap: () => _showActivityDetails(context, 'profil'),
),
// Cotisation payée
MembersActivityItemWidget(
title: 'Cotisation payée',
description: 'Paiement de cotisation mensuelle reçu',
time: 'Il y a 6h',
icon: Icons.payment,
color: AppTheme.primaryColor,
memberName: 'Fatou Traoré',
onTap: () => _showActivityDetails(context, 'cotisation'),
),
// Message envoyé
MembersActivityItemWidget(
title: 'Message de groupe',
description: 'Notification envoyée à tous les membres',
time: 'Il y a 8h',
icon: Icons.message,
color: AppTheme.warningColor,
onTap: () => _showActivityDetails(context, 'message'),
),
// Export de données
MembersActivityItemWidget(
title: 'Export de données',
description: 'Liste des membres exportée en Excel',
time: 'Il y a 1j',
icon: Icons.file_download,
color: const Color(0xFF9C27B0),
onTap: () => _showActivityDetails(context, 'export'),
),
// Sauvegarde automatique
MembersActivityItemWidget(
title: 'Sauvegarde automatique',
description: 'Données sauvegardées avec succès',
time: 'Il y a 1j',
icon: Icons.backup,
color: const Color(0xFF607D8B),
onTap: () => _showActivityDetails(context, 'sauvegarde'),
),
],
),
),
],
);
}
/// Affiche toutes les activités
static void _showAllActivities(BuildContext context) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => DraggableScrollableSheet(
initialChildSize: 0.9,
minChildSize: 0.5,
maxChildSize: 0.95,
builder: (context, scrollController) => Container(
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
children: [
// Handle
Container(
margin: const EdgeInsets.only(top: 8),
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(2),
),
),
// En-tête
const Padding(
padding: EdgeInsets.all(16),
child: Row(
children: [
Icon(
Icons.history,
color: AppTheme.primaryColor,
size: 24,
),
SizedBox(width: 12),
Text(
'Toutes les Activités',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
],
),
),
// Liste complète
Expanded(
child: ListView.builder(
controller: scrollController,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: 20, // Exemple avec plus d'activités
itemBuilder: (context, index) {
return MembersActivityItemWidget(
title: 'Activité ${index + 1}',
description: 'Description de l\'activité numéro ${index + 1}',
time: 'Il y a ${index + 1}h',
icon: _getActivityIcon(index),
color: _getActivityColor(index),
memberName: 'Membre ${index + 1}',
onTap: () => _showActivityDetails(context, 'activite_$index'),
);
},
),
),
],
),
),
),
);
}
/// Affiche les détails d'une activité
static void _showActivityDetails(BuildContext context, String activityType) {
String title = '';
String description = '';
IconData icon = Icons.info;
Color color = AppTheme.primaryColor;
switch (activityType) {
case 'inscription':
title = 'Nouvelle Inscription';
description = 'Marie Kouassi a rejoint la communauté avec le numéro UF-2024-00001247.';
icon = Icons.person_add;
color = AppTheme.successColor;
break;
case 'profil':
title = 'Mise à Jour Profil';
description = 'Jean Baptiste a modifié ses informations de contact et son adresse.';
icon = Icons.edit;
color = AppTheme.infoColor;
break;
case 'cotisation':
title = 'Cotisation Payée';
description = 'Fatou Traoré a payé sa cotisation mensuelle de 25,000 FCFA.';
icon = Icons.payment;
color = AppTheme.primaryColor;
break;
case 'message':
title = 'Message de Groupe';
description = 'Notification envoyée à 1,247 membres concernant la prochaine assemblée générale.';
icon = Icons.message;
color = AppTheme.warningColor;
break;
case 'export':
title = 'Export de Données';
description = 'Liste complète des membres exportée au format Excel (1,247 entrées).';
icon = Icons.file_download;
color = const Color(0xFF9C27B0);
break;
case 'sauvegarde':
title = 'Sauvegarde Automatique';
description = 'Sauvegarde quotidienne effectuée avec succès. Toutes les données sont sécurisées.';
icon = Icons.backup;
color = const Color(0xFF607D8B);
break;
default:
title = 'Activité';
description = 'Détails de l\'activité sélectionnée.';
}
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
icon,
color: color,
size: 20,
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
title,
style: const TextStyle(fontSize: 18),
),
),
],
),
content: Text(description),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Fermer'),
),
ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
// TODO: Action spécifique selon le type
},
style: ElevatedButton.styleFrom(
backgroundColor: color,
foregroundColor: Colors.white,
),
child: const Text('Voir plus'),
),
],
),
);
}
/// Retourne une icône selon l'index
static IconData _getActivityIcon(int index) {
final icons = [
Icons.person_add,
Icons.edit,
Icons.payment,
Icons.message,
Icons.file_download,
Icons.backup,
Icons.notifications,
Icons.security,
Icons.update,
Icons.sync,
];
return icons[index % icons.length];
}
/// Retourne une couleur selon l'index
static Color _getActivityColor(int index) {
final colors = [
AppTheme.successColor,
AppTheme.infoColor,
AppTheme.primaryColor,
AppTheme.warningColor,
const Color(0xFF9C27B0),
const Color(0xFF607D8B),
AppTheme.errorColor,
const Color(0xFF009688),
const Color(0xFF795548),
const Color(0xFFFF5722),
];
return colors[index % colors.length];
}
}

View File

@@ -0,0 +1,396 @@
import 'package:flutter/material.dart';
import 'dart:async';
import '../../../../../shared/theme/app_theme.dart';
/// Widget de recherche intelligente pour les membres
class MembersSmartSearchWidget extends StatefulWidget {
final Function(String) onSearch;
final Function(Map<String, dynamic>) onSuggestionSelected;
final List<Map<String, dynamic>> recentSearches;
const MembersSmartSearchWidget({
super.key,
required this.onSearch,
required this.onSuggestionSelected,
this.recentSearches = const [],
});
@override
State<MembersSmartSearchWidget> createState() => _MembersSmartSearchWidgetState();
}
class _MembersSmartSearchWidgetState extends State<MembersSmartSearchWidget>
with TickerProviderStateMixin {
final TextEditingController _searchController = TextEditingController();
final FocusNode _focusNode = FocusNode();
Timer? _debounceTimer;
late AnimationController _animationController;
late Animation<double> _scaleAnimation;
bool _isSearching = false;
bool _showSuggestions = false;
List<Map<String, dynamic>> _suggestions = [];
List<Map<String, dynamic>> _searchHistory = [];
// Suggestions prédéfinies
final List<Map<String, dynamic>> _predefinedSuggestions = [
{
'type': 'quick_filter',
'title': 'Nouveaux membres',
'subtitle': 'Inscrits ce mois',
'icon': Icons.person_add,
'color': AppTheme.successColor,
'filter': {'timeRange': '30 jours', 'status': 'Actif'},
},
{
'type': 'quick_filter',
'title': 'Membres inactifs',
'subtitle': 'Sans activité récente',
'icon': Icons.person_off,
'color': AppTheme.warningColor,
'filter': {'status': 'Inactif'},
},
{
'type': 'quick_filter',
'title': 'Bureau exécutif',
'subtitle': 'Responsables',
'icon': Icons.admin_panel_settings,
'color': AppTheme.primaryColor,
'filter': {'role': 'Bureau'},
},
{
'type': 'quick_filter',
'title': 'Jeunes membres',
'subtitle': '18-30 ans',
'icon': Icons.people,
'color': AppTheme.infoColor,
'filter': {'ageRange': '18-30'},
},
];
@override
void initState() {
super.initState();
_searchHistory = List.from(widget.recentSearches);
_animationController = AnimationController(
duration: const Duration(milliseconds: 200),
vsync: this,
);
_scaleAnimation = Tween<double>(begin: 0.95, end: 1.0).animate(
CurvedAnimation(parent: _animationController, curve: Curves.easeOut),
);
_focusNode.addListener(_onFocusChanged);
_searchController.addListener(_onSearchChanged);
}
@override
void dispose() {
_debounceTimer?.cancel();
_animationController.dispose();
_focusNode.dispose();
_searchController.dispose();
super.dispose();
}
void _onFocusChanged() {
setState(() {
_showSuggestions = _focusNode.hasFocus;
if (_showSuggestions) {
_animationController.forward();
_updateSuggestions();
} else {
_animationController.reverse();
}
});
}
void _onSearchChanged() {
final query = _searchController.text;
if (_debounceTimer?.isActive ?? false) _debounceTimer!.cancel();
_debounceTimer = Timer(const Duration(milliseconds: 300), () {
if (query.isNotEmpty) {
widget.onSearch(query);
_addToSearchHistory(query);
}
_updateSuggestions();
});
}
void _updateSuggestions() {
final query = _searchController.text.toLowerCase();
List<Map<String, dynamic>> suggestions = [];
if (query.isEmpty) {
// Afficher les suggestions rapides et l'historique
suggestions.addAll(_predefinedSuggestions);
if (_searchHistory.isNotEmpty) {
suggestions.add({
'type': 'divider',
'title': 'Recherches récentes',
});
suggestions.addAll(_searchHistory.take(3));
}
} else {
// Filtrer les suggestions basées sur la requête
suggestions.addAll(_predefinedSuggestions.where((suggestion) =>
suggestion['title'].toString().toLowerCase().contains(query) ||
suggestion['subtitle'].toString().toLowerCase().contains(query)));
// Ajouter des suggestions de membres simulées
suggestions.addAll(_generateMemberSuggestions(query));
}
setState(() {
_suggestions = suggestions;
});
}
List<Map<String, dynamic>> _generateMemberSuggestions(String query) {
// Simulation de suggestions de membres basées sur la requête
final memberSuggestions = <Map<String, dynamic>>[];
if (query.length >= 2) {
memberSuggestions.addAll([
{
'type': 'member',
'title': 'Jean-Baptiste Kouassi',
'subtitle': 'MBR001 • Actif',
'icon': Icons.person,
'color': AppTheme.primaryColor,
'memberId': 'c6ccf741-c55f-390e-96a7-531819fed1dd',
},
{
'type': 'member',
'title': 'Aminata Traoré',
'subtitle': 'MBR002 • Actif',
'icon': Icons.person,
'color': AppTheme.successColor,
'memberId': '9f4ea9cb-798b-3b1c-8444-4b313af999bd',
},
].where((member) =>
member['title'].toString().toLowerCase().contains(query)).toList());
}
return memberSuggestions;
}
void _addToSearchHistory(String query) {
final historyItem = {
'type': 'history',
'title': query,
'subtitle': 'Recherche récente',
'icon': Icons.history,
'color': AppTheme.textSecondary,
'timestamp': DateTime.now(),
};
setState(() {
_searchHistory.removeWhere((item) => item['title'] == query);
_searchHistory.insert(0, historyItem);
if (_searchHistory.length > 10) {
_searchHistory = _searchHistory.take(10).toList();
}
});
}
void _onSuggestionTap(Map<String, dynamic> suggestion) {
switch (suggestion['type']) {
case 'quick_filter':
widget.onSuggestionSelected(suggestion);
_searchController.text = suggestion['title'];
break;
case 'member':
widget.onSuggestionSelected(suggestion);
_searchController.text = suggestion['title'];
break;
case 'history':
_searchController.text = suggestion['title'];
widget.onSearch(suggestion['title']);
break;
}
_focusNode.unfocus();
}
void _clearSearch() {
_searchController.clear();
widget.onSearch('');
_focusNode.unfocus();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
// Barre de recherche
Container(
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: TextField(
controller: _searchController,
focusNode: _focusNode,
decoration: InputDecoration(
hintText: 'Rechercher un membre, rôle, statut...',
hintStyle: const TextStyle(
color: AppTheme.textHint,
fontSize: 14,
),
prefixIcon: const Icon(
Icons.search,
color: AppTheme.primaryColor,
size: 20,
),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(
Icons.clear,
color: AppTheme.textSecondary,
size: 20,
),
onPressed: _clearSearch,
)
: const Icon(
Icons.mic,
color: AppTheme.textHint,
size: 20,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
filled: true,
fillColor: Colors.grey[50],
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
style: const TextStyle(
fontSize: 14,
color: AppTheme.textPrimary,
),
),
),
// Suggestions
if (_showSuggestions && _suggestions.isNotEmpty)
ScaleTransition(
scale: _scaleAnimation,
child: Container(
margin: const EdgeInsets.only(top: 8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 15,
offset: const Offset(0, 4),
),
],
),
child: Column(
children: _suggestions.map((suggestion) {
if (suggestion['type'] == 'divider') {
return _buildDivider(suggestion['title']);
}
return _buildSuggestionItem(suggestion);
}).toList(),
),
),
),
],
);
}
Widget _buildDivider(String title) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
Text(
title,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppTheme.textSecondary,
),
),
const SizedBox(width: 8),
Expanded(
child: Container(
height: 1,
color: Colors.grey[200],
),
),
],
),
);
}
Widget _buildSuggestionItem(Map<String, dynamic> suggestion) {
return InkWell(
onTap: () => _onSuggestionTap(suggestion),
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: suggestion['color'].withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
suggestion['icon'],
color: suggestion['color'],
size: 16,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
suggestion['title'],
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppTheme.textPrimary,
),
),
if (suggestion['subtitle'] != null)
Text(
suggestion['subtitle'],
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
],
),
),
const Icon(
Icons.north_west,
color: AppTheme.textHint,
size: 16,
),
],
),
),
);
}
}

View File

@@ -0,0 +1,380 @@
import 'package:flutter/material.dart';
import '../../../../../core/models/membre_model.dart';
import '../../../../../shared/theme/app_theme.dart';
/// Widget de statistiques avancées pour les membres
class MembersStatsWidget extends StatelessWidget {
final List<MembreModel> members;
final String searchQuery;
final Map<String, dynamic> filters;
const MembersStatsWidget({
super.key,
required this.members,
this.searchQuery = '',
this.filters = const {},
});
@override
Widget build(BuildContext context) {
final stats = _calculateStats();
return Container(
margin: const EdgeInsets.only(bottom: 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(
'Statistiques des membres',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const Spacer(),
if (searchQuery.isNotEmpty || filters.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,
),
),
],
),
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,
),
),
],
),
if (stats['repartitionAge'].isNotEmpty) ...[
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 12),
// Répartition par âge
const Text(
'Répartition par âge',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 8),
_buildAgeDistribution(stats['repartitionAge']),
],
],
),
);
}
Map<String, dynamic> _calculateStats() {
if (members.isEmpty) {
return {
'total': 0,
'actifs': 0,
'ageMoyen': 0,
'nouveaux': 0,
'nouveauxPourcentage': 0.0,
'anciens': 0,
'anciensPourcentage': 0.0,
'repartitionAge': <String, int>{},
};
}
final now = DateTime.now();
final total = members.length;
final actifs = members.where((m) => m.statut.toUpperCase() == 'ACTIF').length;
// Calcul de l'âge moyen
final ages = members.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 = members.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 = members.where((m) {
final daysDiff = now.difference(m.dateAdhesion).inDays;
return daysDiff > 365;
}).length;
final anciensPourcentage = total > 0 ? (anciens / total * 100) : 0.0;
// Répartition par tranche d'âge
final repartitionAge = <String, int>{};
for (final member in members) {
final age = member.age;
String tranche;
if (age < 25) {
tranche = '18-24';
} else if (age < 35) {
tranche = '25-34';
} else if (age < 45) {
tranche = '35-44';
} else if (age < 55) {
tranche = '45-54';
} else {
tranche = '55+';
}
repartitionAge[tranche] = (repartitionAge[tranche] ?? 0) + 1;
}
return {
'total': total,
'actifs': actifs,
'ageMoyen': ageMoyen,
'nouveaux': nouveaux,
'nouveauxPourcentage': nouveauxPourcentage,
'anciens': anciens,
'anciensPourcentage': anciensPourcentage,
'repartitionAge': repartitionAge,
};
}
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: 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,
),
),
],
),
],
),
);
}
Widget _buildAgeDistribution(Map<String, int> repartition) {
final total = repartition.values.fold(0, (sum, count) => sum + count);
if (total == 0) return const SizedBox.shrink();
return Column(
children: repartition.entries.map((entry) {
final percentage = (entry.value / total * 100);
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
SizedBox(
width: 50,
child: Text(
entry.key,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
),
const SizedBox(width: 8),
Expanded(
child: Container(
height: 6,
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: BorderRadius.circular(3),
),
child: FractionallySizedBox(
alignment: Alignment.centerLeft,
widthFactor: percentage / 100,
child: Container(
decoration: BoxDecoration(
color: AppTheme.primaryColor,
borderRadius: BorderRadius.circular(3),
),
),
),
),
),
const SizedBox(width: 8),
SizedBox(
width: 40,
child: Text(
'${entry.value}',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppTheme.textPrimary,
),
textAlign: TextAlign.right,
),
),
],
),
);
}).toList(),
);
}
}

View File

@@ -0,0 +1,109 @@
import 'package:flutter/material.dart';
import '../../../../../shared/theme/app_theme.dart';
/// Widget de section d'accueil pour le dashboard des membres
class MembersWelcomeSectionWidget extends StatelessWidget {
const MembersWelcomeSectionWidget({super.key});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
AppTheme.primaryColor,
AppTheme.primaryColor.withOpacity(0.8),
],
),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: AppTheme.primaryColor.withOpacity(0.3),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.people,
color: Colors.white,
size: 28,
),
),
const SizedBox(width: 16),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Gestion des Membres',
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
SizedBox(height: 4),
Text(
'Tableau de bord complet',
style: TextStyle(
fontSize: 14,
color: Colors.white70,
),
),
],
),
),
],
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Colors.white.withOpacity(0.2),
width: 1,
),
),
child: const Row(
children: [
Icon(
Icons.info_outline,
color: Colors.white70,
size: 16,
),
SizedBox(width: 8),
Expanded(
child: Text(
'Suivez l\'évolution de votre communauté en temps réel',
style: TextStyle(
fontSize: 12,
color: Colors.white70,
),
),
),
],
),
),
],
),
);
}
}