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:
@@ -10,4 +10,4 @@ final GetIt getIt = GetIt.instance;
|
||||
preferRelativeImports: true, // default
|
||||
asExtension: true, // default
|
||||
)
|
||||
void configureDependencies() => getIt.init();
|
||||
Future<void> configureDependencies() => getIt.init();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
70
lib/core/network/org_context_service.dart
Normal file
70
lib/core/network/org_context_service.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user