fix(mobile): URL changement mdp corrigée + v3.0 — multi-org, AppAuth, sécurité prod

Auth:
- profile_repository.dart: /api/auth/change-password → /api/membres/auth/change-password

Multi-org (Phase 3):
- OrgSelectorPage, OrgSwitcherBloc, OrgSwitcherEntry
- org_context_service.dart: headers X-Active-Organisation-Id + X-Active-Role

Navigation:
- MorePage: navigation conditionnelle par typeOrganisation
- Suppression adaptive_navigation (remplacé par main_navigation_layout)

Auth AppAuth:
- keycloak_webview_auth_service: fixes AppAuth Android
- AuthBloc: gestion REAUTH_REQUIS + premierLoginComplet

Onboarding:
- Nouveaux états: payment_method_page, onboarding_shared_widgets
- SouscriptionStatusModel mis à jour StatutValidationSouscription

Android:
- build.gradle: ProGuard/R8, network_security_config
- Gradle wrapper mis à jour
This commit is contained in:
dahoud
2026-04-07 20:56:03 +00:00
parent 22f9c7e9a1
commit 70cbd1c873
63 changed files with 9316 additions and 6122 deletions

View File

@@ -10,4 +10,4 @@ final GetIt getIt = GetIt.instance;
preferRelativeImports: true, // default
asExtension: true, // default
)
void configureDependencies() => getIt.init();
Future<void> configureDependencies() => getIt.init();

View File

@@ -10,7 +10,7 @@ final GetIt sl = getIt;
/// Initialise toutes les dépendances de l'application
Future<void> initializeDependencies() async {
configureDependencies();
await configureDependencies();
}
/// Nettoie toutes les dépendances (optionnel, pour les tests)

View File

@@ -1,563 +0,0 @@
/// Système de navigation adaptatif basé sur les rôles
/// Navigation qui s'adapte selon les permissions et rôles utilisateurs
library adaptive_navigation;
import 'package:flutter/material.dart';
import '../../shared/design_system/tokens/app_colors.dart';
import '../../shared/design_system/tokens/color_tokens.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../features/authentication/presentation/bloc/auth_bloc.dart';
import '../../features/authentication/data/models/user_role.dart';
import '../../features/authentication/data/models/permission_matrix.dart';
import '../../shared/widgets/adaptive_widget.dart';
/// Élément de navigation adaptatif
class AdaptiveNavigationItem {
/// Icône de l'élément
final IconData icon;
/// Icône sélectionnée (optionnelle)
final IconData? selectedIcon;
/// Libellé de l'élément
final String label;
/// Route de destination
final String route;
/// Permissions requises pour afficher cet élément
final List<String> requiredPermissions;
/// Rôles minimum requis
final UserRole? minimumRole;
/// Badge de notification (optionnel)
final String? badge;
/// Couleur personnalisée (optionnelle)
final Color? color;
const AdaptiveNavigationItem({
required this.icon,
this.selectedIcon,
required this.label,
required this.route,
this.requiredPermissions = const [],
this.minimumRole,
this.badge,
this.color,
});
}
/// Drawer de navigation adaptatif
class AdaptiveNavigationDrawer extends StatelessWidget {
/// Callback de navigation
final Function(String route) onNavigate;
/// Callback de déconnexion
final VoidCallback onLogout;
/// Éléments de navigation personnalisés
final List<AdaptiveNavigationItem>? customItems;
const AdaptiveNavigationDrawer({
super.key,
required this.onNavigate,
required this.onLogout,
this.customItems,
});
@override
Widget build(BuildContext context) {
return AdaptiveWidget(
roleWidgets: {
UserRole.superAdmin: () => _buildSuperAdminDrawer(context),
UserRole.orgAdmin: () => _buildOrgAdminDrawer(context),
UserRole.moderator: () => _buildModeratorDrawer(context),
UserRole.activeMember: () => _buildActiveMemberDrawer(context),
UserRole.simpleMember: () => _buildSimpleMemberDrawer(context),
UserRole.visitor: () => _buildVisitorDrawer(context),
},
fallbackWidget: _buildBasicDrawer(context),
loadingWidget: _buildLoadingDrawer(context),
);
}
/// Drawer pour Super Admin
Widget _buildSuperAdminDrawer(BuildContext context) {
final items = [
const AdaptiveNavigationItem(
icon: Icons.dashboard,
label: 'Command Center',
route: '/dashboard',
requiredPermissions: [PermissionMatrix.SYSTEM_ADMIN],
),
const AdaptiveNavigationItem(
icon: Icons.business,
label: 'Organisations',
route: '/organizations',
requiredPermissions: [PermissionMatrix.ORG_CREATE],
),
const AdaptiveNavigationItem(
icon: Icons.people,
label: 'Utilisateurs Globaux',
route: '/global-users',
requiredPermissions: [PermissionMatrix.MEMBERS_VIEW_ALL],
),
const AdaptiveNavigationItem(
icon: Icons.settings,
label: 'Administration',
route: '/system-admin',
requiredPermissions: [PermissionMatrix.SYSTEM_CONFIG],
),
const AdaptiveNavigationItem(
icon: Icons.analytics,
label: 'Analytics',
route: '/analytics',
requiredPermissions: [PermissionMatrix.DASHBOARD_ANALYTICS],
),
const AdaptiveNavigationItem(
icon: Icons.security,
label: 'Sécurité',
route: '/security',
requiredPermissions: [PermissionMatrix.SYSTEM_SECURITY],
),
];
return _buildDrawer(
context,
'Super Administrateur',
AppColors.brandGreen,
Icons.admin_panel_settings,
items,
);
}
/// Drawer pour Org Admin
Widget _buildOrgAdminDrawer(BuildContext context) {
final items = [
const AdaptiveNavigationItem(
icon: Icons.dashboard,
label: 'Control Panel',
route: '/dashboard',
requiredPermissions: [PermissionMatrix.DASHBOARD_VIEW],
),
const AdaptiveNavigationItem(
icon: Icons.people,
label: 'Membres',
route: '/members',
requiredPermissions: [PermissionMatrix.MEMBERS_VIEW_ALL],
),
const AdaptiveNavigationItem(
icon: Icons.account_balance_wallet,
label: 'Finances',
route: '/finances',
requiredPermissions: [PermissionMatrix.FINANCES_VIEW_ALL],
),
const AdaptiveNavigationItem(
icon: Icons.event,
label: 'Événements',
route: '/events',
requiredPermissions: [PermissionMatrix.EVENTS_VIEW_ALL],
),
const AdaptiveNavigationItem(
icon: Icons.volunteer_activism,
label: 'Solidarité',
route: '/solidarity',
requiredPermissions: [PermissionMatrix.SOLIDARITY_VIEW_ALL],
),
const AdaptiveNavigationItem(
icon: Icons.assessment,
label: 'Rapports',
route: '/reports',
requiredPermissions: [PermissionMatrix.REPORTS_GENERATE],
),
const AdaptiveNavigationItem(
icon: Icons.settings,
label: 'Configuration',
route: '/org-settings',
requiredPermissions: [PermissionMatrix.ORG_CONFIG],
),
];
return _buildDrawer(
context,
'Administrateur',
AppColors.primaryGreen,
Icons.business_center,
items,
);
}
/// Drawer pour Modérateur
Widget _buildModeratorDrawer(BuildContext context) {
final items = [
const AdaptiveNavigationItem(
icon: Icons.dashboard,
label: 'Management Hub',
route: '/dashboard',
requiredPermissions: [PermissionMatrix.DASHBOARD_VIEW],
),
const AdaptiveNavigationItem(
icon: Icons.gavel,
label: 'Modération',
route: '/moderation',
requiredPermissions: [PermissionMatrix.MODERATION_CONTENT],
),
const AdaptiveNavigationItem(
icon: Icons.people,
label: 'Membres',
route: '/members',
requiredPermissions: [PermissionMatrix.MEMBERS_VIEW_ALL],
),
const AdaptiveNavigationItem(
icon: Icons.event,
label: 'Événements',
route: '/events',
requiredPermissions: [PermissionMatrix.EVENTS_VIEW_ALL],
),
const AdaptiveNavigationItem(
icon: Icons.message,
label: 'Communication',
route: '/communication',
requiredPermissions: [PermissionMatrix.COMM_MODERATE],
),
];
return _buildDrawer(
context,
'Modérateur',
ColorTokens.secondaryDark,
Icons.manage_accounts,
items,
);
}
/// Drawer pour Membre Actif
Widget _buildActiveMemberDrawer(BuildContext context) {
final items = [
const AdaptiveNavigationItem(
icon: Icons.dashboard,
label: 'Activity Center',
route: '/dashboard',
requiredPermissions: [PermissionMatrix.DASHBOARD_VIEW],
),
const AdaptiveNavigationItem(
icon: Icons.person,
label: 'Mon Profil',
route: '/profile',
requiredPermissions: [PermissionMatrix.MEMBERS_VIEW_OWN],
),
const AdaptiveNavigationItem(
icon: Icons.event,
label: 'Événements',
route: '/events',
requiredPermissions: [PermissionMatrix.EVENTS_VIEW_ALL],
),
const AdaptiveNavigationItem(
icon: Icons.volunteer_activism,
label: 'Solidarité',
route: '/solidarity',
requiredPermissions: [PermissionMatrix.SOLIDARITY_VIEW_ALL],
),
const AdaptiveNavigationItem(
icon: Icons.payment,
label: 'Mes Cotisations',
route: '/my-finances',
requiredPermissions: [PermissionMatrix.FINANCES_VIEW_OWN],
),
const AdaptiveNavigationItem(
icon: Icons.message,
label: 'Messages',
route: '/messages',
requiredPermissions: [PermissionMatrix.DASHBOARD_VIEW],
),
];
return _buildDrawer(
context,
'Membre Actif',
AppColors.brandGreenLight,
Icons.groups,
items,
);
}
/// Drawer pour Membre Simple
Widget _buildSimpleMemberDrawer(BuildContext context) {
final items = [
const AdaptiveNavigationItem(
icon: Icons.dashboard,
label: 'Mon Espace',
route: '/dashboard',
requiredPermissions: [PermissionMatrix.DASHBOARD_VIEW],
),
const AdaptiveNavigationItem(
icon: Icons.person,
label: 'Mon Profil',
route: '/profile',
requiredPermissions: [PermissionMatrix.MEMBERS_VIEW_OWN],
),
const AdaptiveNavigationItem(
icon: Icons.event,
label: 'Événements',
route: '/events',
requiredPermissions: [PermissionMatrix.EVENTS_VIEW_PUBLIC],
),
const AdaptiveNavigationItem(
icon: Icons.payment,
label: 'Mes Cotisations',
route: '/my-finances',
requiredPermissions: [PermissionMatrix.FINANCES_VIEW_OWN],
),
const AdaptiveNavigationItem(
icon: Icons.help,
label: 'Aide',
route: '/help',
requiredPermissions: [],
),
];
return _buildDrawer(
context,
'Membre',
ColorTokens.secondary,
Icons.person,
items,
);
}
/// Drawer pour Visiteur
Widget _buildVisitorDrawer(BuildContext context) {
final items = [
const AdaptiveNavigationItem(
icon: Icons.home,
label: 'Accueil',
route: '/dashboard',
requiredPermissions: [],
),
const AdaptiveNavigationItem(
icon: Icons.info,
label: 'À Propos',
route: '/about',
requiredPermissions: [],
),
const AdaptiveNavigationItem(
icon: Icons.event,
label: 'Événements Publics',
route: '/public-events',
requiredPermissions: [PermissionMatrix.EVENTS_VIEW_PUBLIC],
),
const AdaptiveNavigationItem(
icon: Icons.contact_mail,
label: 'Contact',
route: '/contact',
requiredPermissions: [],
),
const AdaptiveNavigationItem(
icon: Icons.login,
label: 'Se Connecter',
route: '/login',
requiredPermissions: [],
),
];
return _buildDrawer(
context,
'Visiteur',
AppColors.brandMint,
Icons.waving_hand,
items,
);
}
/// Drawer basique de fallback
Widget _buildBasicDrawer(BuildContext context) {
return _buildDrawer(
context,
'UnionFlow',
AppColors.textSecondaryLight,
Icons.dashboard,
[
const AdaptiveNavigationItem(
icon: Icons.home,
label: 'Accueil',
route: '/dashboard',
),
],
);
}
/// Drawer de chargement
Widget _buildLoadingDrawer(BuildContext context) {
return Drawer(
child: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [AppColors.brandGreen, AppColors.primaryGreen],
),
),
child: const Center(
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
),
),
);
}
/// Construit un drawer avec les éléments spécifiés
Widget _buildDrawer(
BuildContext context,
String title,
Color color,
IconData icon,
List<AdaptiveNavigationItem> items,
) {
return Drawer(
child: Column(
children: [
// En-tête du drawer
_buildDrawerHeader(context, title, color, icon),
// Éléments de navigation
Expanded(
child: ListView(
padding: EdgeInsets.zero,
children: [
...items.map((item) => _buildNavigationItem(context, item)),
const Divider(),
_buildLogoutItem(context),
],
),
),
],
),
);
}
/// Construit l'en-tête du drawer
Widget _buildDrawerHeader(
BuildContext context,
String title,
Color color,
IconData icon,
) {
return DrawerHeader(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [color, color.withOpacity(0.8)],
),
),
child: BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) {
if (state is AuthAuthenticated) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, color: Colors.white, size: 32),
const SizedBox(width: 12),
Text(
title,
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 16),
Text(
state.user.fullName,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
),
),
Text(
state.user.email,
style: TextStyle(
color: Colors.white.withOpacity(0.8),
fontSize: 14,
),
),
],
);
}
return Row(
children: [
Icon(icon, color: Colors.white, size: 32),
const SizedBox(width: 12),
Text(
title,
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
],
);
},
),
);
}
/// Construit un élément de navigation
Widget _buildNavigationItem(
BuildContext context,
AdaptiveNavigationItem item,
) {
return SecureWidget(
requiredPermissions: item.requiredPermissions,
child: ListTile(
leading: Icon(item.icon, color: item.color),
title: Text(item.label),
trailing: item.badge != null
? Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: AppColors.error,
borderRadius: BorderRadius.circular(12),
),
child: Text(
item.badge!,
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
)
: null,
onTap: () {
Navigator.of(context).pop();
onNavigate(item.route);
},
),
);
}
/// Construit l'élément de déconnexion
Widget _buildLogoutItem(BuildContext context) {
return ListTile(
leading: Icon(Icons.logout, color: AppColors.error),
title: const Text(
'Déconnexion',
style: TextStyle(color: AppColors.error),
),
onTap: () {
Navigator.of(context).pop();
onLogout();
},
);
}
}

View File

@@ -31,6 +31,10 @@ class _MainNavigationLayoutState extends State<MainNavigationLayout> {
String? _lastOrgId;
Widget _getDashboardForRole(UserRole role, String userId, String? orgId) {
// Compte sans rôle métier — afficher la page d'attente sans appel API
if (role == UserRole.visitor) {
return const VisitorDashboard();
}
if (role == UserRole.orgAdmin && (orgId == null || orgId.isEmpty)) {
return OrgAdminDashboardLoader(userId: userId);
}
@@ -83,8 +87,8 @@ class _MainNavigationLayoutState extends State<MainNavigationLayout> {
MembersPageWrapper(
organisationId: role == UserRole.orgAdmin ? orgId : null,
),
const EventsPageWrapper(),
const MorePage(),
if (role != UserRole.visitor) const EventsPageWrapper(),
if (role != UserRole.visitor) const MorePage(),
];
return _cachedPages!;
}
@@ -150,16 +154,18 @@ class _MainNavigationLayoutState extends State<MainNavigationLayout> {
activeIcon: Icons.people_rounded,
label: 'Membres',
),
const _NavItem(
icon: Icons.event_outlined,
activeIcon: Icons.event_rounded,
label: 'Événements',
),
const _NavItem(
icon: Icons.more_horiz_rounded,
activeIcon: Icons.more_horiz_rounded,
label: 'Plus',
),
if (role != UserRole.visitor) ...[
const _NavItem(
icon: Icons.event_outlined,
activeIcon: Icons.event_rounded,
label: 'Événements',
),
const _NavItem(
icon: Icons.more_horiz_rounded,
activeIcon: Icons.more_horiz_rounded,
label: 'Plus',
),
],
];
}
}

View File

@@ -5,6 +5,8 @@ import '../../features/authentication/data/models/user_role.dart';
import '../../shared/design_system/unionflow_design_system.dart';
import '../../shared/widgets/core_card.dart';
import '../../shared/widgets/mini_avatar.dart';
import '../di/injection_container.dart';
import '../network/org_context_service.dart';
import '../../features/admin/presentation/pages/user_management_page.dart';
import '../../features/settings/presentation/pages/system_settings_page.dart';
@@ -12,18 +14,22 @@ import '../../features/backup/presentation/pages/backup_page.dart';
import '../../features/logs/presentation/pages/logs_page.dart';
import '../../features/reports/presentation/pages/reports_page_wrapper.dart';
import '../../features/epargne/presentation/pages/epargne_page.dart';
import '../../features/contributions/presentation/pages/contributions_page_wrapper.dart';
import '../../features/contributions/presentation/pages/contributions_page_wrapper.dart' show CotisationsPageWrapper;
import '../../features/adhesions/presentation/pages/adhesions_page_wrapper.dart';
import '../../features/solidarity/presentation/pages/demandes_aide_page_wrapper.dart';
import '../../features/organizations/presentation/pages/organizations_page_wrapper.dart';
import '../../features/organizations/presentation/pages/org_selector_page.dart';
import '../../features/organizations/bloc/org_switcher_bloc.dart';
import '../../features/profile/presentation/pages/profile_page_wrapper.dart';
/// Page "Plus" avec les fonctions avancées selon le rôle
/// Page "Plus" avec les fonctions avancées selon le rôle et les modules actifs.
class MorePage extends StatelessWidget {
const MorePage({super.key});
@override
Widget build(BuildContext context) {
final orgCtx = sl<OrgContextService>();
return BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) {
if (state is! AuthAuthenticated) {
@@ -43,11 +49,12 @@ class MorePage extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildUserProfile(context, state),
_buildUserProfile(context, state, orgCtx),
const SizedBox(height: SpacingTokens.md),
..._buildRoleBasedOptions(context, state),
..._buildRoleBasedOptions(context, state, orgCtx),
..._buildModuleOptions(context, state, orgCtx),
const SizedBox(height: SpacingTokens.md),
..._buildCommonOptions(context),
..._buildCommonOptions(context, orgCtx),
],
),
),
@@ -56,7 +63,8 @@ class MorePage extends StatelessWidget {
);
}
Widget _buildUserProfile(BuildContext context, AuthAuthenticated state) {
Widget _buildUserProfile(
BuildContext context, AuthAuthenticated state, OrgContextService orgCtx) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final textColor = isDark ? AppColors.textPrimaryDark : AppColors.textPrimaryLight;
final roleColor = isDark ? AppColors.brandGreenLight : AppColors.primaryGreen;
@@ -90,6 +98,10 @@ class MorePage extends StatelessWidget {
fontWeight: FontWeight.bold,
),
),
if (orgCtx.hasContext) ...[
const SizedBox(height: 4),
_OrgBadge(orgCtx: orgCtx, onTap: () => _openOrgSelector(context)),
],
],
),
),
@@ -101,7 +113,20 @@ class MorePage extends StatelessWidget {
);
}
List<Widget> _buildRoleBasedOptions(BuildContext context, AuthAuthenticated state) {
void _openOrgSelector(BuildContext context) {
// Vérifier que OrgSwitcherBloc est disponible (fourni par un ancêtre)
try {
showOrgSelector(context);
} catch (_) {
// OrgSwitcherBloc pas fourni dans ce contexte, navigation vers ProfilePage
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const ProfilePageWrapper()),
);
}
}
List<Widget> _buildRoleBasedOptions(
BuildContext context, AuthAuthenticated state, OrgContextService orgCtx) {
final options = <Widget>[];
if (state.effectiveRole == UserRole.superAdmin) {
@@ -195,7 +220,115 @@ class MorePage extends StatelessWidget {
return options;
}
List<Widget> _buildCommonOptions(BuildContext context) {
/// Sections de modules métier — visibles uniquement si le module est actif.
List<Widget> _buildModuleOptions(
BuildContext context, AuthAuthenticated state, OrgContextService orgCtx) {
final options = <Widget>[];
final isAdmin = state.effectiveRole == UserRole.orgAdmin ||
state.effectiveRole == UserRole.superAdmin;
// Module TONTINE
if (orgCtx.isModuleActif('TONTINE')) {
options.add(_buildSectionTitle(context, 'Tontine'));
if (isAdmin) {
options.add(_buildOptionTile(context,
icon: Icons.autorenew,
title: 'Gestion Tontine',
subtitle: 'Cycles, cotisations et remises',
onTap: () => Navigator.pushNamed(context, '/tontine'),
));
} else {
options.add(_buildOptionTile(context,
icon: Icons.autorenew,
title: 'Ma Tontine',
subtitle: 'Mes cycles et cotisations',
onTap: () => Navigator.pushNamed(context, '/tontine'),
));
}
}
// Module EPARGNE
if (orgCtx.isModuleActif('EPARGNE')) {
options.add(_buildSectionTitle(context, 'Épargne'));
options.add(_buildOptionTile(context,
icon: Icons.savings,
title: isAdmin ? 'Gestion Épargne' : 'Mon Épargne',
subtitle: isAdmin ? 'Comptes épargne et transactions' : 'Mon compte épargne',
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const EpargnePage())),
));
}
// Module CREDIT
if (orgCtx.isModuleActif('CREDIT')) {
options.add(_buildSectionTitle(context, 'Crédit'));
options.add(_buildOptionTile(context,
icon: Icons.account_balance,
title: isAdmin ? 'Gestion Crédit' : 'Mon Crédit',
subtitle: isAdmin ? 'Demandes et suivi des crédits' : 'Mes demandes de crédit',
onTap: () => Navigator.pushNamed(context, '/credit'),
));
}
// Module AGRICULTURE
if (orgCtx.isModuleActif('AGRICULTURE')) {
options.add(_buildSectionTitle(context, 'Agriculture'));
options.add(_buildOptionTile(context,
icon: Icons.eco,
title: 'Campagnes Agricoles',
subtitle: 'Parcelles, récoltes et stocks',
onTap: () => Navigator.pushNamed(context, '/agricole'),
));
}
// Module COLLECTE_FONDS
if (orgCtx.isModuleActif('COLLECTE_FONDS')) {
options.add(_buildSectionTitle(context, 'Collecte de Fonds'));
options.add(_buildOptionTile(context,
icon: Icons.volunteer_activism,
title: 'Campagnes de Collecte',
subtitle: 'Dons et levées de fonds',
onTap: () => Navigator.pushNamed(context, '/collecte'),
));
}
// Module PROJETS_ONG
if (orgCtx.isModuleActif('PROJETS_ONG')) {
options.add(_buildSectionTitle(context, 'Projets ONG'));
options.add(_buildOptionTile(context,
icon: Icons.public,
title: 'Projets',
subtitle: 'Gérer et suivre les projets',
onTap: () => Navigator.pushNamed(context, '/projets-ong'),
));
}
// Module CULTE_DONS
if (orgCtx.isModuleActif('CULTE_DONS')) {
options.add(_buildSectionTitle(context, 'Culte & Dons'));
options.add(_buildOptionTile(context,
icon: Icons.church,
title: 'Dons et Offrandes',
subtitle: 'Gestion des dons religieux',
onTap: () => Navigator.pushNamed(context, '/culte'),
));
}
// Module VOTES
if (orgCtx.isModuleActif('VOTES')) {
options.add(_buildSectionTitle(context, 'Votes'));
options.add(_buildOptionTile(context,
icon: Icons.how_to_vote,
title: 'Votes & Élections',
subtitle: 'Campagnes et résultats',
onTap: () => Navigator.pushNamed(context, '/votes'),
));
}
return options;
}
List<Widget> _buildCommonOptions(BuildContext context, OrgContextService orgCtx) {
return [
_buildSectionTitle(context, 'Général'),
_buildOptionTile(context,
@@ -219,17 +352,20 @@ class MorePage extends StatelessWidget {
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const DemandesAidePageWrapper())),
),
_buildOptionTile(context,
icon: Icons.savings_outlined,
title: 'Comptes épargne',
subtitle: 'Mutuelle épargne dépôts (LCB-FT)',
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const EpargnePage())),
),
// Épargne — affiché en commun uniquement si le module n'est PAS actif (évite doublon avec section module)
if (!orgCtx.isModuleActif('EPARGNE'))
_buildOptionTile(context,
icon: Icons.savings_outlined,
title: 'Comptes épargne',
subtitle: 'Mutuelle épargne dépôts (LCB-FT)',
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const EpargnePage())),
),
];
}
Widget _buildSectionTitle(BuildContext context, String title) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Padding(
padding: const EdgeInsets.only(top: 16, bottom: 6, left: 4),
@@ -252,6 +388,7 @@ class MorePage extends StatelessWidget {
required VoidCallback onTap,
Color? accentColor,
}) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final accent = accentColor ?? AppColors.primaryGreen;
final titleColor = accentColor != null
@@ -297,3 +434,46 @@ class MorePage extends StatelessWidget {
);
}
}
/// Badge compact affichant l'organisation active avec bouton de changement.
class _OrgBadge extends StatelessWidget {
final OrgContextService orgCtx;
final VoidCallback onTap;
const _OrgBadge({required this.orgCtx, required this.onTap});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: colorScheme.primaryContainer.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.business, size: 12, color: colorScheme.primary),
const SizedBox(width: 4),
Flexible(
child: Text(
orgCtx.activeOrganisationNom ?? '',
style: TextStyle(
fontSize: 11,
color: colorScheme.primary,
fontWeight: FontWeight.w600,
),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 2),
Icon(Icons.swap_horiz, size: 12, color: colorScheme.primary),
],
),
),
);
}
}

View File

@@ -10,18 +10,19 @@ import '../error/error_handler.dart';
import '../utils/logger.dart';
import '../../features/authentication/presentation/bloc/auth_bloc.dart';
import '../../features/authentication/data/datasources/keycloak_auth_service.dart';
import 'org_context_service.dart';
/// Client réseau unifié basé sur Dio (Version DRY & Minimaliste).
@lazySingleton
class ApiClient {
late final Dio _dio;
static const FlutterSecureStorage _storage = FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock_this_device),
);
ApiClient() {
ApiClient(OrgContextService orgContextService) {
_dio = Dio(
BaseOptions(
baseUrl: AppConfig.apiBaseUrl,
@@ -53,6 +54,11 @@ class ApiClient {
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
// Injecter l'organisation active si disponible
if (orgContextService.hasContext) {
options.headers[OrgContextService.headerName] =
orgContextService.activeOrganisationId;
}
return handler.next(options);
},
onError: (DioException e, handler) async {

View File

@@ -0,0 +1,70 @@
/// Service singleton qui maintient le contexte d'organisation actif.
///
/// Injecté dans [ApiClient] pour ajouter automatiquement le header
/// [X-Active-Organisation-Id] à chaque requête backend.
library org_context_service;
import 'package:injectable/injectable.dart';
import '../utils/logger.dart';
const _kHeaderActiveOrg = 'X-Active-Organisation-Id';
@lazySingleton
class OrgContextService {
String? _activeOrganisationId;
String? _activeOrganisationNom;
String? _activeOrganisationType;
Set<String> _modulesActifs = {};
/// L'UUID de l'organisation active (null si non sélectionnée).
String? get activeOrganisationId => _activeOrganisationId;
/// Le nom lisible de l'organisation active.
String? get activeOrganisationNom => _activeOrganisationNom;
/// Le type de l'organisation active (ex: ASSOCIATION, TONTINE...).
String? get activeOrganisationType => _activeOrganisationType;
/// Modules actifs de l'organisation active (en majuscules).
Set<String> get modulesActifs => Set.unmodifiable(_modulesActifs);
/// Nom du header HTTP utilisé par le backend.
static const String headerName = _kHeaderActiveOrg;
/// Indique si un contexte est disponible.
bool get hasContext => _activeOrganisationId != null;
/// Vérifie si un module spécifique est actif.
bool isModuleActif(String module) =>
_modulesActifs.contains(module.toUpperCase());
/// Définit l'organisation active.
void setActiveOrganisation({
required String organisationId,
required String nom,
String? type,
String? modulesActifsCsv,
}) {
_activeOrganisationId = organisationId;
_activeOrganisationNom = nom;
_activeOrganisationType = type;
_modulesActifs = _parseModules(modulesActifsCsv);
AppLogger.info(
'OrgContextService: organisation active → $nom ($organisationId)'
' | modules: $_modulesActifs');
}
/// Réinitialise le contexte (ex: à la déconnexion).
void clear() {
_activeOrganisationId = null;
_activeOrganisationNom = null;
_activeOrganisationType = null;
_modulesActifs = {};
AppLogger.info('OrgContextService: contexte org effacé');
}
Set<String> _parseModules(String? csv) {
if (csv == null || csv.isEmpty) return {};
return csv.split(',').map((m) => m.trim().toUpperCase()).toSet();
}
}

View File

@@ -25,8 +25,10 @@ abstract class WebSocketEvent {
factory WebSocketEvent.fromJson(Map<String, dynamic> json) {
final eventType = json['eventType'] as String;
final timestamp = DateTime.parse(json['timestamp'] as String);
final data = json['data'] as Map<String, dynamic>;
final timestamp = json['timestamp'] != null
? DateTime.parse(json['timestamp'] as String)
: DateTime.now();
final data = (json['data'] as Map<String, dynamic>?) ?? <String, dynamic>{};
switch (eventType) {
case 'APPROVAL_PENDING':
@@ -242,6 +244,26 @@ class WebSocketService {
return '$baseUrl/ws/dashboard';
}
// ─────────────────────────────────────────────────────────────────────────
// Helpers de conversion de types (Flutter Web / dart2js compatibility)
// ─────────────────────────────────────────────────────────────────────────
/// Convertit récursivement un objet JSON (potentiellement JS) en types Dart natifs.
/// Nécessaire sur Flutter Web où jsonDecode peut retourner des LegacyJavaScriptObject.
static dynamic _toDart(dynamic value) {
if (value is Map) {
return Map<String, dynamic>.fromEntries(
(value as Map).entries.map(
(e) => MapEntry(e.key.toString(), _toDart(e.value)),
),
);
}
if (value is List) {
return (value as List).map(_toDart).toList();
}
return value;
}
/// Gestion des messages reçus
void _onMessage(dynamic message) {
try {
@@ -249,12 +271,25 @@ class WebSocketService {
AppLogger.debug('WebSocket message reçu: $message');
}
final json = jsonDecode(message as String) as Map<String, dynamic>;
// Sur Flutter Web (web_socket_channel ^3.0.x avec package:web), les messages
// text peuvent arriver comme JSString/LegacyJavaScriptObject plutôt que String.
// toString() fonctionne pour les strings JS primitifs.
final String rawMessage = message is String ? message : message.toString();
if (rawMessage.isEmpty) return;
// Convertir en types Dart natifs pour éviter les LegacyJavaScriptObject imbriqués
final dynamic decoded = jsonDecode(rawMessage);
if (decoded is! Map) {
AppLogger.warning('WebSocket: message ignoré (non-objet): type=${decoded.runtimeType}');
return;
}
final json = _toDart(decoded) as Map<String, dynamic>;
final type = json['type'] as String?;
// Gérer les messages système
if (type == 'connected') {
AppLogger.info('🔗 WebSocket: ${json['data']['message']}');
final connectedMsg = (json['data'] as Map<String, dynamic>?)?['message'] ?? 'WebSocket connecté';
AppLogger.info('🔗 WebSocket: $connectedMsg');
return;
}