- explore + feed : pages de découverte (réseau, fil d'actualité) - finance_workflow : approvals bloc + budgets bloc + dialogs - help : support page avec FAQ + contact - logs : monitoring bloc avec metrics + alerts + searchLogs - members : recherche avancée, bulk actions, bloc complet, import/export - notifications : bloc + page
1301 lines
48 KiB
Dart
1301 lines
48 KiB
Dart
/// Bottom sheet d'ajout de membre — Design UnionFlow V2
|
|
library add_member_dialog;
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
import 'package:get_it/get_it.dart';
|
|
import 'package:intl/intl.dart';
|
|
import 'package:share_plus/share_plus.dart';
|
|
import 'package:url_launcher/url_launcher.dart';
|
|
import '../../../authentication/data/models/user_role.dart';
|
|
import '../../../authentication/presentation/bloc/auth_bloc.dart';
|
|
import '../../../organizations/domain/repositories/organization_repository.dart';
|
|
import '../../../../shared/design_system/tokens/unionflow_colors.dart';
|
|
import '../../bloc/membres_bloc.dart';
|
|
import '../../bloc/membres_event.dart';
|
|
import '../../bloc/membres_state.dart';
|
|
import '../../data/models/membre_complete_model.dart';
|
|
|
|
/// Ouvre le bottom sheet d'ajout de membre.
|
|
/// Doit être appelé depuis un contexte qui a accès à [MembresBloc] et [AuthBloc].
|
|
void showAddMemberSheet(BuildContext context) {
|
|
final membresBloc = context.read<MembresBloc>();
|
|
final authState = context.read<AuthBloc>().state;
|
|
|
|
showModalBottomSheet<void>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
backgroundColor: Colors.transparent,
|
|
builder: (_) => BlocProvider.value(
|
|
value: membresBloc,
|
|
child: AddMemberSheet(
|
|
authState: authState,
|
|
onCreated: (membre) => _showPasswordDialog(context, membre),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// ─── Helpers credentials ─────────────────────────────────────────────────────
|
|
|
|
/// Formate le bloc de credentials en texte brut partageable
|
|
String _credentialText(String nomComplet, String email, String password) =>
|
|
'''
|
|
Identifiants UnionFlow
|
|
─────────────────────────────
|
|
Membre $nomComplet
|
|
Email $email
|
|
Mot de passe $password
|
|
─────────────────────────────
|
|
⚠ Changez le mot de passe à la première connexion.
|
|
'''
|
|
.trim();
|
|
|
|
// ─── Dialog credentials ───────────────────────────────────────────────────────
|
|
|
|
/// Point d'entrée public — utilisable depuis n'importe quel widget (ex: reset mot de passe)
|
|
void showCredentialsDialog(BuildContext context, MembreCompletModel membre) =>
|
|
_showPasswordDialog(context, membre);
|
|
|
|
void _showPasswordDialog(BuildContext context, MembreCompletModel membre) {
|
|
final username = membre.email;
|
|
final password = membre.motDePasseTemporaire;
|
|
final nomComplet = '${membre.prenom} ${membre.nom}';
|
|
final credText = password != null
|
|
? _credentialText(nomComplet, username, password)
|
|
: null;
|
|
|
|
showDialog<void>(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
builder: (dialogCtx) => _CredentialsDialog(
|
|
membre: membre,
|
|
username: username,
|
|
password: password,
|
|
credText: credText,
|
|
),
|
|
);
|
|
}
|
|
|
|
// ─── Widget dialog ────────────────────────────────────────────────────────────
|
|
|
|
class _CredentialsDialog extends StatefulWidget {
|
|
final MembreCompletModel membre;
|
|
final String username;
|
|
final String? password;
|
|
final String? credText;
|
|
|
|
const _CredentialsDialog({
|
|
required this.membre,
|
|
required this.username,
|
|
this.password,
|
|
this.credText,
|
|
});
|
|
|
|
@override
|
|
State<_CredentialsDialog> createState() => _CredentialsDialogState();
|
|
}
|
|
|
|
class _CredentialsDialogState extends State<_CredentialsDialog> {
|
|
bool _copied = false;
|
|
|
|
void _copyAll() {
|
|
if (widget.credText == null) return;
|
|
Clipboard.setData(ClipboardData(text: widget.credText!));
|
|
setState(() => _copied = true);
|
|
Future.delayed(const Duration(seconds: 2),
|
|
() { if (mounted) setState(() => _copied = false); });
|
|
}
|
|
|
|
Future<void> _shareViaSms() async {
|
|
if (widget.credText == null) return;
|
|
await Share.share(widget.credText!, subject: 'Identifiants UnionFlow');
|
|
}
|
|
|
|
Future<void> _shareViaEmail() async {
|
|
if (widget.credText == null) return;
|
|
final subject = Uri.encodeComponent('Vos identifiants UnionFlow');
|
|
final body = Uri.encodeComponent(widget.credText!);
|
|
final uri = Uri.parse('mailto:${widget.membre.email}?subject=$subject&body=$body');
|
|
if (await canLaunchUrl(uri)) {
|
|
await launchUrl(uri);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final nomComplet = '${widget.membre.prenom} ${widget.membre.nom}';
|
|
|
|
return Dialog(
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
|
insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 40),
|
|
child: SingleChildScrollView(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(20),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// ── Header ──────────────────────────────────────────────
|
|
Row(
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: UnionFlowColors.successPale,
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: const Icon(Icons.check_circle_rounded,
|
|
color: UnionFlowColors.success, size: 22),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text('Compte créé',
|
|
style: TextStyle(
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w700,
|
|
color: UnionFlowColors.textPrimary)),
|
|
Text(nomComplet,
|
|
style: const TextStyle(
|
|
fontSize: 12,
|
|
color: UnionFlowColors.textSecondary)),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// ── Cas sans mot de passe ────────────────────────────────
|
|
if (widget.password == null) ...[
|
|
Container(
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: UnionFlowColors.infoPale,
|
|
borderRadius: BorderRadius.circular(10),
|
|
border: Border.all(color: UnionFlowColors.infoLight),
|
|
),
|
|
child: const Row(
|
|
children: [
|
|
Icon(Icons.info_outline_rounded,
|
|
size: 15, color: UnionFlowColors.info),
|
|
SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
'Provisionnement Keycloak non bloquant — mot de passe à définir manuellement via la console.',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: UnionFlowColors.textSecondary,
|
|
height: 1.4),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
|
|
// ── Bloc credentials ─────────────────────────────────────
|
|
if (widget.password != null) ...[
|
|
const Text(
|
|
'IDENTIFIANTS À COMMUNIQUER',
|
|
style: TextStyle(
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.w700,
|
|
color: UnionFlowColors.textTertiary,
|
|
letterSpacing: 1),
|
|
),
|
|
const SizedBox(height: 8),
|
|
|
|
// Bloc code sombre
|
|
Container(
|
|
width: double.infinity,
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFF0D1428), // navy sombre terminal
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: const Color(0xFF2D3554)),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Barre titre du bloc
|
|
Container(
|
|
padding: const EdgeInsets.fromLTRB(14, 10, 10, 10),
|
|
decoration: const BoxDecoration(
|
|
color: Color(0xFF060A14),
|
|
borderRadius:
|
|
BorderRadius.vertical(top: Radius.circular(11)),
|
|
border: Border(
|
|
bottom: BorderSide(color: Color(0xFF2D3554))),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.terminal_rounded,
|
|
size: 13, color: Color(0xFF297FFF)),
|
|
const SizedBox(width: 6),
|
|
const Text(
|
|
'credentials.txt',
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w600,
|
|
color: Color(0xFF69B7FF),
|
|
fontFamily: 'monospace'),
|
|
),
|
|
const Spacer(),
|
|
GestureDetector(
|
|
onTap: _copyAll,
|
|
child: AnimatedSwitcher(
|
|
duration: const Duration(milliseconds: 200),
|
|
child: _copied
|
|
? const Row(
|
|
key: ValueKey('copied'),
|
|
children: [
|
|
Icon(Icons.check_rounded,
|
|
size: 13,
|
|
color: Color(0xFF22C55E)),
|
|
SizedBox(width: 4),
|
|
Text('Copié',
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: Color(0xFF22C55E),
|
|
fontFamily: 'monospace')),
|
|
],
|
|
)
|
|
: const Row(
|
|
key: ValueKey('copy'),
|
|
children: [
|
|
Icon(Icons.copy_rounded,
|
|
size: 13,
|
|
color: Color(0xFF69B7FF)),
|
|
SizedBox(width: 4),
|
|
Text('Copier tout',
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: Color(0xFF69B7FF),
|
|
fontFamily: 'monospace')),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
// Corps du bloc
|
|
Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_credLine('# Identifiants UnionFlow',
|
|
color: const Color(0xFF5C8DB5),
|
|
isComment: true),
|
|
const SizedBox(height: 8),
|
|
_credLine('email ',
|
|
value: widget.username,
|
|
labelColor: const Color(0xFF9CDCFE),
|
|
valueColor: const Color(0xFFCE9178)),
|
|
const SizedBox(height: 6),
|
|
_credLine('mot_de_passe',
|
|
value: widget.password!,
|
|
labelColor: const Color(0xFF9CDCFE),
|
|
valueColor: const Color(0xFFD4D4D4),
|
|
isPassword: true),
|
|
const SizedBox(height: 12),
|
|
_credLine(
|
|
'# Changez le mot de passe à la 1ère connexion',
|
|
color: const Color(0xFF5C8DB5),
|
|
isComment: true),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// ── Boutons partage ──────────────────────────────────
|
|
const Text(
|
|
'PARTAGER LES IDENTIFIANTS',
|
|
style: TextStyle(
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.w700,
|
|
color: UnionFlowColors.textTertiary,
|
|
letterSpacing: 1),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: _shareButton(
|
|
icon: Icons.sms_rounded,
|
|
label: 'SMS',
|
|
color: UnionFlowColors.unionGreen,
|
|
onTap: _shareViaSms,
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: _shareButton(
|
|
icon: Icons.email_rounded,
|
|
label: 'Email',
|
|
color: UnionFlowColors.info,
|
|
onTap: _shareViaEmail,
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: _shareButton(
|
|
icon: Icons.share_rounded,
|
|
label: 'Partager',
|
|
color: UnionFlowColors.amber,
|
|
onTap: _shareViaSms, // opens share sheet
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 14),
|
|
|
|
// ── Note prochaine étape ─────────────────────────────
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 12, vertical: 10),
|
|
decoration: BoxDecoration(
|
|
color: UnionFlowColors.warningPale,
|
|
borderRadius: BorderRadius.circular(10),
|
|
border: Border.all(color: UnionFlowColors.warningLight),
|
|
),
|
|
child: const Row(
|
|
children: [
|
|
Icon(Icons.info_outline_rounded,
|
|
size: 14, color: UnionFlowColors.warning),
|
|
SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
'Pensez à valider l\'adhésion du membre pour activer son accès complet.',
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: UnionFlowColors.textSecondary,
|
|
height: 1.4),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
|
|
const SizedBox(height: 20),
|
|
|
|
// ── Action ───────────────────────────────────────────────
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: FilledButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
style: FilledButton.styleFrom(
|
|
backgroundColor: UnionFlowColors.unionGreen,
|
|
padding: const EdgeInsets.symmetric(vertical: 13),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(10)),
|
|
),
|
|
child: const Text('Compris',
|
|
style: TextStyle(fontWeight: FontWeight.w600)),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _credLine(
|
|
String label, {
|
|
String? value,
|
|
Color? color,
|
|
Color? labelColor,
|
|
Color? valueColor,
|
|
bool isComment = false,
|
|
bool isPassword = false,
|
|
}) {
|
|
if (isComment) {
|
|
return Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontFamily: 'monospace',
|
|
fontSize: 11,
|
|
color: color ?? const Color(0xFF5C8DB5),
|
|
height: 1.4),
|
|
);
|
|
}
|
|
|
|
return Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontFamily: 'monospace',
|
|
fontSize: 13,
|
|
color: labelColor ?? const Color(0xFF9CDCFE),
|
|
fontWeight: FontWeight.w600),
|
|
),
|
|
const Text(' ',
|
|
style: TextStyle(fontFamily: 'monospace', fontSize: 13)),
|
|
Expanded(
|
|
child: SelectableText(
|
|
value ?? '',
|
|
style: TextStyle(
|
|
fontFamily: 'monospace',
|
|
fontSize: isPassword ? 15 : 13,
|
|
color: valueColor ?? const Color(0xFFD4D4D4),
|
|
fontWeight:
|
|
isPassword ? FontWeight.w700 : FontWeight.normal,
|
|
letterSpacing: isPassword ? 1.5 : 0,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _shareButton({
|
|
required IconData icon,
|
|
required String label,
|
|
required Color color,
|
|
required VoidCallback onTap,
|
|
}) {
|
|
return InkWell(
|
|
onTap: onTap,
|
|
borderRadius: BorderRadius.circular(10),
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
|
decoration: BoxDecoration(
|
|
color: color.withOpacity(0.08),
|
|
borderRadius: BorderRadius.circular(10),
|
|
border: Border.all(color: color.withOpacity(0.25)),
|
|
),
|
|
child: Column(
|
|
children: [
|
|
Icon(icon, size: 18, color: color),
|
|
const SizedBox(height: 4),
|
|
Text(label,
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w600,
|
|
color: color)),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─── Bottom sheet ─────────────────────────────────────────────────────────────
|
|
|
|
class AddMemberSheet extends StatefulWidget {
|
|
final AuthState authState;
|
|
final void Function(MembreCompletModel) onCreated;
|
|
|
|
const AddMemberSheet({
|
|
super.key,
|
|
required this.authState,
|
|
required this.onCreated,
|
|
});
|
|
|
|
@override
|
|
State<AddMemberSheet> createState() => _AddMemberSheetState();
|
|
}
|
|
|
|
class _AddMemberSheetState extends State<AddMemberSheet> {
|
|
final _formKey = GlobalKey<FormState>();
|
|
|
|
// Requis
|
|
final _prenomCtrl = TextEditingController();
|
|
final _nomCtrl = TextEditingController();
|
|
final _emailCtrl = TextEditingController();
|
|
DateTime? _dateNaissance;
|
|
bool _dateMissing = false;
|
|
|
|
// Optionnels
|
|
final _telephoneCtrl = TextEditingController();
|
|
final _telephoneWaveCtrl = TextEditingController();
|
|
final _professionCtrl = TextEditingController();
|
|
final _nationaliteCtrl = TextEditingController();
|
|
final _numeroIdentiteCtrl = TextEditingController();
|
|
String? _statutMatrimonial;
|
|
String? _typeIdentite;
|
|
|
|
bool _showOptional = false;
|
|
bool _isSubmitting = false;
|
|
String? _errorMessage;
|
|
|
|
// Sélection organisation (superadmin uniquement)
|
|
String? _selectedOrganisationId;
|
|
String? _selectedOrganisationNom;
|
|
List<Map<String, String>> _organisations = [];
|
|
bool _loadingOrgs = false;
|
|
|
|
bool get _isSuperAdmin {
|
|
final auth = widget.authState;
|
|
return auth is AuthAuthenticated &&
|
|
auth.user.primaryRole == UserRole.superAdmin;
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
if (_isSuperAdmin) _loadOrganisations();
|
|
}
|
|
|
|
Future<void> _loadOrganisations() async {
|
|
setState(() => _loadingOrgs = true);
|
|
try {
|
|
final repo = GetIt.instance<IOrganizationRepository>();
|
|
final orgs = await repo.getOrganizations(page: 0, size: 100);
|
|
if (mounted) {
|
|
setState(() {
|
|
_organisations = orgs
|
|
.where((o) => o.id != null && o.id!.isNotEmpty)
|
|
.map((o) => {'id': o.id!, 'nom': o.nomAffichage})
|
|
.toList();
|
|
});
|
|
}
|
|
} catch (e) {
|
|
// Non bloquant : le sélecteur affichera un message d'erreur
|
|
} finally {
|
|
if (mounted) setState(() => _loadingOrgs = false);
|
|
}
|
|
}
|
|
|
|
static const _statutOptions = [
|
|
'CELIBATAIRE',
|
|
'MARIE',
|
|
'DIVORCE',
|
|
'VEUF',
|
|
];
|
|
static const _statutLabels = {
|
|
'CELIBATAIRE': 'Célibataire',
|
|
'MARIE': 'Marié(e)',
|
|
'DIVORCE': 'Divorcé(e)',
|
|
'VEUF': 'Veuf / Veuve',
|
|
};
|
|
|
|
static const _identiteOptions = [
|
|
'CNI',
|
|
'PASSEPORT',
|
|
'PERMIS_CONDUIRE',
|
|
'TITRE_SEJOUR',
|
|
];
|
|
static const _identiteLabels = {
|
|
'CNI': "Carte Nationale d'Identité",
|
|
'PASSEPORT': 'Passeport',
|
|
'PERMIS_CONDUIRE': 'Permis de conduire',
|
|
'TITRE_SEJOUR': 'Titre de séjour',
|
|
};
|
|
|
|
@override
|
|
void dispose() {
|
|
_prenomCtrl.dispose();
|
|
_nomCtrl.dispose();
|
|
_emailCtrl.dispose();
|
|
_telephoneCtrl.dispose();
|
|
_telephoneWaveCtrl.dispose();
|
|
_professionCtrl.dispose();
|
|
_nationaliteCtrl.dispose();
|
|
_numeroIdentiteCtrl.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
String? _resolveOrganisationId() {
|
|
// Superadmin : utilise l'org sélectionnée dans le sélecteur
|
|
if (_isSuperAdmin) return _selectedOrganisationId;
|
|
|
|
final auth = widget.authState;
|
|
if (auth is AuthAuthenticated) {
|
|
return auth.user.organizationContexts.isNotEmpty
|
|
? auth.user.organizationContexts.first.organizationId
|
|
: null;
|
|
}
|
|
if (auth is AuthPendingOnboarding) return auth.organisationId;
|
|
return null;
|
|
}
|
|
|
|
void _submit() {
|
|
final formValid = _formKey.currentState!.validate();
|
|
final dateValid = _dateNaissance != null;
|
|
final orgValid = !_isSuperAdmin || _selectedOrganisationId != null;
|
|
|
|
setState(() {
|
|
_dateMissing = !dateValid;
|
|
if (!orgValid) _errorMessage = 'Veuillez sélectionner une organisation.';
|
|
});
|
|
|
|
if (!formValid || !dateValid || !orgValid) return;
|
|
|
|
setState(() {
|
|
_isSubmitting = true;
|
|
_errorMessage = null;
|
|
});
|
|
|
|
final membre = MembreCompletModel(
|
|
prenom: _prenomCtrl.text.trim(),
|
|
nom: _nomCtrl.text.trim(),
|
|
email: _emailCtrl.text.trim().toLowerCase(),
|
|
dateNaissance: _dateNaissance,
|
|
telephone: _val(_telephoneCtrl),
|
|
telephoneWave: _val(_telephoneWaveCtrl),
|
|
profession: _val(_professionCtrl),
|
|
nationalite: _val(_nationaliteCtrl),
|
|
statutMatrimonial: _statutMatrimonial,
|
|
typeIdentite: _typeIdentite,
|
|
numeroIdentite: _val(_numeroIdentiteCtrl),
|
|
organisationId: _resolveOrganisationId(),
|
|
statut: StatutMembre.actif,
|
|
);
|
|
|
|
context.read<MembresBloc>().add(CreateMembre(membre));
|
|
}
|
|
|
|
String? _val(TextEditingController ctrl) {
|
|
final v = ctrl.text.trim();
|
|
return v.isNotEmpty ? v : null;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return BlocListener<MembresBloc, MembresState>(
|
|
listener: (ctx, state) {
|
|
if (state is MembresLoading) {
|
|
if (mounted) setState(() => _isSubmitting = true);
|
|
} else if (state is MembreCreated) {
|
|
// Fermer la sheet puis afficher le dialog credentials (gère mot de passe null ou non)
|
|
Navigator.pop(ctx);
|
|
widget.onCreated(state.membre);
|
|
} else if (state is MembresError || state is MembresValidationError) {
|
|
final msg = state is MembresValidationError
|
|
? state.validationErrors.values.join(' · ')
|
|
: (state as MembresError).message;
|
|
if (mounted) {
|
|
setState(() {
|
|
_isSubmitting = false;
|
|
_errorMessage = msg;
|
|
});
|
|
}
|
|
} else {
|
|
if (mounted) setState(() => _isSubmitting = false);
|
|
}
|
|
},
|
|
child: DraggableScrollableSheet(
|
|
initialChildSize: 0.92,
|
|
maxChildSize: 0.97,
|
|
minChildSize: 0.5,
|
|
builder: (_, scrollCtrl) => Container(
|
|
decoration: const BoxDecoration(
|
|
color: UnionFlowColors.surface,
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
|
),
|
|
child: Column(
|
|
children: [
|
|
_buildHandle(),
|
|
_buildHeader(),
|
|
const Divider(height: 1, color: UnionFlowColors.border),
|
|
if (_errorMessage != null) _buildErrorBanner(),
|
|
Expanded(child: _buildForm(scrollCtrl)),
|
|
_buildFooter(),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// ── Handle ─────────────────────────────────────────────────────────────────
|
|
|
|
Widget _buildHandle() => Center(
|
|
child: Container(
|
|
margin: const EdgeInsets.only(top: 12, bottom: 6),
|
|
width: 36,
|
|
height: 4,
|
|
decoration: BoxDecoration(
|
|
color: UnionFlowColors.border,
|
|
borderRadius: BorderRadius.circular(2),
|
|
),
|
|
),
|
|
);
|
|
|
|
// ── Header ─────────────────────────────────────────────────────────────────
|
|
|
|
Widget _buildHeader() => Padding(
|
|
padding: const EdgeInsets.fromLTRB(20, 6, 8, 14),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 38,
|
|
height: 38,
|
|
decoration: BoxDecoration(
|
|
color: UnionFlowColors.unionGreenPale,
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: const Icon(Icons.person_add_rounded,
|
|
color: UnionFlowColors.unionGreen, size: 20),
|
|
),
|
|
const SizedBox(width: 12),
|
|
const Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Nouveau membre',
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w700,
|
|
color: UnionFlowColors.textPrimary),
|
|
),
|
|
Text(
|
|
'Compte Keycloak provisionné automatiquement',
|
|
style: TextStyle(
|
|
fontSize: 11, color: UnionFlowColors.textSecondary),
|
|
),
|
|
],
|
|
),
|
|
const Spacer(),
|
|
IconButton(
|
|
icon: const Icon(Icons.close_rounded,
|
|
size: 20, color: UnionFlowColors.textSecondary),
|
|
onPressed: () => Navigator.pop(context),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
// ── Bannière erreur ────────────────────────────────────────────────────────
|
|
|
|
Widget _buildErrorBanner() => Container(
|
|
margin: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
|
decoration: BoxDecoration(
|
|
color: UnionFlowColors.errorPale,
|
|
borderRadius: BorderRadius.circular(10),
|
|
border: Border.all(color: UnionFlowColors.errorLight),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.error_outline_rounded,
|
|
size: 16, color: UnionFlowColors.error),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
_errorMessage!,
|
|
style: const TextStyle(
|
|
fontSize: 12, color: UnionFlowColors.error),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
// ── Formulaire ─────────────────────────────────────────────────────────────
|
|
|
|
Widget _buildOrgPicker() {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_sectionLabel('Organisation', required: true),
|
|
const SizedBox(height: 10),
|
|
if (_loadingOrgs)
|
|
const Center(
|
|
child: Padding(
|
|
padding: EdgeInsets.symmetric(vertical: 12),
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
),
|
|
)
|
|
else if (_organisations.isEmpty)
|
|
Container(
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: UnionFlowColors.warningPale,
|
|
borderRadius: BorderRadius.circular(10),
|
|
border: Border.all(color: UnionFlowColors.warning.withOpacity(0.4)),
|
|
),
|
|
child: const Text(
|
|
'Aucune organisation disponible. Créez d\'abord une organisation.',
|
|
style: TextStyle(fontSize: 12, color: UnionFlowColors.textSecondary),
|
|
),
|
|
)
|
|
else
|
|
DropdownButtonFormField<String>(
|
|
value: _selectedOrganisationId,
|
|
decoration: InputDecoration(
|
|
prefixIcon: const Icon(Icons.business_outlined, size: 18,
|
|
color: UnionFlowColors.textSecondary),
|
|
hintText: 'Sélectionner une organisation',
|
|
hintStyle: const TextStyle(fontSize: 13, color: UnionFlowColors.textTertiary),
|
|
contentPadding: const EdgeInsets.symmetric(vertical: 13, horizontal: 14),
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
borderSide: BorderSide(color: UnionFlowColors.border.withOpacity(0.4)),
|
|
),
|
|
enabledBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
borderSide: BorderSide(color: UnionFlowColors.border.withOpacity(0.4)),
|
|
),
|
|
focusedBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
borderSide: const BorderSide(color: UnionFlowColors.unionGreen, width: 1.5),
|
|
),
|
|
filled: true,
|
|
fillColor: UnionFlowColors.surfaceVariant.withOpacity(0.3),
|
|
),
|
|
isExpanded: true,
|
|
items: _organisations
|
|
.map((org) => DropdownMenuItem<String>(
|
|
value: org['id'],
|
|
child: Text(
|
|
org['nom']!,
|
|
style: const TextStyle(fontSize: 13, color: UnionFlowColors.textPrimary),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
))
|
|
.toList(),
|
|
onChanged: (val) => setState(() {
|
|
_selectedOrganisationId = val;
|
|
_selectedOrganisationNom = _organisations
|
|
.firstWhere((o) => o['id'] == val, orElse: () => {})['nom'];
|
|
_errorMessage = null;
|
|
}),
|
|
validator: (v) => v == null ? 'Obligatoire' : null,
|
|
),
|
|
const SizedBox(height: 20),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildForm(ScrollController scrollCtrl) => SingleChildScrollView(
|
|
controller: scrollCtrl,
|
|
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
|
|
child: Form(
|
|
key: _formKey,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Sélecteur organisation — superadmin uniquement
|
|
if (_isSuperAdmin) _buildOrgPicker(),
|
|
|
|
// Identité — requis
|
|
_sectionLabel('Identité', required: true),
|
|
const SizedBox(height: 12),
|
|
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: _field(_prenomCtrl, 'Prénom',
|
|
required: true, icon: Icons.badge_outlined),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: _field(_nomCtrl, 'Nom', required: true),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 10),
|
|
|
|
_field(
|
|
_emailCtrl,
|
|
'Adresse email',
|
|
required: true,
|
|
icon: Icons.alternate_email_rounded,
|
|
keyboard: TextInputType.emailAddress,
|
|
validator: (v) {
|
|
if (v == null || v.trim().isEmpty) return 'Obligatoire';
|
|
if (!RegExp(r'^[\w.+\-]+@[\w\-]+\.[a-z]{2,}$')
|
|
.hasMatch(v.trim())) {
|
|
return 'Format invalide';
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
const SizedBox(height: 10),
|
|
|
|
_datePicker(),
|
|
|
|
const SizedBox(height: 20),
|
|
|
|
// Section optionnelle — accordéon
|
|
_optionalToggle(),
|
|
|
|
if (_showOptional) ...[
|
|
const SizedBox(height: 16),
|
|
|
|
_sectionLabel('Contact'),
|
|
const SizedBox(height: 10),
|
|
|
|
_field(_telephoneCtrl, 'Téléphone',
|
|
icon: Icons.phone_outlined,
|
|
keyboard: TextInputType.phone,
|
|
hint: 'ex: +22507XXXXXXXX',
|
|
maxLength: 20,
|
|
validator: (v) {
|
|
if (v == null || v.trim().isEmpty) return null;
|
|
if (!RegExp(r'^\+[1-9][0-9]{6,14}$').hasMatch(v.trim())) {
|
|
return 'Format E.164 requis (ex: +22507XXXXXXXX)';
|
|
}
|
|
return null;
|
|
}),
|
|
const SizedBox(height: 10),
|
|
|
|
_field(_telephoneWaveCtrl, 'Numéro Wave',
|
|
icon: Icons.waves_rounded,
|
|
keyboard: TextInputType.phone,
|
|
hint: 'ex: +22507XXXXXXXX',
|
|
maxLength: 20,
|
|
validator: (v) {
|
|
if (v == null || v.trim().isEmpty) return null;
|
|
if (!RegExp(r'^\+[1-9][0-9]{6,14}$').hasMatch(v.trim())) {
|
|
return 'Format E.164 requis (ex: +22507XXXXXXXX)';
|
|
}
|
|
return null;
|
|
}),
|
|
|
|
const SizedBox(height: 18),
|
|
_sectionLabel('Profil'),
|
|
const SizedBox(height: 10),
|
|
|
|
_field(_professionCtrl, 'Profession',
|
|
icon: Icons.work_outline_rounded, maxLength: 100),
|
|
const SizedBox(height: 10),
|
|
|
|
_field(_nationaliteCtrl, 'Nationalité',
|
|
icon: Icons.flag_outlined, maxLength: 100),
|
|
const SizedBox(height: 10),
|
|
|
|
_dropdown(
|
|
label: 'Statut matrimonial',
|
|
value: _statutMatrimonial,
|
|
items: _statutOptions,
|
|
labels: _statutLabels,
|
|
icon: Icons.favorite_border_rounded,
|
|
onChanged: (v) => setState(() => _statutMatrimonial = v),
|
|
),
|
|
|
|
const SizedBox(height: 18),
|
|
_sectionLabel("Pièce d'identité"),
|
|
const SizedBox(height: 10),
|
|
|
|
_dropdown(
|
|
label: 'Type de pièce',
|
|
value: _typeIdentite,
|
|
items: _identiteOptions,
|
|
labels: _identiteLabels,
|
|
icon: Icons.credit_card_rounded,
|
|
onChanged: (v) => setState(() => _typeIdentite = v),
|
|
),
|
|
const SizedBox(height: 10),
|
|
|
|
_field(_numeroIdentiteCtrl, 'Numéro',
|
|
icon: Icons.numbers_rounded, maxLength: 100),
|
|
],
|
|
|
|
const SizedBox(height: 12),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
|
|
// ── Pied de page ───────────────────────────────────────────────────────────
|
|
|
|
Widget _buildFooter() => Container(
|
|
padding: EdgeInsets.fromLTRB(
|
|
20, 12, 20, 16 + MediaQuery.of(context).viewInsets.bottom),
|
|
decoration: const BoxDecoration(
|
|
color: UnionFlowColors.surface,
|
|
border: Border(top: BorderSide(color: UnionFlowColors.border)),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: OutlinedButton(
|
|
onPressed: _isSubmitting ? null : () => Navigator.pop(context),
|
|
style: OutlinedButton.styleFrom(
|
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
|
side: const BorderSide(color: UnionFlowColors.border),
|
|
foregroundColor: UnionFlowColors.textSecondary,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(10)),
|
|
),
|
|
child: const Text('Annuler'),
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
flex: 2,
|
|
child: FilledButton(
|
|
onPressed: _isSubmitting ? null : _submit,
|
|
style: FilledButton.styleFrom(
|
|
backgroundColor: UnionFlowColors.unionGreen,
|
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(10)),
|
|
),
|
|
child: _isSubmitting
|
|
? const SizedBox(
|
|
width: 18,
|
|
height: 18,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2, color: Colors.white),
|
|
)
|
|
: const Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(Icons.person_add_rounded, size: 16),
|
|
SizedBox(width: 8),
|
|
Text('Créer le membre',
|
|
style: TextStyle(fontWeight: FontWeight.w600)),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
// ── Composants internes ────────────────────────────────────────────────────
|
|
|
|
Widget _sectionLabel(String label, {bool required = false}) => Row(
|
|
children: [
|
|
Text(
|
|
label.toUpperCase(),
|
|
style: const TextStyle(
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.w700,
|
|
color: UnionFlowColors.textTertiary,
|
|
letterSpacing: 1,
|
|
),
|
|
),
|
|
if (required) ...[
|
|
const SizedBox(width: 4),
|
|
const Text('*',
|
|
style: TextStyle(
|
|
color: UnionFlowColors.error,
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w700)),
|
|
],
|
|
],
|
|
);
|
|
|
|
Widget _optionalToggle() => InkWell(
|
|
onTap: () => setState(() => _showOptional = !_showOptional),
|
|
borderRadius: BorderRadius.circular(10),
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11),
|
|
decoration: BoxDecoration(
|
|
color: UnionFlowColors.surfaceVariant,
|
|
borderRadius: BorderRadius.circular(10),
|
|
border: Border.all(color: UnionFlowColors.border),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.tune_rounded,
|
|
size: 16, color: UnionFlowColors.textSecondary),
|
|
const SizedBox(width: 8),
|
|
const Text(
|
|
'Informations complémentaires',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
color: UnionFlowColors.textSecondary),
|
|
),
|
|
const Spacer(),
|
|
Text(
|
|
_showOptional ? 'Réduire' : 'Développer',
|
|
style: const TextStyle(
|
|
fontSize: 11, color: UnionFlowColors.unionGreen),
|
|
),
|
|
Icon(
|
|
_showOptional ? Icons.expand_less : Icons.expand_more,
|
|
size: 18,
|
|
color: UnionFlowColors.unionGreen,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
|
|
InputDecoration _inputDeco(String label,
|
|
{IconData? icon, String? hint, int? maxLength, bool required = false}) {
|
|
return InputDecoration(
|
|
labelText: required ? '$label *' : label,
|
|
hintText: hint,
|
|
counterText: '',
|
|
prefixIcon: icon != null
|
|
? Icon(icon, size: 18, color: UnionFlowColors.textTertiary)
|
|
: null,
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(10),
|
|
borderSide: const BorderSide(color: UnionFlowColors.border),
|
|
),
|
|
enabledBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(10),
|
|
borderSide: const BorderSide(color: UnionFlowColors.border),
|
|
),
|
|
focusedBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(10),
|
|
borderSide:
|
|
const BorderSide(color: UnionFlowColors.unionGreen, width: 1.5),
|
|
),
|
|
errorBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(10),
|
|
borderSide: const BorderSide(color: UnionFlowColors.error),
|
|
),
|
|
focusedErrorBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(10),
|
|
borderSide:
|
|
const BorderSide(color: UnionFlowColors.error, width: 1.5),
|
|
),
|
|
contentPadding:
|
|
const EdgeInsets.symmetric(horizontal: 14, vertical: 13),
|
|
filled: true,
|
|
fillColor: UnionFlowColors.surface,
|
|
labelStyle:
|
|
const TextStyle(fontSize: 13, color: UnionFlowColors.textSecondary),
|
|
);
|
|
}
|
|
|
|
Widget _field(
|
|
TextEditingController ctrl,
|
|
String label, {
|
|
bool required = false,
|
|
IconData? icon,
|
|
TextInputType? keyboard,
|
|
String? Function(String?)? validator,
|
|
String? hint,
|
|
int? maxLength,
|
|
}) {
|
|
return TextFormField(
|
|
controller: ctrl,
|
|
keyboardType: keyboard,
|
|
maxLength: maxLength,
|
|
style:
|
|
const TextStyle(fontSize: 14, color: UnionFlowColors.textPrimary),
|
|
decoration: _inputDeco(label,
|
|
icon: icon, hint: hint, maxLength: maxLength, required: required),
|
|
validator: validator ??
|
|
(required
|
|
? (v) =>
|
|
(v == null || v.trim().isEmpty) ? 'Champ obligatoire' : null
|
|
: null),
|
|
);
|
|
}
|
|
|
|
Widget _dropdown({
|
|
required String label,
|
|
required String? value,
|
|
required List<String> items,
|
|
required Map<String, String> labels,
|
|
required IconData icon,
|
|
required void Function(String?) onChanged,
|
|
}) {
|
|
return DropdownButtonFormField<String>(
|
|
value: value,
|
|
isExpanded: true,
|
|
decoration: _inputDeco(label, icon: icon),
|
|
style:
|
|
const TextStyle(fontSize: 14, color: UnionFlowColors.textPrimary),
|
|
items: items
|
|
.map((k) => DropdownMenuItem(
|
|
value: k,
|
|
child: Text(labels[k] ?? k,
|
|
style: const TextStyle(fontSize: 14)),
|
|
))
|
|
.toList(),
|
|
onChanged: onChanged,
|
|
);
|
|
}
|
|
|
|
Widget _datePicker() => Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
InkWell(
|
|
onTap: _pickDate,
|
|
borderRadius: BorderRadius.circular(10),
|
|
child: InputDecorator(
|
|
decoration: InputDecoration(
|
|
labelText: 'Date de naissance *',
|
|
prefixIcon: const Icon(Icons.calendar_today_rounded,
|
|
size: 18, color: UnionFlowColors.textTertiary),
|
|
suffixIcon: const Icon(Icons.chevron_right_rounded,
|
|
size: 18, color: UnionFlowColors.textTertiary),
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(10),
|
|
borderSide: BorderSide(
|
|
color: _dateMissing
|
|
? UnionFlowColors.error
|
|
: UnionFlowColors.border,
|
|
),
|
|
),
|
|
enabledBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(10),
|
|
borderSide: BorderSide(
|
|
color: _dateMissing
|
|
? UnionFlowColors.error
|
|
: _dateNaissance != null
|
|
? UnionFlowColors.unionGreen
|
|
: UnionFlowColors.border,
|
|
),
|
|
),
|
|
contentPadding:
|
|
const EdgeInsets.symmetric(horizontal: 14, vertical: 13),
|
|
filled: true,
|
|
fillColor: UnionFlowColors.surface,
|
|
labelStyle: const TextStyle(
|
|
fontSize: 13, color: UnionFlowColors.textSecondary),
|
|
),
|
|
child: Text(
|
|
_dateNaissance != null
|
|
? DateFormat('dd MMMM yyyy', 'fr').format(_dateNaissance!)
|
|
: 'Sélectionner une date',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
color: _dateNaissance != null
|
|
? UnionFlowColors.textPrimary
|
|
: UnionFlowColors.textTertiary,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
if (_dateMissing)
|
|
const Padding(
|
|
padding: EdgeInsets.only(top: 4, left: 14),
|
|
child: Text(
|
|
'La date de naissance est obligatoire',
|
|
style: TextStyle(fontSize: 11, color: UnionFlowColors.error),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
|
|
Future<void> _pickDate() async {
|
|
final picked = await showDatePicker(
|
|
context: context,
|
|
initialDate: _dateNaissance ?? DateTime(1990),
|
|
firstDate: DateTime(1900),
|
|
lastDate: DateTime.now().subtract(const Duration(days: 365 * 16)),
|
|
builder: (ctx, child) => Theme(
|
|
data: Theme.of(ctx).copyWith(
|
|
colorScheme: Theme.of(ctx).colorScheme.copyWith(
|
|
primary: UnionFlowColors.unionGreen,
|
|
),
|
|
),
|
|
child: child!,
|
|
),
|
|
);
|
|
if (picked != null) {
|
|
setState(() {
|
|
_dateNaissance = picked;
|
|
_dateMissing = false;
|
|
});
|
|
}
|
|
}
|
|
}
|