- Replace flutter_appauth with custom WebView implementation to resolve deep link issues - Add KeycloakWebViewAuthService with integrated WebView for seamless authentication - Configure Android manifest for HTTP cleartext traffic support - Add network security config for development environment (192.168.1.11) - Update Keycloak client to use HTTP callback endpoint (http://192.168.1.11:8080/auth/callback) - Remove obsolete keycloak_auth_service.dart and temporary scripts - Clean up dependencies and regenerate injection configuration - Tested successfully on multiple Android devices (Xiaomi 2201116TG, SM A725F) BREAKING CHANGE: Authentication flow now uses WebView instead of external browser - Users will see Keycloak login page within the app instead of browser redirect - Resolves ERR_CLEARTEXT_NOT_PERMITTED and deep link state management issues - Maintains full OIDC compliance with PKCE flow and secure token storage Technical improvements: - WebView with custom navigation delegate for callback handling - Automatic token extraction and user info parsing from JWT - Proper error handling and user feedback - Consistent authentication state management across app lifecycle
1123 lines
34 KiB
Dart
1123 lines
34 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
import 'package:intl/intl.dart';
|
|
import '../../../../core/di/injection.dart';
|
|
import '../../../../core/models/membre_model.dart';
|
|
import '../../../../shared/theme/app_theme.dart';
|
|
import '../../../../shared/widgets/custom_text_field.dart';
|
|
import '../../../../shared/widgets/buttons/buttons.dart';
|
|
import '../../../../core/auth/services/permission_service.dart';
|
|
import '../../../../shared/widgets/permission_widget.dart';
|
|
import '../bloc/membres_bloc.dart';
|
|
import '../bloc/membres_event.dart';
|
|
import '../bloc/membres_state.dart';
|
|
|
|
/// Page de modification d'un membre existant
|
|
class MembreEditPage extends StatefulWidget {
|
|
const MembreEditPage({
|
|
super.key,
|
|
required this.membre,
|
|
});
|
|
|
|
final MembreModel membre;
|
|
|
|
@override
|
|
State<MembreEditPage> createState() => _MembreEditPageState();
|
|
}
|
|
|
|
class _MembreEditPageState extends State<MembreEditPage>
|
|
with SingleTickerProviderStateMixin, PermissionMixin {
|
|
late MembresBloc _membresBloc;
|
|
late TabController _tabController;
|
|
final _formKey = GlobalKey<FormState>();
|
|
|
|
// Controllers pour les champs du formulaire
|
|
final _nomController = TextEditingController();
|
|
final _prenomController = TextEditingController();
|
|
final _emailController = TextEditingController();
|
|
final _telephoneController = TextEditingController();
|
|
final _adresseController = TextEditingController();
|
|
final _villeController = TextEditingController();
|
|
final _codePostalController = TextEditingController();
|
|
final _paysController = TextEditingController();
|
|
final _professionController = TextEditingController();
|
|
final _numeroMembreController = TextEditingController();
|
|
|
|
// Variables d'état
|
|
DateTime? _dateNaissance;
|
|
DateTime _dateAdhesion = DateTime.now();
|
|
bool _actif = true;
|
|
bool _isLoading = false;
|
|
int _currentStep = 0;
|
|
bool _hasChanges = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
|
|
// Vérification des permissions d'accès
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!permissionService.canEditMembers) {
|
|
showPermissionError(context, 'Vous n\'avez pas les permissions pour modifier les membres');
|
|
Navigator.of(context).pop();
|
|
return;
|
|
}
|
|
});
|
|
|
|
_membresBloc = getIt<MembresBloc>();
|
|
_tabController = TabController(length: 3, vsync: this);
|
|
|
|
// Pré-remplir les champs avec les données existantes
|
|
_populateFields();
|
|
|
|
// Écouter les changements pour détecter les modifications
|
|
_setupChangeListeners();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_tabController.dispose();
|
|
_nomController.dispose();
|
|
_prenomController.dispose();
|
|
_emailController.dispose();
|
|
_telephoneController.dispose();
|
|
_adresseController.dispose();
|
|
_villeController.dispose();
|
|
_codePostalController.dispose();
|
|
_paysController.dispose();
|
|
_professionController.dispose();
|
|
_numeroMembreController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _populateFields() {
|
|
_numeroMembreController.text = widget.membre.numeroMembre;
|
|
_nomController.text = widget.membre.nom;
|
|
_prenomController.text = widget.membre.prenom;
|
|
_emailController.text = widget.membre.email;
|
|
_telephoneController.text = widget.membre.telephone;
|
|
_adresseController.text = widget.membre.adresse ?? '';
|
|
_villeController.text = widget.membre.ville ?? '';
|
|
_codePostalController.text = widget.membre.codePostal ?? '';
|
|
_paysController.text = widget.membre.pays ?? 'Côte d\'Ivoire';
|
|
_professionController.text = widget.membre.profession ?? '';
|
|
|
|
_dateNaissance = widget.membre.dateNaissance;
|
|
_dateAdhesion = widget.membre.dateAdhesion;
|
|
_actif = widget.membre.actif;
|
|
}
|
|
|
|
void _setupChangeListeners() {
|
|
final controllers = [
|
|
_nomController, _prenomController, _emailController, _telephoneController,
|
|
_adresseController, _villeController, _codePostalController,
|
|
_paysController, _professionController
|
|
];
|
|
|
|
for (final controller in controllers) {
|
|
controller.addListener(_onFieldChanged);
|
|
}
|
|
}
|
|
|
|
void _onFieldChanged() {
|
|
if (!_hasChanges) {
|
|
setState(() {
|
|
_hasChanges = true;
|
|
});
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return BlocProvider.value(
|
|
value: _membresBloc,
|
|
child: WillPopScope(
|
|
onWillPop: _onWillPop,
|
|
child: Scaffold(
|
|
backgroundColor: AppTheme.backgroundLight,
|
|
appBar: _buildAppBar(),
|
|
body: BlocConsumer<MembresBloc, MembresState>(
|
|
listener: (context, state) {
|
|
if (state is MembreUpdated) {
|
|
setState(() {
|
|
_isLoading = false;
|
|
_hasChanges = false;
|
|
});
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text('Membre modifié avec succès !'),
|
|
backgroundColor: AppTheme.successColor,
|
|
),
|
|
);
|
|
|
|
Navigator.of(context).pop(true); // Retourner true pour indiquer le succès
|
|
} else if (state is MembresError) {
|
|
setState(() {
|
|
_isLoading = false;
|
|
});
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(state.message),
|
|
backgroundColor: AppTheme.errorColor,
|
|
),
|
|
);
|
|
}
|
|
},
|
|
builder: (context, state) {
|
|
return Column(
|
|
children: [
|
|
_buildProgressIndicator(),
|
|
Expanded(
|
|
child: _buildFormContent(),
|
|
),
|
|
_buildBottomActions(),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
PreferredSizeWidget _buildAppBar() {
|
|
return AppBar(
|
|
backgroundColor: AppTheme.primaryColor,
|
|
foregroundColor: Colors.white,
|
|
title: Text(
|
|
'Modifier ${widget.membre.prenom} ${widget.membre.nom}',
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.w600,
|
|
fontSize: 18,
|
|
),
|
|
),
|
|
actions: [
|
|
if (_hasChanges)
|
|
PermissionIconButton(
|
|
permission: () => permissionService.canEditMembers,
|
|
icon: const Icon(Icons.save),
|
|
onPressed: _submitForm,
|
|
tooltip: 'Sauvegarder',
|
|
disabledMessage: 'Vous n\'avez pas les permissions pour modifier ce membre',
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.help_outline),
|
|
onPressed: _showHelp,
|
|
tooltip: 'Aide',
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildProgressIndicator() {
|
|
return Container(
|
|
padding: const EdgeInsets.all(16),
|
|
color: Colors.white,
|
|
child: Column(
|
|
children: [
|
|
Row(
|
|
children: [
|
|
_buildStepIndicator(0, 'Informations\npersonnelles', Icons.person),
|
|
_buildStepConnector(0),
|
|
_buildStepIndicator(1, 'Contact &\nAdresse', Icons.contact_mail),
|
|
_buildStepConnector(1),
|
|
_buildStepIndicator(2, 'Finalisation', Icons.check_circle),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
LinearProgressIndicator(
|
|
value: (_currentStep + 1) / 3,
|
|
backgroundColor: AppTheme.backgroundLight,
|
|
valueColor: const AlwaysStoppedAnimation<Color>(AppTheme.primaryColor),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildStepIndicator(int step, String label, IconData icon) {
|
|
final isActive = step == _currentStep;
|
|
final isCompleted = step < _currentStep;
|
|
|
|
Color color;
|
|
if (isCompleted) {
|
|
color = AppTheme.successColor;
|
|
} else if (isActive) {
|
|
color = AppTheme.primaryColor;
|
|
} else {
|
|
color = AppTheme.textHint;
|
|
}
|
|
|
|
return Expanded(
|
|
child: Column(
|
|
children: [
|
|
Container(
|
|
width: 40,
|
|
height: 40,
|
|
decoration: BoxDecoration(
|
|
color: isCompleted ? AppTheme.successColor :
|
|
isActive ? AppTheme.primaryColor : AppTheme.backgroundLight,
|
|
shape: BoxShape.circle,
|
|
border: Border.all(color: color, width: 2),
|
|
),
|
|
child: Icon(
|
|
isCompleted ? Icons.check : icon,
|
|
color: isCompleted || isActive ? Colors.white : color,
|
|
size: 20,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
label,
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
fontSize: 10,
|
|
color: color,
|
|
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildStepConnector(int step) {
|
|
final isCompleted = step < _currentStep;
|
|
return Expanded(
|
|
child: Container(
|
|
height: 2,
|
|
margin: const EdgeInsets.only(bottom: 32),
|
|
color: isCompleted ? AppTheme.successColor : AppTheme.backgroundLight,
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildFormContent() {
|
|
return Form(
|
|
key: _formKey,
|
|
child: PageView(
|
|
controller: PageController(initialPage: _currentStep),
|
|
onPageChanged: (index) {
|
|
setState(() {
|
|
_currentStep = index;
|
|
});
|
|
},
|
|
children: [
|
|
_buildPersonalInfoStep(),
|
|
_buildContactStep(),
|
|
_buildFinalizationStep(),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildContactStep() {
|
|
return SingleChildScrollView(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_buildSectionHeader(
|
|
'Contact & Adresse',
|
|
'Modifiez les informations de contact et adresse',
|
|
),
|
|
const SizedBox(height: 24),
|
|
|
|
// Email
|
|
CustomTextField(
|
|
controller: _emailController,
|
|
label: 'Email *',
|
|
hintText: 'exemple@email.com',
|
|
prefixIcon: Icons.email_outlined,
|
|
keyboardType: TextInputType.emailAddress,
|
|
textInputAction: TextInputAction.next,
|
|
validator: (value) {
|
|
if (value == null || value.trim().isEmpty) {
|
|
return 'L\'email est requis';
|
|
}
|
|
if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(value)) {
|
|
return 'Format d\'email invalide';
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// Téléphone
|
|
CustomTextField(
|
|
controller: _telephoneController,
|
|
label: 'Téléphone *',
|
|
hintText: '+225 XX XX XX XX XX',
|
|
prefixIcon: Icons.phone_outlined,
|
|
keyboardType: TextInputType.phone,
|
|
textInputAction: TextInputAction.next,
|
|
inputFormatters: [
|
|
FilteringTextInputFormatter.allow(RegExp(r'[0-9+\-\s\(\)]')),
|
|
],
|
|
validator: (value) {
|
|
if (value == null || value.trim().isEmpty) {
|
|
return 'Le téléphone est requis';
|
|
}
|
|
if (value.trim().length < 8) {
|
|
return 'Numéro de téléphone invalide';
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
const SizedBox(height: 24),
|
|
|
|
// Section Adresse
|
|
const Text(
|
|
'Adresse',
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w600,
|
|
color: AppTheme.textPrimary,
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// Adresse
|
|
CustomTextField(
|
|
controller: _adresseController,
|
|
label: 'Adresse',
|
|
hintText: 'Rue, quartier, etc.',
|
|
prefixIcon: Icons.location_on_outlined,
|
|
textInputAction: TextInputAction.next,
|
|
maxLines: 2,
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// Ville et Code postal
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
flex: 2,
|
|
child: CustomTextField(
|
|
controller: _villeController,
|
|
label: 'Ville',
|
|
hintText: 'Abidjan',
|
|
prefixIcon: Icons.location_city_outlined,
|
|
textInputAction: TextInputAction.next,
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: CustomTextField(
|
|
controller: _codePostalController,
|
|
label: 'Code postal',
|
|
hintText: '00225',
|
|
prefixIcon: Icons.markunread_mailbox_outlined,
|
|
keyboardType: TextInputType.number,
|
|
textInputAction: TextInputAction.next,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// Pays
|
|
CustomTextField(
|
|
controller: _paysController,
|
|
label: 'Pays',
|
|
prefixIcon: Icons.flag_outlined,
|
|
textInputAction: TextInputAction.done,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildFinalizationStep() {
|
|
return SingleChildScrollView(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_buildSectionHeader(
|
|
'Finalisation',
|
|
'Vérifiez les modifications et finalisez',
|
|
),
|
|
const SizedBox(height: 24),
|
|
|
|
// Résumé des modifications
|
|
_buildChangesCard(),
|
|
const SizedBox(height: 24),
|
|
|
|
// Date d'adhésion
|
|
_buildDateField(
|
|
label: 'Date d\'adhésion',
|
|
value: _dateAdhesion,
|
|
onTap: _selectDateAdhesion,
|
|
icon: Icons.calendar_today_outlined,
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// Statut actif
|
|
Card(
|
|
elevation: 1,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: SwitchListTile(
|
|
title: const Text('Membre actif'),
|
|
subtitle: Text(
|
|
_actif
|
|
? 'Le membre peut accéder aux services'
|
|
: 'Le membre est désactivé',
|
|
),
|
|
value: _actif,
|
|
onChanged: (value) {
|
|
setState(() {
|
|
_actif = value;
|
|
_hasChanges = true;
|
|
});
|
|
},
|
|
activeColor: AppTheme.primaryColor,
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// Informations de version
|
|
_buildVersionInfo(),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildSectionHeader(String title, String subtitle) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
title,
|
|
style: const TextStyle(
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.bold,
|
|
color: AppTheme.textPrimary,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
subtitle,
|
|
style: const TextStyle(
|
|
fontSize: 14,
|
|
color: AppTheme.textSecondary,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildDateField({
|
|
required String label,
|
|
required DateTime? value,
|
|
required VoidCallback onTap,
|
|
required IconData icon,
|
|
}) {
|
|
return InkWell(
|
|
onTap: onTap,
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
|
decoration: BoxDecoration(
|
|
border: Border.all(color: AppTheme.borderColor),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(icon, color: AppTheme.textSecondary),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
label,
|
|
style: const TextStyle(
|
|
fontSize: 12,
|
|
color: AppTheme.textSecondary,
|
|
),
|
|
),
|
|
Text(
|
|
value != null
|
|
? DateFormat('dd/MM/yyyy').format(value)
|
|
: 'Sélectionner une date',
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
color: value != null
|
|
? AppTheme.textPrimary
|
|
: AppTheme.textHint,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const Icon(Icons.edit, color: AppTheme.textSecondary),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildPersonalInfoStep() {
|
|
return SingleChildScrollView(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_buildSectionHeader(
|
|
'Informations personnelles',
|
|
'Modifiez les informations de base du membre',
|
|
),
|
|
const SizedBox(height: 24),
|
|
|
|
// Numéro de membre (non modifiable)
|
|
CustomTextField(
|
|
controller: _numeroMembreController,
|
|
label: 'Numéro de membre',
|
|
prefixIcon: Icons.badge,
|
|
enabled: false,
|
|
validator: (value) {
|
|
if (value == null || value.isEmpty) {
|
|
return 'Le numéro de membre est requis';
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// Nom et Prénom
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: CustomTextField(
|
|
controller: _prenomController,
|
|
label: 'Prénom *',
|
|
hintText: 'Jean',
|
|
prefixIcon: Icons.person_outline,
|
|
textInputAction: TextInputAction.next,
|
|
validator: (value) {
|
|
if (value == null || value.trim().isEmpty) {
|
|
return 'Le prénom est requis';
|
|
}
|
|
if (value.trim().length < 2) {
|
|
return 'Le prénom doit contenir au moins 2 caractères';
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: CustomTextField(
|
|
controller: _nomController,
|
|
label: 'Nom *',
|
|
hintText: 'Dupont',
|
|
prefixIcon: Icons.person_outline,
|
|
textInputAction: TextInputAction.next,
|
|
validator: (value) {
|
|
if (value == null || value.trim().isEmpty) {
|
|
return 'Le nom est requis';
|
|
}
|
|
if (value.trim().length < 2) {
|
|
return 'Le nom doit contenir au moins 2 caractères';
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// Date de naissance
|
|
_buildDateField(
|
|
label: 'Date de naissance',
|
|
value: _dateNaissance,
|
|
onTap: _selectDateNaissance,
|
|
icon: Icons.cake_outlined,
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// Profession
|
|
CustomTextField(
|
|
controller: _professionController,
|
|
label: 'Profession',
|
|
hintText: 'Enseignant, Commerçant, etc.',
|
|
prefixIcon: Icons.work_outline,
|
|
textInputAction: TextInputAction.next,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildChangesCard() {
|
|
if (!_hasChanges) {
|
|
return Card(
|
|
elevation: 1,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: const Padding(
|
|
padding: EdgeInsets.all(16),
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.info_outline, color: AppTheme.textSecondary),
|
|
SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
'Aucune modification détectée',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
color: AppTheme.textSecondary,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
return Card(
|
|
elevation: 2,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Row(
|
|
children: [
|
|
Icon(Icons.edit, color: AppTheme.warningColor),
|
|
SizedBox(width: 8),
|
|
Text(
|
|
'Modifications détectées',
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w600,
|
|
color: AppTheme.textPrimary,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
_buildSummaryRow('Nom complet', '${_prenomController.text} ${_nomController.text}'),
|
|
_buildSummaryRow('Email', _emailController.text),
|
|
_buildSummaryRow('Téléphone', _telephoneController.text),
|
|
if (_dateNaissance != null)
|
|
_buildSummaryRow('Date de naissance', DateFormat('dd/MM/yyyy').format(_dateNaissance!)),
|
|
if (_professionController.text.isNotEmpty)
|
|
_buildSummaryRow('Profession', _professionController.text),
|
|
if (_adresseController.text.isNotEmpty)
|
|
_buildSummaryRow('Adresse', _adresseController.text),
|
|
_buildSummaryRow('Statut', _actif ? 'Actif' : 'Inactif'),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildSummaryRow(String label, String value) {
|
|
if (value.trim().isEmpty) return const SizedBox.shrink();
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 8),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
SizedBox(
|
|
width: 120,
|
|
child: Text(
|
|
label,
|
|
style: const TextStyle(
|
|
fontSize: 12,
|
|
color: AppTheme.textSecondary,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
),
|
|
Expanded(
|
|
child: Text(
|
|
value,
|
|
style: const TextStyle(
|
|
fontSize: 14,
|
|
color: AppTheme.textPrimary,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildVersionInfo() {
|
|
return Card(
|
|
elevation: 1,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Row(
|
|
children: [
|
|
Icon(Icons.info_outline, color: AppTheme.textSecondary),
|
|
SizedBox(width: 8),
|
|
Text(
|
|
'Informations de version',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w600,
|
|
color: AppTheme.textPrimary,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
'Version actuelle : ${widget.membre.version}',
|
|
style: const TextStyle(
|
|
fontSize: 12,
|
|
color: AppTheme.textSecondary,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'Créé le : ${DateFormat('dd/MM/yyyy à HH:mm').format(widget.membre.dateCreation)}',
|
|
style: const TextStyle(
|
|
fontSize: 12,
|
|
color: AppTheme.textSecondary,
|
|
),
|
|
),
|
|
if (widget.membre.dateModification != null) ...[
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'Modifié le : ${DateFormat('dd/MM/yyyy à HH:mm').format(widget.membre.dateModification!)}',
|
|
style: const TextStyle(
|
|
fontSize: 12,
|
|
color: AppTheme.textSecondary,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildBottomActions() {
|
|
return Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: const BoxDecoration(
|
|
color: Colors.white,
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black12,
|
|
blurRadius: 4,
|
|
offset: Offset(0, -2),
|
|
),
|
|
],
|
|
),
|
|
child: Row(
|
|
children: [
|
|
if (_currentStep > 0)
|
|
Expanded(
|
|
child: OutlinedButton(
|
|
onPressed: _previousStep,
|
|
style: OutlinedButton.styleFrom(
|
|
foregroundColor: AppTheme.primaryColor,
|
|
side: const BorderSide(color: AppTheme.primaryColor),
|
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
),
|
|
child: const Text('Précédent'),
|
|
),
|
|
),
|
|
if (_currentStep > 0) const SizedBox(width: 16),
|
|
Expanded(
|
|
flex: _currentStep == 0 ? 1 : 1,
|
|
child: ElevatedButton(
|
|
onPressed: _isLoading ? null : _handleNextOrSubmit,
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: _hasChanges ? AppTheme.primaryColor : AppTheme.textHint,
|
|
foregroundColor: Colors.white,
|
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
),
|
|
child: _isLoading
|
|
? const SizedBox(
|
|
width: 20,
|
|
height: 20,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
|
),
|
|
)
|
|
: Text(_currentStep == 2 ? 'Sauvegarder' : 'Suivant'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _previousStep() {
|
|
if (_currentStep > 0) {
|
|
setState(() {
|
|
_currentStep--;
|
|
});
|
|
}
|
|
}
|
|
|
|
void _handleNextOrSubmit() {
|
|
if (_currentStep < 2) {
|
|
if (_validateCurrentStep()) {
|
|
setState(() {
|
|
_currentStep++;
|
|
});
|
|
}
|
|
} else {
|
|
_submitForm();
|
|
}
|
|
}
|
|
|
|
bool _validateCurrentStep() {
|
|
switch (_currentStep) {
|
|
case 0:
|
|
return _validatePersonalInfo();
|
|
case 1:
|
|
return _validateContactInfo();
|
|
case 2:
|
|
return true; // Pas de validation spécifique pour la finalisation
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
bool _validatePersonalInfo() {
|
|
bool isValid = true;
|
|
|
|
if (_prenomController.text.trim().isEmpty) {
|
|
_showFieldError('Le prénom est requis');
|
|
isValid = false;
|
|
}
|
|
|
|
if (_nomController.text.trim().isEmpty) {
|
|
_showFieldError('Le nom est requis');
|
|
isValid = false;
|
|
}
|
|
|
|
return isValid;
|
|
}
|
|
|
|
bool _validateContactInfo() {
|
|
bool isValid = true;
|
|
|
|
if (_emailController.text.trim().isEmpty) {
|
|
_showFieldError('L\'email est requis');
|
|
isValid = false;
|
|
} else if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(_emailController.text)) {
|
|
_showFieldError('Format d\'email invalide');
|
|
isValid = false;
|
|
}
|
|
|
|
if (_telephoneController.text.trim().isEmpty) {
|
|
_showFieldError('Le téléphone est requis');
|
|
isValid = false;
|
|
}
|
|
|
|
return isValid;
|
|
}
|
|
|
|
void _showFieldError(String message) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(message),
|
|
backgroundColor: AppTheme.errorColor,
|
|
duration: const Duration(seconds: 2),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _submitForm() {
|
|
// Vérification des permissions
|
|
if (!permissionService.canEditMembers) {
|
|
showPermissionError(context, 'Vous n\'avez pas les permissions pour modifier ce membre');
|
|
return;
|
|
}
|
|
|
|
if (!_formKey.currentState!.validate()) {
|
|
return;
|
|
}
|
|
|
|
if (!_hasChanges) {
|
|
_showFieldError('Aucune modification à sauvegarder');
|
|
return;
|
|
}
|
|
|
|
// Log de l'action pour audit
|
|
permissionService.logAction('Modification membre', details: {
|
|
'membreId': widget.membre.id,
|
|
'nom': '${widget.membre.prenom} ${widget.membre.nom}',
|
|
});
|
|
|
|
setState(() {
|
|
_isLoading = true;
|
|
});
|
|
|
|
// Créer le modèle membre modifié
|
|
final membreModifie = widget.membre.copyWith(
|
|
nom: _nomController.text.trim(),
|
|
prenom: _prenomController.text.trim(),
|
|
email: _emailController.text.trim(),
|
|
telephone: _telephoneController.text.trim(),
|
|
dateNaissance: _dateNaissance,
|
|
adresse: _adresseController.text.trim().isNotEmpty ? _adresseController.text.trim() : null,
|
|
ville: _villeController.text.trim().isNotEmpty ? _villeController.text.trim() : null,
|
|
codePostal: _codePostalController.text.trim().isNotEmpty ? _codePostalController.text.trim() : null,
|
|
pays: _paysController.text.trim().isNotEmpty ? _paysController.text.trim() : null,
|
|
profession: _professionController.text.trim().isNotEmpty ? _professionController.text.trim() : null,
|
|
dateAdhesion: _dateAdhesion,
|
|
actif: _actif,
|
|
version: widget.membre.version + 1,
|
|
dateModification: DateTime.now(),
|
|
);
|
|
|
|
// Envoyer l'événement de modification
|
|
final memberId = widget.membre.id;
|
|
if (memberId != null && memberId.isNotEmpty) {
|
|
_membresBloc.add(UpdateMembre(memberId, membreModifie));
|
|
} else {
|
|
_showFieldError('Erreur : ID du membre manquant');
|
|
setState(() {
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _selectDateNaissance() async {
|
|
final date = await showDatePicker(
|
|
context: context,
|
|
initialDate: _dateNaissance ?? DateTime.now().subtract(const Duration(days: 365 * 25)),
|
|
firstDate: DateTime(1900),
|
|
lastDate: DateTime.now(),
|
|
locale: const Locale('fr', 'FR'),
|
|
);
|
|
|
|
if (date != null && date != _dateNaissance) {
|
|
setState(() {
|
|
_dateNaissance = date;
|
|
_hasChanges = true;
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _selectDateAdhesion() async {
|
|
final date = await showDatePicker(
|
|
context: context,
|
|
initialDate: _dateAdhesion,
|
|
firstDate: DateTime(2000),
|
|
lastDate: DateTime.now().add(const Duration(days: 365)),
|
|
locale: const Locale('fr', 'FR'),
|
|
);
|
|
|
|
if (date != null && date != _dateAdhesion) {
|
|
setState(() {
|
|
_dateAdhesion = date;
|
|
_hasChanges = true;
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<bool> _onWillPop() async {
|
|
if (!_hasChanges) {
|
|
return true;
|
|
}
|
|
|
|
final result = await showDialog<bool>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: const Text('Modifications non sauvegardées'),
|
|
content: const Text(
|
|
'Vous avez des modifications non sauvegardées. '
|
|
'Voulez-vous vraiment quitter sans sauvegarder ?',
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(false),
|
|
child: const Text('Annuler'),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(true),
|
|
style: TextButton.styleFrom(
|
|
foregroundColor: AppTheme.errorColor,
|
|
),
|
|
child: const Text('Quitter sans sauvegarder'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
return result ?? false;
|
|
}
|
|
|
|
void _showHelp() {
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: const Text('Aide - Modification de membre'),
|
|
content: const SingleChildScrollView(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
'Modification en 3 étapes :',
|
|
style: TextStyle(fontWeight: FontWeight.bold),
|
|
),
|
|
SizedBox(height: 8),
|
|
Text('1. Informations personnelles : Nom, prénom, date de naissance'),
|
|
Text('2. Contact & Adresse : Email, téléphone, adresse'),
|
|
Text('3. Finalisation : Vérification et sauvegarde'),
|
|
SizedBox(height: 16),
|
|
Text(
|
|
'Fonctionnalités :',
|
|
style: TextStyle(fontWeight: FontWeight.bold),
|
|
),
|
|
SizedBox(height: 8),
|
|
Text('• Détection automatique des modifications'),
|
|
Text('• Validation en temps réel'),
|
|
Text('• Confirmation avant sortie si modifications non sauvées'),
|
|
Text('• Gestion de version automatique'),
|
|
SizedBox(height: 16),
|
|
Text(
|
|
'Le numéro de membre ne peut pas être modifié pour des raisons de traçabilité.',
|
|
style: TextStyle(fontSize: 12, color: Colors.grey),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
child: const Text('Fermer'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|