feat: alignement master sur develop (squash)
- Dossiers unifiés #119, pending-families enrichi, validation admin (wizards) - Front: modèles dossier_unifie / pending_family, NIR, auth - Migrations dossier_famille, scripts de test API - Résolution conflits: parents.*, docs tickets, auth_service, nir_utils Made-with: Cursor
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:p_tits_pas/models/user.dart';
|
||||
import 'package:p_tits_pas/utils/phone_utils.dart';
|
||||
import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart';
|
||||
import 'package:p_tits_pas/services/auth_service.dart';
|
||||
import 'package:p_tits_pas/services/user_service.dart';
|
||||
@@ -60,18 +61,18 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
|
||||
if (!mounted) return;
|
||||
if (cached != null) {
|
||||
setState(() {
|
||||
_currentUserRole = cached.role.toLowerCase();
|
||||
_currentUserRole = (cached.role).toLowerCase();
|
||||
});
|
||||
return;
|
||||
}
|
||||
final refreshed = await AuthService.refreshCurrentUser();
|
||||
if (!mounted || refreshed == null) return;
|
||||
setState(() {
|
||||
_currentUserRole = refreshed.role.toLowerCase();
|
||||
_currentUserRole = (refreshed.role).toLowerCase();
|
||||
});
|
||||
}
|
||||
|
||||
bool _isSuperAdmin(AppUser user) => user.role.toLowerCase() == 'super_admin';
|
||||
bool _isSuperAdmin(AppUser user) => (user.role).toLowerCase() == 'super_admin';
|
||||
|
||||
bool _canEditAdmin(AppUser target) {
|
||||
if (!_isSuperAdmin(target)) return true;
|
||||
@@ -123,7 +124,7 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
|
||||
: Icons.manage_accounts_outlined,
|
||||
subtitleLines: [
|
||||
user.email,
|
||||
'Téléphone : ${user.telephone?.trim().isNotEmpty == true ? user.telephone : 'Non renseigné'}',
|
||||
'Téléphone : ${user.telephone?.trim().isNotEmpty == true ? formatPhoneForDisplay(user.telephone!) : 'Non renseigné'}',
|
||||
],
|
||||
avatarUrl: user.photoUrl,
|
||||
borderColor: isSuperAdmin
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
|
||||
import 'package:p_tits_pas/utils/phone_utils.dart';
|
||||
import 'package:p_tits_pas/services/user_service.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
|
||||
@@ -126,7 +127,7 @@ class _AssistanteMaternelleManagementWidgetState
|
||||
),
|
||||
AdminDetailField(
|
||||
label: 'Telephone',
|
||||
value: _v(assistante.user.telephone),
|
||||
value: _v(assistante.user.telephone) != '–' ? formatPhoneForDisplay(_v(assistante.user.telephone)) : '–',
|
||||
),
|
||||
AdminDetailField(label: 'Adresse', value: _v(assistante.user.adresse)),
|
||||
AdminDetailField(label: 'Ville', value: _v(assistante.user.ville)),
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'admin_detail_modal.dart';
|
||||
|
||||
/// Bloc type formulaire (titre de section + champs read-only) pour les modales de validation.
|
||||
/// [rowLayout] : même disposition que la création de compte, ex. [2, 2, 1, 2] = ligne de 2, ligne de 2, plein largeur, ligne de 2.
|
||||
/// [rowFlex] : flex par index de ligne (optionnel). Ex. {3: [2, 5]} = 4e ligne : code postal étroit (2), ville large (5).
|
||||
class ValidationDetailSection extends StatelessWidget {
|
||||
final String title;
|
||||
final List<AdminDetailField> fields;
|
||||
|
||||
/// Nombre de champs par ligne (1 = plein largeur, 2 = deux côte à côte). Ex. [2, 2, 1, 2] pour identité.
|
||||
final List<int>? rowLayout;
|
||||
|
||||
/// Flex par ligne (index de ligne -> [flex1, flex2, ...]). Ex. {3: [2, 5]} pour Code postal | Ville.
|
||||
final Map<int, List<int>>? rowFlex;
|
||||
|
||||
const ValidationDetailSection({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.fields,
|
||||
this.rowLayout,
|
||||
this.rowFlex,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final layout = rowLayout ?? List.filled(fields.length, 1);
|
||||
int index = 0;
|
||||
int rowIndex = 0;
|
||||
final rows = <Widget>[];
|
||||
for (final count in layout) {
|
||||
if (index >= fields.length) break;
|
||||
final rowFields = fields.skip(index).take(count).toList();
|
||||
index += count;
|
||||
if (rowFields.isEmpty) continue;
|
||||
final flexForRow = rowFlex?[rowIndex];
|
||||
rowIndex++;
|
||||
if (count == 1) {
|
||||
rows.add(Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: _buildFieldCell(rowFields.first),
|
||||
));
|
||||
} else {
|
||||
rows.add(Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (int i = 0; i < rowFields.length; i++) ...[
|
||||
if (i > 0) const SizedBox(width: 16),
|
||||
Expanded(
|
||||
flex: (flexForRow != null && i < flexForRow.length)
|
||||
? flexForRow[i]
|
||||
: 1,
|
||||
child: _buildFieldCell(rowFields[i]),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
...rows,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFieldCell(AdminDetailField field) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
field.label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.grey.shade700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
ValidationReadOnlyField(value: field.value),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Champ texte en lecture seule, style formulaire (fond gris léger, bordure). Réutilisable en éditable plus tard.
|
||||
class ValidationReadOnlyField extends StatelessWidget {
|
||||
final String value;
|
||||
final int? maxLines;
|
||||
|
||||
const ValidationReadOnlyField({
|
||||
super.key,
|
||||
required this.value,
|
||||
this.maxLines = 1,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade50,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
),
|
||||
child: Text(
|
||||
value,
|
||||
style: const TextStyle(color: Colors.black87, fontSize: 14),
|
||||
maxLines: maxLines,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Sous-barre : Gestionnaires | Parents | Assistantes maternelles | [Administrateurs].
|
||||
/// [subTabCount] = 3 pour masquer l'onglet Administrateurs (dashboard gestionnaire).
|
||||
/// Sous-barre : [À valider] | Gestionnaires | Parents | Assistantes maternelles | [Administrateurs].
|
||||
/// [tabLabels] : liste des libellés d'onglets (ex. avec « À valider » en premier si dossiers en attente).
|
||||
/// [subTabCount] = 3 pour masquer Administrateurs (dashboard gestionnaire).
|
||||
class DashboardUserManagementSubBar extends StatelessWidget {
|
||||
final int selectedSubIndex;
|
||||
final ValueChanged<int> onSubTabChange;
|
||||
@@ -11,8 +12,10 @@ class DashboardUserManagementSubBar extends StatelessWidget {
|
||||
final VoidCallback? onAddPressed;
|
||||
final String addLabel;
|
||||
final int subTabCount;
|
||||
/// Si non null, utilisé à la place des labels par défaut (ex. ['À valider', 'Parents', ...]).
|
||||
final List<String>? tabLabels;
|
||||
|
||||
static const List<String> _tabLabels = [
|
||||
static const List<String> _defaultTabLabels = [
|
||||
'Gestionnaires',
|
||||
'Parents',
|
||||
'Assistantes maternelles',
|
||||
@@ -29,10 +32,14 @@ class DashboardUserManagementSubBar extends StatelessWidget {
|
||||
this.onAddPressed,
|
||||
this.addLabel = '+ Ajouter',
|
||||
this.subTabCount = 4,
|
||||
this.tabLabels,
|
||||
}) : super(key: key);
|
||||
|
||||
List<String> get _labels => tabLabels ?? _defaultTabLabels.sublist(0, subTabCount.clamp(1, _defaultTabLabels.length));
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final labels = _labels;
|
||||
return Container(
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
@@ -42,9 +49,9 @@ class DashboardUserManagementSubBar extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
for (int i = 0; i < subTabCount; i++) ...[
|
||||
for (int i = 0; i < labels.length; i++) ...[
|
||||
if (i > 0) const SizedBox(width: 12),
|
||||
_buildSubNavItem(context, _tabLabels[i], i),
|
||||
_buildSubNavItem(context, labels[i], i),
|
||||
],
|
||||
const SizedBox(width: 36),
|
||||
_pillField(
|
||||
@@ -68,7 +75,7 @@ class DashboardUserManagementSubBar extends StatelessWidget {
|
||||
_pillField(width: 150, child: filterControl!),
|
||||
],
|
||||
const Spacer(),
|
||||
_buildAddButton(),
|
||||
if (onAddPressed != null) _buildAddButton(),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:p_tits_pas/models/parent_model.dart';
|
||||
import 'package:p_tits_pas/utils/phone_utils.dart';
|
||||
import 'package:p_tits_pas/services/user_service.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
|
||||
@@ -122,7 +123,7 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
|
||||
),
|
||||
AdminDetailField(
|
||||
label: 'Telephone',
|
||||
value: _v(parent.user.telephone),
|
||||
value: _v(parent.user.telephone) != '–' ? formatPhoneForDisplay(_v(parent.user.telephone)) : '–',
|
||||
),
|
||||
AdminDetailField(label: 'Adresse', value: _v(parent.user.adresse)),
|
||||
AdminDetailField(label: 'Ville', value: _v(parent.user.ville)),
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:p_tits_pas/models/user.dart';
|
||||
import 'package:p_tits_pas/models/pending_family.dart';
|
||||
import 'package:p_tits_pas/services/user_service.dart';
|
||||
import 'package:p_tits_pas/utils/phone_utils.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/validation_dossier_modal.dart';
|
||||
|
||||
/// Onglet « À valider » : deux listes (AM en attente, familles en attente). Ticket #107.
|
||||
class PendingValidationWidget extends StatefulWidget {
|
||||
final VoidCallback? onRefresh;
|
||||
|
||||
const PendingValidationWidget({super.key, this.onRefresh});
|
||||
|
||||
@override
|
||||
State<PendingValidationWidget> createState() => _PendingValidationWidgetState();
|
||||
}
|
||||
|
||||
class _PendingValidationWidgetState extends State<PendingValidationWidget> {
|
||||
bool _isLoading = true;
|
||||
String? _error;
|
||||
List<AppUser> _pendingAM = [];
|
||||
List<PendingFamily> _pendingFamilies = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final am = await UserService.getPendingUsers(role: 'assistante_maternelle');
|
||||
final families = await UserService.getPendingFamilies();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_pendingAM = am;
|
||||
_pendingFamilies = families;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = e is Exception ? e.toString().replaceFirst('Exception: ', '') : 'Erreur inconnue';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _onOpenValidation({String? type, String? id, String? numeroDossier}) {
|
||||
final num = numeroDossier?.trim();
|
||||
if (num == null || num.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Numéro de dossier manquant.')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => ValidationDossierModal(
|
||||
numeroDossier: num,
|
||||
onClose: () => Navigator.of(context).pop(),
|
||||
onSuccess: () {
|
||||
Navigator.of(context).pop();
|
||||
_load();
|
||||
widget.onRefresh?.call();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (_error != null && _error!.isNotEmpty) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(_error!, style: const TextStyle(color: Colors.red)),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _load,
|
||||
child: const Text('Réessayer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final hasAM = _pendingAM.isNotEmpty;
|
||||
final hasFamilies = _pendingFamilies.isNotEmpty;
|
||||
if (!hasAM && !hasFamilies) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.check_circle_outline, size: 64, color: Colors.grey.shade400),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Aucun dossier en attente de validation',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
await _load();
|
||||
widget.onRefresh?.call();
|
||||
},
|
||||
child: SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (hasAM) ...[
|
||||
_sectionTitle('Assistantes maternelles en attente'),
|
||||
const SizedBox(height: 8),
|
||||
..._pendingAM.map((u) => _buildAMCard(u)),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
if (hasFamilies) ...[
|
||||
_sectionTitle('Familles en attente'),
|
||||
const SizedBox(height: 8),
|
||||
..._pendingFamilies.map((f) => _buildFamilyCard(f)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _sectionTitle(String title) {
|
||||
return Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.black87,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Ligne commune : icône | titre (+ sous-titre) | bouton Ouvrir.
|
||||
/// [titleWidget] remplace [title] si les deux sont fournis : priorité à [titleWidget].
|
||||
Widget _buildPendingRow({
|
||||
required IconData icon,
|
||||
String? title,
|
||||
Widget? titleWidget,
|
||||
String? subtitle,
|
||||
TextStyle? subtitleStyle,
|
||||
required VoidCallback onOpen,
|
||||
}) {
|
||||
assert(title != null || titleWidget != null);
|
||||
final titleChild = titleWidget ??
|
||||
Text(
|
||||
title!,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 14,
|
||||
),
|
||||
);
|
||||
final subStyle = subtitleStyle ??
|
||||
TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey.shade600,
|
||||
);
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
side: BorderSide(color: Colors.grey.shade300),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(icon, color: Colors.grey.shade600, size: 28),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
titleChild,
|
||||
if (subtitle != null && subtitle.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
subtitle,
|
||||
style: subStyle,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
onPressed: onOpen,
|
||||
icon: const Icon(Icons.open_in_new, size: 18),
|
||||
label: const Text('Ouvrir'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Sous-titre AM : `email - date • tél. • CP ville` (plan affichage lignes À valider).
|
||||
String _amSubtitleLine(AppUser user) {
|
||||
final email = user.email.trim();
|
||||
final bits = <String>[];
|
||||
bits.add(DateFormat('dd/MM/yyyy').format(user.createdAt.toLocal()));
|
||||
final tel = user.telephone?.trim();
|
||||
if (tel != null && tel.isNotEmpty) {
|
||||
bits.add(formatPhoneForDisplay(tel));
|
||||
}
|
||||
final cp = user.codePostal?.trim();
|
||||
final ville = user.ville?.trim();
|
||||
final loc = [if (cp != null && cp.isNotEmpty) cp, if (ville != null && ville.isNotEmpty) ville]
|
||||
.join(' ')
|
||||
.trim();
|
||||
if (loc.isNotEmpty) bits.add(loc);
|
||||
final infos = bits.join(' • ');
|
||||
if (email.isEmpty) return infos;
|
||||
return '$email - $infos';
|
||||
}
|
||||
|
||||
Widget _buildAMCard(AppUser user) {
|
||||
final numDossier = user.numeroDossier ?? '–';
|
||||
final nameBold =
|
||||
user.fullName.isNotEmpty ? user.fullName : (user.email.isNotEmpty ? user.email : '–');
|
||||
return _buildPendingRow(
|
||||
icon: Icons.person_outline,
|
||||
titleWidget: Text.rich(
|
||||
TextSpan(
|
||||
style: const TextStyle(fontSize: 14, color: Colors.black87),
|
||||
children: [
|
||||
TextSpan(
|
||||
text: nameBold,
|
||||
style: const TextStyle(fontWeight: FontWeight.w700),
|
||||
),
|
||||
TextSpan(
|
||||
text: ' - $numDossier',
|
||||
style: const TextStyle(fontWeight: FontWeight.w400),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
subtitle: _amSubtitleLine(user),
|
||||
subtitleStyle: TextStyle(
|
||||
fontSize: 12,
|
||||
fontStyle: FontStyle.italic,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
onOpen: () => _onOpenValidation(
|
||||
type: 'AM',
|
||||
id: user.id,
|
||||
numeroDossier: user.numeroDossier,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// `email, tél., localisation` par parent, puis `date soumission`, puis `nb enfants`.
|
||||
String _familyParentSegment(PendingParentLine p) {
|
||||
final parts = <String>[];
|
||||
final e = p.email?.trim();
|
||||
if (e != null && e.isNotEmpty) parts.add(e);
|
||||
final t = p.telephone?.trim();
|
||||
if (t != null && t.isNotEmpty) parts.add(formatPhoneForDisplay(t));
|
||||
final cp = p.codePostal?.trim();
|
||||
final v = p.ville?.trim();
|
||||
final loc = [if (cp != null && cp.isNotEmpty) cp, if (v != null && v.isNotEmpty) v]
|
||||
.join(' ')
|
||||
.trim();
|
||||
if (loc.isNotEmpty) parts.add(loc);
|
||||
return parts.join(', ');
|
||||
}
|
||||
|
||||
String _familySubtitleLine(PendingFamily family) {
|
||||
final blocks = family.parentLines
|
||||
.map(_familyParentSegment)
|
||||
.where((s) => s.isNotEmpty)
|
||||
.join(' - ');
|
||||
|
||||
final tail = <String>[];
|
||||
final date = family.dateSoumission;
|
||||
if (date != null) {
|
||||
tail.add(DateFormat('dd/MM/yyyy').format(date.toLocal()));
|
||||
}
|
||||
if (family.nombreEnfants > 0) {
|
||||
tail.add(
|
||||
family.nombreEnfants > 1
|
||||
? '${family.nombreEnfants} enfants'
|
||||
: '1 enfant',
|
||||
);
|
||||
}
|
||||
final right = tail.join(' - ');
|
||||
|
||||
if (blocks.isEmpty && right.isEmpty) return '';
|
||||
if (blocks.isEmpty) return right;
|
||||
if (right.isEmpty) return blocks;
|
||||
return '$blocks - $right';
|
||||
}
|
||||
|
||||
Widget _buildFamilyCard(PendingFamily family) {
|
||||
final numDossier = family.numeroDossier ?? '–';
|
||||
final nameBold = family.libelle.isNotEmpty ? family.libelle : 'Famille';
|
||||
return _buildPendingRow(
|
||||
icon: Icons.family_restroom_outlined,
|
||||
titleWidget: Text.rich(
|
||||
TextSpan(
|
||||
style: const TextStyle(fontSize: 14, color: Colors.black87),
|
||||
children: [
|
||||
TextSpan(
|
||||
text: nameBold,
|
||||
style: const TextStyle(fontWeight: FontWeight.w700),
|
||||
),
|
||||
TextSpan(
|
||||
text: ' - $numDossier',
|
||||
style: const TextStyle(fontWeight: FontWeight.w400),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
subtitle: _familySubtitleLine(family),
|
||||
subtitleStyle: TextStyle(
|
||||
fontSize: 12,
|
||||
fontStyle: FontStyle.italic,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
onOpen: () => _onOpenValidation(
|
||||
type: 'famille',
|
||||
id: family.parentIds.isNotEmpty ? family.parentIds.first : null,
|
||||
numeroDossier: family.numeroDossier,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:p_tits_pas/models/relais_model.dart';
|
||||
import 'package:p_tits_pas/utils/phone_utils.dart';
|
||||
import 'package:p_tits_pas/services/relais_service.dart';
|
||||
|
||||
class RelaisManagementPanel extends StatefulWidget {
|
||||
@@ -723,7 +724,7 @@ class _RelaisFormDialogState extends State<_RelaisFormDialog> {
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(10),
|
||||
_FrenchPhoneNumberFormatter(),
|
||||
FrenchPhoneNumberFormatter(),
|
||||
],
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Ligne fixe',
|
||||
@@ -991,30 +992,6 @@ class _RelaisFormDialogState extends State<_RelaisFormDialog> {
|
||||
}
|
||||
}
|
||||
|
||||
class _FrenchPhoneNumberFormatter extends TextInputFormatter {
|
||||
@override
|
||||
TextEditingValue formatEditUpdate(
|
||||
TextEditingValue oldValue,
|
||||
TextEditingValue newValue,
|
||||
) {
|
||||
final digits = newValue.text.replaceAll(RegExp(r'\D'), '');
|
||||
final buffer = StringBuffer();
|
||||
|
||||
for (var i = 0; i < digits.length; i++) {
|
||||
if (i > 0 && i.isEven) {
|
||||
buffer.write(' ');
|
||||
}
|
||||
buffer.write(digits[i]);
|
||||
}
|
||||
|
||||
final formatted = buffer.toString();
|
||||
return TextEditingValue(
|
||||
text: formatted,
|
||||
selection: TextSelection.collapsed(offset: formatted.length),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RelaisAddressFields extends StatelessWidget {
|
||||
final TextEditingController streetController;
|
||||
final TextEditingController postalCodeController;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart';
|
||||
import 'package:p_tits_pas/services/user_service.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/admin_management_widget.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/assistante_maternelle_management_widget.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/gestionnaire_management_widget.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/parent_managmant_widget.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/pending_validation_widget.dart';
|
||||
|
||||
class UserManagementPanel extends StatefulWidget {
|
||||
/// Afficher l'onglet Administrateurs (sinon 3 onglets : Gestionnaires, Parents, AM).
|
||||
@@ -26,12 +28,41 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
final TextEditingController _amCapacityController = TextEditingController();
|
||||
String? _parentStatus;
|
||||
bool _hasPending = false;
|
||||
bool _pendingLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_searchController.addListener(_onFilterChanged);
|
||||
_amCapacityController.addListener(_onFilterChanged);
|
||||
_loadPending();
|
||||
}
|
||||
|
||||
Future<void> _loadPending() async {
|
||||
try {
|
||||
final am = await UserService.getPendingUsers(role: 'assistante_maternelle');
|
||||
final families = await UserService.getPendingFamilies();
|
||||
if (!mounted) return;
|
||||
final hasPending = am.isNotEmpty || families.isNotEmpty;
|
||||
setState(() {
|
||||
final hadPending = _hasPending;
|
||||
_hasPending = hasPending;
|
||||
_pendingLoading = false;
|
||||
// Si on passe à "plus de dossiers", recaler l'index (onglet À valider disparaît).
|
||||
if (hadPending && !hasPending) {
|
||||
_subIndex = (_subIndex > 0 ? _subIndex - 1 : 0).clamp(0, _tabLabels.length - 1);
|
||||
} else if (!hadPending && hasPending) {
|
||||
_subIndex = 0; // Afficher l'onglet À valider
|
||||
}
|
||||
});
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_hasPending = false;
|
||||
_pendingLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -48,8 +79,17 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
List<String> get _tabLabels {
|
||||
const base = ['Parents', 'Assistantes maternelles', 'Gestionnaires'];
|
||||
final withAdmin = [...base, 'Administrateurs'];
|
||||
final list = widget.showAdministrateursTab ? withAdmin : base;
|
||||
// Onglet « À valider » visible seulement s'il y a des dossiers en attente (ticket #107).
|
||||
if (!_pendingLoading && _hasPending) return ['À valider', ...list];
|
||||
return list;
|
||||
}
|
||||
|
||||
void _onSubTabChange(int index) {
|
||||
final maxIndex = widget.showAdministrateursTab ? 3 : 2;
|
||||
final maxIndex = _tabLabels.length - 1;
|
||||
setState(() {
|
||||
_subIndex = index.clamp(0, maxIndex);
|
||||
_searchController.clear();
|
||||
@@ -58,14 +98,20 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
||||
});
|
||||
}
|
||||
|
||||
/// Index du contenu : -1 = À valider (si visible), 0 = Parents, 1 = AM, 2 = Gestionnaires, 3 = Admin.
|
||||
int get _contentIndexOffset => (_hasPending && !_pendingLoading) ? 1 : 0;
|
||||
|
||||
String _searchHintForTab() {
|
||||
switch (_subIndex) {
|
||||
final contentIndex = _subIndex - _contentIndexOffset;
|
||||
switch (contentIndex) {
|
||||
case -1:
|
||||
return 'À valider (pas de recherche)';
|
||||
case 0:
|
||||
return 'Rechercher un gestionnaire...';
|
||||
case 1:
|
||||
return 'Rechercher un parent...';
|
||||
case 2:
|
||||
case 1:
|
||||
return 'Rechercher une assistante...';
|
||||
case 2:
|
||||
return 'Rechercher un gestionnaire...';
|
||||
case 3:
|
||||
return 'Rechercher un administrateur...';
|
||||
default:
|
||||
@@ -74,7 +120,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
||||
}
|
||||
|
||||
Widget? _subBarFilterControl() {
|
||||
if (_subIndex == 1) {
|
||||
if (_subIndex == _contentIndexOffset + 0) {
|
||||
return DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String?>(
|
||||
value: _parentStatus,
|
||||
@@ -122,7 +168,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
||||
);
|
||||
}
|
||||
|
||||
if (_subIndex == 2) {
|
||||
if (_subIndex == _contentIndexOffset + 1) {
|
||||
return TextField(
|
||||
controller: _amCapacityController,
|
||||
decoration: const InputDecoration(
|
||||
@@ -139,22 +185,26 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
||||
}
|
||||
|
||||
Widget _buildBody() {
|
||||
switch (_subIndex) {
|
||||
final contentIndex = _subIndex - _contentIndexOffset;
|
||||
if (_hasPending && !_pendingLoading && contentIndex == -1) {
|
||||
return PendingValidationWidget(onRefresh: _loadPending);
|
||||
}
|
||||
switch (contentIndex) {
|
||||
case 0:
|
||||
return GestionnaireManagementWidget(
|
||||
key: ValueKey('gestionnaires-$_gestionnaireRefreshTick'),
|
||||
searchQuery: _searchController.text,
|
||||
);
|
||||
case 1:
|
||||
return ParentManagementWidget(
|
||||
searchQuery: _searchController.text,
|
||||
statusFilter: _parentStatus,
|
||||
);
|
||||
case 2:
|
||||
case 1:
|
||||
return AssistanteMaternelleManagementWidget(
|
||||
searchQuery: _searchController.text,
|
||||
capacityMin: int.tryParse(_amCapacityController.text),
|
||||
);
|
||||
case 2:
|
||||
return GestionnaireManagementWidget(
|
||||
key: ValueKey('gestionnaires-$_gestionnaireRefreshTick'),
|
||||
searchQuery: _searchController.text,
|
||||
);
|
||||
case 3:
|
||||
return AdminManagementWidget(
|
||||
key: ValueKey('admins-$_adminRefreshTick'),
|
||||
@@ -167,7 +217,8 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final subTabCount = widget.showAdministrateursTab ? 4 : 3;
|
||||
final labels = _tabLabels;
|
||||
final isAValiderTab = _hasPending && !_pendingLoading && _subIndex == 0;
|
||||
return Column(
|
||||
children: [
|
||||
DashboardUserManagementSubBar(
|
||||
@@ -176,9 +227,10 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
||||
searchController: _searchController,
|
||||
searchHint: _searchHintForTab(),
|
||||
filterControl: _subBarFilterControl(),
|
||||
onAddPressed: _handleAddPressed,
|
||||
onAddPressed: isAValiderTab ? null : _handleAddPressed,
|
||||
addLabel: 'Ajouter',
|
||||
subTabCount: subTabCount,
|
||||
subTabCount: labels.length,
|
||||
tabLabels: labels,
|
||||
),
|
||||
Expanded(child: _buildBody()),
|
||||
],
|
||||
@@ -186,7 +238,8 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
||||
}
|
||||
|
||||
Future<void> _handleAddPressed() async {
|
||||
if (_subIndex == 0) {
|
||||
final contentIndex = _subIndex - _contentIndexOffset;
|
||||
if (contentIndex == 2) {
|
||||
final created = await showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
@@ -204,7 +257,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_subIndex == 3) {
|
||||
if (contentIndex == 3) {
|
||||
final created = await showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
|
||||
@@ -0,0 +1,507 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:p_tits_pas/models/dossier_unifie.dart';
|
||||
import 'package:p_tits_pas/utils/phone_utils.dart';
|
||||
import 'package:p_tits_pas/utils/nir_utils.dart';
|
||||
import 'package:p_tits_pas/models/user.dart';
|
||||
import 'package:p_tits_pas/services/user_service.dart';
|
||||
import 'package:p_tits_pas/services/api/api_config.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
|
||||
import 'validation_modal_theme.dart';
|
||||
import 'validation_refus_form.dart';
|
||||
import 'validation_valider_confirm_dialog.dart';
|
||||
|
||||
/// Wizard de validation dossier AM : étapes sobres (label/valeur), récap, Valider/Refuser/Annuler, page refus. Ticket #107.
|
||||
class ValidationAmWizard extends StatefulWidget {
|
||||
final DossierAM dossier;
|
||||
final VoidCallback onClose;
|
||||
final VoidCallback onSuccess;
|
||||
final void Function(int step, int total)? onStepChanged;
|
||||
|
||||
const ValidationAmWizard({
|
||||
super.key,
|
||||
required this.dossier,
|
||||
required this.onClose,
|
||||
required this.onSuccess,
|
||||
this.onStepChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ValidationAmWizard> createState() => _ValidationAmWizardState();
|
||||
}
|
||||
|
||||
class _ValidationAmWizardState extends State<ValidationAmWizard> {
|
||||
int _step = 0;
|
||||
bool _showRefusForm = false;
|
||||
bool _submitting = false;
|
||||
|
||||
static const int _stepCount = 3;
|
||||
|
||||
bool get _isEnAttente => widget.dossier.user.statut == 'en_attente';
|
||||
|
||||
static String _v(String? s) =>
|
||||
(s != null && s.trim().isNotEmpty) ? s.trim() : '–';
|
||||
|
||||
/// Présentation lisible : `1 12 34 56 789 012 - 34` (15 caractères utiles requis).
|
||||
static String _formatNirForDisplay(String? nir) {
|
||||
final v = _v(nir);
|
||||
if (v == '–') return v;
|
||||
final raw = nirToRaw(v).toUpperCase();
|
||||
return raw.length == 15 ? formatNir(raw) : v;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _emitStep());
|
||||
}
|
||||
|
||||
void _emitStep() => widget.onStepChanged?.call(_step, _stepCount);
|
||||
|
||||
/// Même ordre et disposition que le formulaire de création de compte (Nom/Prénom, Tél/Email, Adresse, CP/Ville).
|
||||
List<AdminDetailField> _personalFields(AppUser u) => [
|
||||
AdminDetailField(label: 'Nom', value: _v(u.nom)),
|
||||
AdminDetailField(label: 'Prénom', value: _v(u.prenom)),
|
||||
AdminDetailField(
|
||||
label: 'Téléphone',
|
||||
value: _v(u.telephone) != '–'
|
||||
? formatPhoneForDisplay(_v(u.telephone))
|
||||
: '–'),
|
||||
AdminDetailField(label: 'Email', value: _v(u.email)),
|
||||
AdminDetailField(label: 'Adresse (N° et Rue)', value: _v(u.adresse)),
|
||||
AdminDetailField(label: 'Code postal', value: _v(u.codePostal)),
|
||||
AdminDetailField(label: 'Ville', value: _v(u.ville)),
|
||||
];
|
||||
|
||||
/// Informations professionnelles : N° Agrément|Date agrément, NIR, Capacité|Places, Ville.
|
||||
List<AdminDetailField> _proFields(DossierAM d) => [
|
||||
AdminDetailField(label: 'N° Agrément', value: _v(d.numeroAgrement)),
|
||||
AdminDetailField(
|
||||
label: 'Date d’agrément',
|
||||
value: d.dateAgrement != null && d.dateAgrement!.trim().isNotEmpty
|
||||
? d.dateAgrement!.trim()
|
||||
: '–',
|
||||
),
|
||||
AdminDetailField(label: 'NIR', value: _formatNirForDisplay(d.nir)),
|
||||
AdminDetailField(
|
||||
label: 'Capacité max (enfants)',
|
||||
value: d.nbMaxEnfants != null ? d.nbMaxEnfants.toString() : '–',
|
||||
),
|
||||
AdminDetailField(
|
||||
label: 'Places disponibles',
|
||||
value: d.placesDisponibles != null
|
||||
? d.placesDisponibles.toString()
|
||||
: '–',
|
||||
),
|
||||
AdminDetailField(
|
||||
label: 'Ville de résidence', value: _v(d.villeResidence)),
|
||||
];
|
||||
|
||||
static const List<int> _personalRowLayout = [2, 2, 1, 2];
|
||||
static const Map<int, List<int>> _personalRowFlex = {
|
||||
3: [2, 5]
|
||||
}; // Code postal étroit, Ville large
|
||||
|
||||
/// Proportion photo d’identité (35×45 mm).
|
||||
static const double _idPhotoAspectRatio = 35 / 45;
|
||||
|
||||
static const double _photoProGap = 24;
|
||||
/// Largeur mini réservée aux champs (évite une colonne photo trop gourmande).
|
||||
static const double _proColumnMinWidth = 260;
|
||||
static const double _photoColumnMinWidth = 160;
|
||||
|
||||
/// URL complète pour la photo : si relatif, on préfixe par l’origine de l’API.
|
||||
static String _fullPhotoUrl(String? url) {
|
||||
if (url == null || url.trim().isEmpty) return '';
|
||||
final u = url.trim();
|
||||
if (u.startsWith('http://') || u.startsWith('https://')) return u;
|
||||
final base = ApiConfig.baseUrl;
|
||||
final origin = base.replaceAll(RegExp(r'/api/v1.*'), '');
|
||||
return u.startsWith('/') ? '$origin$u' : '$origin/$u';
|
||||
}
|
||||
|
||||
Widget _buildPhotoSection(AppUser u) {
|
||||
final photoUrl = _fullPhotoUrl(u.photoUrl);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text(
|
||||
'Photo de profil',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, c) {
|
||||
// Cadre clair : une seule épaisseur partout (photo + padding identique haut/bas/gauche/droite).
|
||||
const uniformFrame = 8.0;
|
||||
final maxPhotoW =
|
||||
(c.maxWidth - 2 * uniformFrame).clamp(0.0, double.infinity);
|
||||
final maxPhotoH =
|
||||
(c.maxHeight - 2 * uniformFrame).clamp(0.0, double.infinity);
|
||||
const ar = _idPhotoAspectRatio;
|
||||
double ph = maxPhotoH;
|
||||
double pw = ph * ar;
|
||||
if (pw > maxPhotoW) {
|
||||
pw = maxPhotoW;
|
||||
ph = pw / ar;
|
||||
}
|
||||
return Align(
|
||||
alignment: Alignment.center,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade100,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(uniformFrame),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: SizedBox(
|
||||
width: pw,
|
||||
height: ph,
|
||||
child: photoUrl.isEmpty
|
||||
? ColoredBox(
|
||||
color: Colors.grey.shade200,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.person_off_outlined,
|
||||
size: 40,
|
||||
color: Colors.grey.shade400),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Aucune photo fournie',
|
||||
style: TextStyle(
|
||||
color: Colors.grey.shade600,
|
||||
fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: Image.network(
|
||||
photoUrl,
|
||||
fit: BoxFit.cover,
|
||||
width: pw,
|
||||
height: ph,
|
||||
loadingBuilder: (_, child, progress) {
|
||||
if (progress == null) return child;
|
||||
return ColoredBox(
|
||||
color: Colors.grey.shade200,
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(
|
||||
value: progress.expectedTotalBytes !=
|
||||
null
|
||||
? progress.cumulativeBytesLoaded /
|
||||
(progress.expectedTotalBytes!)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
errorBuilder: (_, __, ___) => ColoredBox(
|
||||
color: Colors.grey.shade200,
|
||||
child: Column(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.broken_image_outlined,
|
||||
size: 40,
|
||||
color: Colors.grey.shade400),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Impossible de charger la photo',
|
||||
style: TextStyle(
|
||||
color: Colors.grey.shade600,
|
||||
fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_showRefusForm) {
|
||||
return _buildRefusPage();
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: 4),
|
||||
Expanded(child: _buildStepContent()),
|
||||
const SizedBox(height: 24),
|
||||
_buildNavigation(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStepContent() {
|
||||
final d = widget.dossier;
|
||||
final u = d.user;
|
||||
switch (_step) {
|
||||
case 0:
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return SingleChildScrollView(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minWidth: constraints.maxWidth),
|
||||
child: ValidationDetailSection(
|
||||
title: 'Informations personnelles',
|
||||
fields: _personalFields(u),
|
||||
rowLayout: _personalRowLayout,
|
||||
rowFlex: _personalRowFlex,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
case 1:
|
||||
// Pas de SingleChildScrollView sur la Row (hauteur non bornée). Défilement à droite.
|
||||
// Largeur photo ≈ ratio × hauteur utile, plafonnée pour laisser au moins [_proColumnMinWidth] aux champs.
|
||||
return LayoutBuilder(
|
||||
builder: (context, c) {
|
||||
final maxRowW = c.maxWidth;
|
||||
final maxRowH = c.maxHeight;
|
||||
// Titre « Photo de profil » + espacement (~52 px) : hauteur dispo pour le cadre photo.
|
||||
const photoHeaderH = 52.0;
|
||||
final bodyH = (maxRowH - photoHeaderH).clamp(0.0, double.infinity);
|
||||
final idealPhotoW =
|
||||
bodyH * _idPhotoAspectRatio + 16; // marge approx. cadre clair
|
||||
final maxPhotoW = (maxRowW - _photoProGap - _proColumnMinWidth)
|
||||
.clamp(0.0, double.infinity);
|
||||
var photoW = idealPhotoW.clamp(_photoColumnMinWidth, 360.0);
|
||||
if (photoW > maxPhotoW) photoW = maxPhotoW;
|
||||
photoW = photoW.clamp(0.0, maxRowW - _photoProGap);
|
||||
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: photoW,
|
||||
child: _buildPhotoSection(u),
|
||||
),
|
||||
const SizedBox(width: _photoProGap),
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return SingleChildScrollView(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
minWidth: constraints.maxWidth),
|
||||
child: ValidationDetailSection(
|
||||
title: 'Informations professionnelles',
|
||||
fields: _proFields(d),
|
||||
rowLayout: const [
|
||||
2,
|
||||
1,
|
||||
2,
|
||||
1
|
||||
], // N° Agrément|Date agrément, NIR, Capacité|Places, Ville
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
case 2:
|
||||
final presentation =
|
||||
(d.presentation != null && d.presentation!.trim().isNotEmpty)
|
||||
? d.presentation!
|
||||
: '–';
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'Présentation',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.black87),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return SingleChildScrollView(
|
||||
child: ConstrainedBox(
|
||||
constraints:
|
||||
BoxConstraints(minHeight: constraints.maxHeight),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade50,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
),
|
||||
child: SelectableText(
|
||||
presentation,
|
||||
style: const TextStyle(
|
||||
color: Colors.black87, fontSize: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
default:
|
||||
return const SizedBox();
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildNavigation() {
|
||||
if (_step == 2) {
|
||||
return Row(
|
||||
children: [
|
||||
TextButton(onPressed: widget.onClose, child: const Text('Annuler')),
|
||||
const Spacer(),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setState(() => _step = 1);
|
||||
_emitStep();
|
||||
},
|
||||
child: const Text('Précédent'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (_isEnAttente) ...[
|
||||
OutlinedButton(
|
||||
onPressed: _submitting ? null : _refuser,
|
||||
child: const Text('Refuser')),
|
||||
const SizedBox(width: 12),
|
||||
ElevatedButton(
|
||||
style: ValidationModalTheme.primaryElevatedStyle,
|
||||
onPressed: _submitting ? null : _onValiderPressed,
|
||||
child: Text(_submitting ? 'Envoi...' : 'Valider'),
|
||||
),
|
||||
] else
|
||||
ElevatedButton(
|
||||
style: ValidationModalTheme.primaryElevatedStyle,
|
||||
onPressed: widget.onClose,
|
||||
child: const Text('Fermer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return Row(
|
||||
children: [
|
||||
TextButton(onPressed: widget.onClose, child: const Text('Annuler')),
|
||||
const Spacer(),
|
||||
if (_step > 0) ...[
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setState(() => _step--);
|
||||
_emitStep();
|
||||
},
|
||||
child: const Text('Précédent'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
ElevatedButton(
|
||||
style: ValidationModalTheme.primaryElevatedStyle,
|
||||
onPressed: () {
|
||||
setState(() => _step++);
|
||||
_emitStep();
|
||||
},
|
||||
child: const Text('Suivant'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onValiderPressed() async {
|
||||
if (_submitting) return;
|
||||
final ok = await showValidationValiderConfirmDialog(
|
||||
context,
|
||||
body:
|
||||
'Voulez-vous valider le dossier de cette assistante maternelle ? Cette action confirme le compte.',
|
||||
);
|
||||
if (!mounted || !ok) return;
|
||||
await _valider();
|
||||
}
|
||||
|
||||
Future<void> _valider() async {
|
||||
if (_submitting) return;
|
||||
setState(() => _submitting = true);
|
||||
try {
|
||||
await UserService.validateUser(widget.dossier.user.id);
|
||||
if (!mounted) return;
|
||||
widget.onSuccess();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(e is Exception
|
||||
? e.toString().replaceFirst('Exception: ', '')
|
||||
: 'Erreur'),
|
||||
backgroundColor: Colors.red.shade700,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _submitting = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _refuser() => setState(() => _showRefusForm = true);
|
||||
|
||||
Widget _buildRefusPage() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: ValidationRefusForm(
|
||||
onCancel: widget.onClose,
|
||||
onPrevious: () => setState(() => _showRefusForm = false),
|
||||
onSubmit: (comment) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Refus (à brancher sur l’API refus)')),
|
||||
);
|
||||
widget.onClose();
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:p_tits_pas/models/dossier_unifie.dart';
|
||||
import 'package:p_tits_pas/services/user_service.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/validation_am_wizard.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/validation_family_wizard.dart';
|
||||
|
||||
/// Modale (dialog) : charge le dossier par numéro puis affiche le wizard AM ou Famille. Ticket #107, #119.
|
||||
class ValidationDossierModal extends StatefulWidget {
|
||||
final String numeroDossier;
|
||||
final VoidCallback onClose;
|
||||
final VoidCallback? onSuccess;
|
||||
|
||||
const ValidationDossierModal({
|
||||
super.key,
|
||||
required this.numeroDossier,
|
||||
required this.onClose,
|
||||
this.onSuccess,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ValidationDossierModal> createState() => _ValidationDossierModalState();
|
||||
}
|
||||
|
||||
class _ValidationDossierModalState extends State<ValidationDossierModal> {
|
||||
bool _loading = true;
|
||||
String? _error;
|
||||
DossierUnifie? _dossier;
|
||||
int? _stepIndex;
|
||||
int? _stepTotal;
|
||||
|
||||
void _onStepChanged(int step, int total) {
|
||||
// step = 0-based dans les wizards, affichage 1-based dans l'en-tête.
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_stepIndex = step;
|
||||
_stepTotal = total;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
_dossier = null;
|
||||
_stepIndex = null;
|
||||
_stepTotal = null;
|
||||
});
|
||||
try {
|
||||
final d = await UserService.getDossier(widget.numeroDossier);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_dossier = d;
|
||||
_loading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = e is Exception
|
||||
? e.toString().replaceFirst('Exception: ', '')
|
||||
: 'Erreur inconnue';
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _onSuccess() {
|
||||
widget.onSuccess?.call();
|
||||
// La modale est fermée par l’appelant dans onSuccess (Navigator.pop).
|
||||
}
|
||||
|
||||
/// Largeur modale = 1,5 × 620.
|
||||
static const double _modalWidth = 930; // 620 * 1.5
|
||||
// Hauteur uniforme (ajustée +5px pour éviter l'overflow des étapes parents sans scroll).
|
||||
static const double _bodyHeight = 435;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final maxH = MediaQuery.of(context).size.height * 0.85;
|
||||
final showStep =
|
||||
_stepIndex != null && _stepTotal != null && (_stepTotal ?? 0) > 0;
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: _modalWidth, maxHeight: maxH),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(18, 18, 0, 12),
|
||||
child: Text(
|
||||
'Dossier ${widget.numeroDossier}',
|
||||
style: const TextStyle(
|
||||
fontSize: 18, fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (showStep) ...[
|
||||
Text(
|
||||
'Étape ${(_stepIndex ?? 0) + 1}/${_stepTotal ?? 1}',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: Colors.black54,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: widget.onClose,
|
||||
tooltip: 'Fermer',
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(height: 1),
|
||||
SizedBox(
|
||||
height: _bodyHeight,
|
||||
child: _buildBody(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody() {
|
||||
if (_loading) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(48),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
if (_error != null && _error!.isNotEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(_error!,
|
||||
style: const TextStyle(color: Colors.red),
|
||||
textAlign: TextAlign.center),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(onPressed: _load, child: const Text('Réessayer')),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(onPressed: widget.onClose, child: const Text('Fermer')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
final d = _dossier!;
|
||||
if (d.isAm) {
|
||||
return ValidationAmWizard(
|
||||
dossier: d.asAm,
|
||||
onClose: widget.onClose,
|
||||
onSuccess: _onSuccess,
|
||||
onStepChanged: _onStepChanged,
|
||||
);
|
||||
}
|
||||
return ValidationFamilyWizard(
|
||||
dossier: d.asFamily,
|
||||
onClose: widget.onClose,
|
||||
onSuccess: _onSuccess,
|
||||
onStepChanged: _onStepChanged,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,730 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:p_tits_pas/models/dossier_unifie.dart';
|
||||
import 'package:p_tits_pas/utils/phone_utils.dart';
|
||||
import 'package:p_tits_pas/services/user_service.dart';
|
||||
import 'package:p_tits_pas/services/api/api_config.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
|
||||
import 'validation_modal_theme.dart';
|
||||
import 'validation_refus_form.dart';
|
||||
import 'validation_valider_confirm_dialog.dart';
|
||||
|
||||
/// Wizard de validation dossier famille : étapes sobres (label/valeur), récap, Valider/Refuser/Annuler, page refus. Ticket #107.
|
||||
class ValidationFamilyWizard extends StatefulWidget {
|
||||
final DossierFamille dossier;
|
||||
final VoidCallback onClose;
|
||||
final VoidCallback onSuccess;
|
||||
final void Function(int step, int total)? onStepChanged;
|
||||
|
||||
const ValidationFamilyWizard({
|
||||
super.key,
|
||||
required this.dossier,
|
||||
required this.onClose,
|
||||
required this.onSuccess,
|
||||
this.onStepChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ValidationFamilyWizard> createState() => _ValidationFamilyWizardState();
|
||||
}
|
||||
|
||||
class _ValidationFamilyWizardState extends State<ValidationFamilyWizard> {
|
||||
int _step = 0;
|
||||
bool _showRefusForm = false;
|
||||
bool _submitting = false;
|
||||
final ScrollController _enfantsScrollController = ScrollController();
|
||||
|
||||
/// Même logique que [ParentRegisterStep3Screen] : masque alpha sur les bords (ShaderMask dstIn).
|
||||
bool _enfantsIsScrollable = false;
|
||||
bool _enfantsFadeLeft = false;
|
||||
bool _enfantsFadeRight = false;
|
||||
|
||||
/// Fraction de la largeur du viewport pour le fondu (identique inscription étape 3).
|
||||
static const double _enfantsFadeExtent = 0.05;
|
||||
|
||||
int get _stepCount => 4;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_enfantsScrollController.addListener(_syncEnfantsScrollFades);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _emitStep());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_enfantsScrollController.removeListener(_syncEnfantsScrollFades);
|
||||
_enfantsScrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _emitStep() => widget.onStepChanged?.call(_step, _stepCount);
|
||||
|
||||
void _syncEnfantsScrollFades() {
|
||||
if (!mounted) return;
|
||||
if (!_enfantsScrollController.hasClients) {
|
||||
if (_enfantsFadeLeft || _enfantsFadeRight || _enfantsIsScrollable) {
|
||||
setState(() {
|
||||
_enfantsIsScrollable = false;
|
||||
_enfantsFadeLeft = false;
|
||||
_enfantsFadeRight = false;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
final p = _enfantsScrollController.position;
|
||||
final scrollable = p.maxScrollExtent > 0;
|
||||
final left = scrollable &&
|
||||
p.pixels > (p.viewportDimension * _enfantsFadeExtent / 2);
|
||||
final right = scrollable &&
|
||||
p.pixels <
|
||||
(p.maxScrollExtent -
|
||||
(p.viewportDimension * _enfantsFadeExtent / 2));
|
||||
if (scrollable != _enfantsIsScrollable ||
|
||||
left != _enfantsFadeLeft ||
|
||||
right != _enfantsFadeRight) {
|
||||
setState(() {
|
||||
_enfantsIsScrollable = scrollable;
|
||||
_enfantsFadeLeft = left;
|
||||
_enfantsFadeRight = right;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
bool get _isEnAttente => widget.dossier.isEnAttente;
|
||||
|
||||
String? get _firstParentId => widget.dossier.parents.isNotEmpty
|
||||
? widget.dossier.parents.first.id
|
||||
: null;
|
||||
|
||||
static String _v(String? s) =>
|
||||
(s != null && s.trim().isNotEmpty) ? s.trim() : 'Non défini';
|
||||
|
||||
/// Date de naissance en jour/mois/année (dd/MM/yyyy).
|
||||
static String _formatBirthDate(String? s) {
|
||||
if (s == null || s.trim().isEmpty) return 'Non défini';
|
||||
try {
|
||||
final d = DateTime.parse(s.trim());
|
||||
return DateFormat('dd/MM/yyyy').format(d);
|
||||
} catch (_) {
|
||||
return s.trim();
|
||||
}
|
||||
}
|
||||
|
||||
/// Même ordre et disposition que le formulaire de création (Nom/Prénom, Tél/Email, Adresse, CP/Ville).
|
||||
List<AdminDetailField> _parentFields(ParentDossier p) => [
|
||||
AdminDetailField(label: 'Nom', value: _v(p.nom)),
|
||||
AdminDetailField(label: 'Prénom', value: _v(p.prenom)),
|
||||
AdminDetailField(
|
||||
label: 'Téléphone',
|
||||
value: _v(p.telephone) != 'Non défini'
|
||||
? formatPhoneForDisplay(_v(p.telephone))
|
||||
: 'Non défini'),
|
||||
AdminDetailField(label: 'Email', value: _v(p.email)),
|
||||
AdminDetailField(label: 'Adresse (N° et Rue)', value: _v(p.adresse)),
|
||||
AdminDetailField(label: 'Code postal', value: _v(p.codePostal)),
|
||||
AdminDetailField(label: 'Ville', value: _v(p.ville)),
|
||||
];
|
||||
|
||||
static const List<int> _parentRowLayout = [2, 2, 1, 2];
|
||||
static const Map<int, List<int>> _parentRowFlex = {
|
||||
3: [2, 5]
|
||||
}; // Code postal étroit, Ville large
|
||||
|
||||
static String _fullPhotoUrl(String? url) {
|
||||
if (url == null || url.trim().isEmpty) return '';
|
||||
final u = url.trim();
|
||||
if (u.startsWith('http://') || u.startsWith('https://')) return u;
|
||||
final base = ApiConfig.baseUrl;
|
||||
final origin = base.replaceAll(RegExp(r'/api/v1.*'), '');
|
||||
return u.startsWith('/') ? '$origin$u' : '$origin/$u';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_showRefusForm) {
|
||||
return _buildRefusPage();
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: 4),
|
||||
Expanded(child: _buildStepContent()),
|
||||
const SizedBox(height: 24),
|
||||
_buildNavigation(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStepContent() {
|
||||
final d = widget.dossier;
|
||||
switch (_step) {
|
||||
case 0:
|
||||
return ValidationDetailSection(
|
||||
title: 'Parent principal',
|
||||
fields: _parentFields(d.parents.first),
|
||||
rowLayout: _parentRowLayout,
|
||||
rowFlex: _parentRowFlex,
|
||||
);
|
||||
case 1:
|
||||
return _buildParent2Step();
|
||||
case 2:
|
||||
return _buildEnfantsStep();
|
||||
case 3:
|
||||
return _buildPresentationStep();
|
||||
default:
|
||||
return const SizedBox();
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildParent2Step() {
|
||||
if (widget.dossier.parents.length < 2) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 12),
|
||||
child: Text('Un seul parent pour ce dossier.',
|
||||
style: TextStyle(color: Colors.black87)),
|
||||
);
|
||||
}
|
||||
return ValidationDetailSection(
|
||||
title: 'Deuxième parent',
|
||||
fields: _parentFields(widget.dossier.parents[1]),
|
||||
rowLayout: _parentRowLayout,
|
||||
rowFlex: _parentRowFlex,
|
||||
);
|
||||
}
|
||||
|
||||
static const double _idPhotoAspectRatio = 35 / 45;
|
||||
|
||||
Widget _buildEnfantsStep() {
|
||||
final enfants = widget.dossier.enfants;
|
||||
if (enfants.isEmpty) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 12),
|
||||
child: Text('Aucun enfant renseigné.',
|
||||
style: TextStyle(color: Colors.black87)),
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text(
|
||||
'Enfants',
|
||||
style: TextStyle(
|
||||
fontSize: 16, fontWeight: FontWeight.w600, color: Colors.black87),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final cardHeight = constraints.maxHeight;
|
||||
// Carte large : 1/3 photo + 2/3 champs (scroll horizontal si plusieurs enfants).
|
||||
final cardWidth = (cardHeight * 1.72).clamp(500.0, 700.0);
|
||||
return NotificationListener<ScrollMetricsNotification>(
|
||||
onNotification: (_) {
|
||||
_syncEnfantsScrollFades();
|
||||
return false;
|
||||
},
|
||||
child: ShaderMask(
|
||||
blendMode: BlendMode.dstIn,
|
||||
shaderCallback: (Rect bounds) {
|
||||
final stops = <double>[
|
||||
0.0,
|
||||
_enfantsFadeExtent,
|
||||
1.0 - _enfantsFadeExtent,
|
||||
1.0,
|
||||
];
|
||||
if (!_enfantsIsScrollable) {
|
||||
return LinearGradient(
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
colors: const <Color>[
|
||||
Colors.black,
|
||||
Colors.black,
|
||||
Colors.black,
|
||||
Colors.black,
|
||||
],
|
||||
stops: stops,
|
||||
).createShader(bounds);
|
||||
}
|
||||
final leftMask =
|
||||
_enfantsFadeLeft ? Colors.transparent : Colors.black;
|
||||
final rightMask =
|
||||
_enfantsFadeRight ? Colors.transparent : Colors.black;
|
||||
return LinearGradient(
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
colors: <Color>[
|
||||
leftMask,
|
||||
Colors.black,
|
||||
Colors.black,
|
||||
rightMask,
|
||||
],
|
||||
stops: stops,
|
||||
).createShader(bounds);
|
||||
},
|
||||
child: Listener(
|
||||
onPointerSignal: (event) {
|
||||
if (event is PointerScrollEvent &&
|
||||
_enfantsScrollController.hasClients) {
|
||||
final offset = _enfantsScrollController.offset +
|
||||
event.scrollDelta.dy;
|
||||
_enfantsScrollController.jumpTo(offset.clamp(
|
||||
_enfantsScrollController.position.minScrollExtent,
|
||||
_enfantsScrollController.position.maxScrollExtent,
|
||||
));
|
||||
}
|
||||
},
|
||||
child: ListView.builder(
|
||||
controller: _enfantsScrollController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: enfants.length,
|
||||
itemBuilder: (_, i) => Padding(
|
||||
padding: EdgeInsets.only(
|
||||
right: i < enfants.length - 1 ? 16 : 0),
|
||||
child: SizedBox(
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
child: _buildEnfantCard(enfants[i]),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Fond carte enfant : teintes très pastel ; bordure discrète ; accent léger (barre).
|
||||
static const Color _enfantCardBoyBg = Color(0xFFF0F7FB);
|
||||
static const Color _enfantCardBoyBorder = Color(0xFFE3EDF4);
|
||||
static const Color _enfantCardGirlBg = Color(0xFFFCF5F8);
|
||||
static const Color _enfantCardGirlBorder = Color(0xFFEAE3E7);
|
||||
|
||||
static const double _enfantCardRadius = 12;
|
||||
|
||||
static List<BoxShadow> _enfantCardShadows() => [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.06),
|
||||
blurRadius: 14,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
];
|
||||
|
||||
static BoxDecoration _enfantCardDecoration(String? gender) {
|
||||
final g = (gender ?? '').trim().toUpperCase();
|
||||
if (g == 'H') {
|
||||
return BoxDecoration(
|
||||
color: _enfantCardBoyBg,
|
||||
borderRadius: BorderRadius.circular(_enfantCardRadius),
|
||||
border: Border.all(color: _enfantCardBoyBorder, width: 1),
|
||||
boxShadow: _enfantCardShadows(),
|
||||
);
|
||||
}
|
||||
if (g == 'F') {
|
||||
return BoxDecoration(
|
||||
color: _enfantCardGirlBg,
|
||||
borderRadius: BorderRadius.circular(_enfantCardRadius),
|
||||
border: Border.all(color: _enfantCardGirlBorder, width: 1),
|
||||
boxShadow: _enfantCardShadows(),
|
||||
);
|
||||
}
|
||||
return BoxDecoration(
|
||||
color: Colors.grey.shade50,
|
||||
borderRadius: BorderRadius.circular(_enfantCardRadius),
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
boxShadow: _enfantCardShadows(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Carte enfant : prénom pleine largeur, puis ligne photo 1/3 + colonne 2/3 (champs + statut hors TF si besoin).
|
||||
Widget _buildEnfantCard(EnfantDossier e) {
|
||||
final photoUrl = _fullPhotoUrl(e.photoUrl);
|
||||
final columnStatusLabel = _enfantColumnStatusLabel(e);
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(_enfantCardRadius),
|
||||
child: Container(
|
||||
decoration: _enfantCardDecoration(e.gender),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
|
||||
child: _enfantLabeledField('Prénom', _v(e.firstName)),
|
||||
),
|
||||
Expanded(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, c) {
|
||||
// Même marge gauche que le bloc « Prénom » (12) ; droite / haut / bas 8.
|
||||
const padL = 12.0;
|
||||
const padR = 8.0;
|
||||
const padV = 8.0;
|
||||
final maxW =
|
||||
(c.maxWidth - padL - padR).clamp(0.0, double.infinity);
|
||||
final maxH =
|
||||
(c.maxHeight - 2 * padV).clamp(0.0, double.infinity);
|
||||
const ar = _idPhotoAspectRatio;
|
||||
double ph = maxH;
|
||||
double pw = ph * ar;
|
||||
if (pw > maxW) {
|
||||
pw = maxW;
|
||||
ph = pw / ar;
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(padL, padV, padR, padV),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: _buildEnfantPhotoSlot(photoUrl, pw, ph),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 4, 14, 12),
|
||||
child: columnStatusLabel == null
|
||||
? SingleChildScrollView(
|
||||
child: _buildEnfantInfoFields(e),
|
||||
)
|
||||
: CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: _buildEnfantInfoFields(e),
|
||||
),
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Center(
|
||||
child: Text(
|
||||
columnStatusLabel,
|
||||
textAlign: TextAlign.center,
|
||||
style: GoogleFonts.merienda(
|
||||
fontSize: 14,
|
||||
fontStyle: FontStyle.italic,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.grey.shade800,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// « Scolarisé » / « Scolarisée » selon le genre enfant (`F` / sinon masculin par défaut).
|
||||
static String _scolariseAccordeAuGenre(String? gender) {
|
||||
final g = (gender ?? '').trim().toUpperCase();
|
||||
if (g == 'F') return 'Scolarisée';
|
||||
return 'Scolarisé';
|
||||
}
|
||||
|
||||
/// Statut dans la colonne 2/3 uniquement (pas de [ValidationReadOnlyField]) : scolarisé·e ou « À naître ».
|
||||
/// `actif` : pas de ligne statut.
|
||||
String? _enfantColumnStatusLabel(EnfantDossier e) {
|
||||
final s = (e.status ?? '').trim().toLowerCase();
|
||||
if (s == 'a_naitre') return 'À naître';
|
||||
if (s == 'scolarise') return _scolariseAccordeAuGenre(e.gender);
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Nom ; date de naissance et genre sur une ligne (prénom au-dessus, pleine largeur).
|
||||
Widget _buildEnfantInfoFields(EnfantDossier e) {
|
||||
final isANaitre = (e.status ?? '').trim().toLowerCase() == 'a_naitre';
|
||||
final dueDateRenseignee = e.dueDate != null && e.dueDate!.trim().isNotEmpty;
|
||||
final dateValue = isANaitre
|
||||
? (dueDateRenseignee ? '${_formatBirthDate(e.dueDate)} (P)' : '– (P)')
|
||||
: _formatBirthDate(e.birthDate);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: _enfantLabeledField('Nom', _formatNom(e.lastName)),
|
||||
),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: _enfantLabeledField('Date de naissance', dateValue),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: _enfantLabeledField(
|
||||
'Genre',
|
||||
_genreEnfantLabel(e.gender, e.status),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _enfantLabeledField(String label, String value) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.grey.shade700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
ValidationReadOnlyField(value: value),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEnfantPhotoSlot(String photoUrl, double width, double height) {
|
||||
return Container(
|
||||
width: width,
|
||||
height: height,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.black.withValues(alpha: 0.08)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 6,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: photoUrl.isEmpty
|
||||
? ColoredBox(
|
||||
color: Colors.grey.shade100,
|
||||
child: Center(
|
||||
child: Icon(Icons.person_outline, size: 32, color: Colors.grey.shade400),
|
||||
),
|
||||
)
|
||||
: Image.network(
|
||||
photoUrl,
|
||||
fit: BoxFit.contain,
|
||||
width: width,
|
||||
height: height,
|
||||
errorBuilder: (_, __, ___) => ColoredBox(
|
||||
color: Colors.grey.shade100,
|
||||
child: Center(
|
||||
child: Icon(Icons.broken_image_outlined, size: 32, color: Colors.grey.shade400),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static String _formatNom(String? lastName) {
|
||||
final n = (lastName ?? '').trim().toUpperCase();
|
||||
return n.isEmpty ? 'Non défini' : n;
|
||||
}
|
||||
|
||||
/// Genre enfant : Garçon, Fille, ou "Non connu" (uniquement si l'enfant est à naître).
|
||||
static String _genreEnfantLabel(String? gender, String? status) {
|
||||
final g = (gender ?? '').trim().toUpperCase();
|
||||
final isANaitre = (status ?? '').trim().toLowerCase() == 'a_naitre';
|
||||
if (g == 'H') return 'Garçon';
|
||||
if (g == 'F') return 'Fille';
|
||||
if (isANaitre) return 'Non connu';
|
||||
if (g.isEmpty) return 'Non défini';
|
||||
return (gender ?? '').trim();
|
||||
}
|
||||
|
||||
Widget _buildPresentationStep() {
|
||||
final p = widget.dossier.presentation ?? '';
|
||||
final text = p.trim().isEmpty ? 'Non défini' : p;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text(
|
||||
'Présentation / Motivation',
|
||||
style: TextStyle(
|
||||
fontSize: 16, fontWeight: FontWeight.w600, color: Colors.black87),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return SingleChildScrollView(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minHeight: constraints.maxHeight),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade50,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
),
|
||||
child: SelectableText(
|
||||
text,
|
||||
style:
|
||||
const TextStyle(color: Colors.black87, fontSize: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNavigation() {
|
||||
if (_step == 3) {
|
||||
return Row(
|
||||
children: [
|
||||
TextButton(onPressed: widget.onClose, child: const Text('Annuler')),
|
||||
const Spacer(),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setState(() => _step = 2);
|
||||
_emitStep();
|
||||
},
|
||||
child: const Text('Précédent'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (_isEnAttente && _firstParentId != null) ...[
|
||||
OutlinedButton(
|
||||
onPressed: _submitting ? null : _refuser,
|
||||
child: const Text('Refuser')),
|
||||
const SizedBox(width: 12),
|
||||
ElevatedButton(
|
||||
style: ValidationModalTheme.primaryElevatedStyle,
|
||||
onPressed: _submitting ? null : _onValiderPressed,
|
||||
child: Text(_submitting ? 'Envoi...' : 'Valider'),
|
||||
),
|
||||
] else if (!_isEnAttente)
|
||||
ElevatedButton(
|
||||
style: ValidationModalTheme.primaryElevatedStyle,
|
||||
onPressed: widget.onClose,
|
||||
child: const Text('Fermer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return Row(
|
||||
children: [
|
||||
TextButton(onPressed: widget.onClose, child: const Text('Annuler')),
|
||||
const Spacer(),
|
||||
if (_step > 0) ...[
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setState(() => _step--);
|
||||
_emitStep();
|
||||
},
|
||||
child: const Text('Précédent'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
ElevatedButton(
|
||||
style: ValidationModalTheme.primaryElevatedStyle,
|
||||
onPressed: () {
|
||||
setState(() => _step++);
|
||||
_emitStep();
|
||||
},
|
||||
child: const Text('Suivant'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onValiderPressed() async {
|
||||
if (_submitting || _firstParentId == null) return;
|
||||
final ok = await showValidationValiderConfirmDialog(
|
||||
context,
|
||||
body:
|
||||
'Voulez-vous valider ce dossier famille ? Les comptes parents concernés seront confirmés.',
|
||||
);
|
||||
if (!mounted || !ok) return;
|
||||
await _valider();
|
||||
}
|
||||
|
||||
Future<void> _valider() async {
|
||||
if (_submitting || _firstParentId == null) return;
|
||||
setState(() => _submitting = true);
|
||||
try {
|
||||
await UserService.validerDossierFamille(_firstParentId!);
|
||||
if (!mounted) return;
|
||||
widget.onSuccess();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(e is Exception
|
||||
? e.toString().replaceFirst('Exception: ', '')
|
||||
: 'Erreur'),
|
||||
backgroundColor: Colors.red.shade700,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _submitting = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _refuser() => setState(() => _showRefusForm = true);
|
||||
|
||||
Widget _buildRefusPage() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: ValidationRefusForm(
|
||||
onCancel: widget.onClose,
|
||||
onPrevious: () => setState(() => _showRefusForm = false),
|
||||
onSubmit: (comment) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Refus (à brancher sur l’API refus – ticket #110)')),
|
||||
);
|
||||
widget.onClose();
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Couleurs / styles communs aux modales de validation (cohérent avec le violet / lavande admin).
|
||||
abstract final class ValidationModalTheme {
|
||||
/// Violet pastel foncé (proche des cartes admin, ex. `0xFF6D4EA1`).
|
||||
static const Color primaryActionBackground = Color(0xFF6D4EA1);
|
||||
static const Color primaryActionForeground = Colors.white;
|
||||
|
||||
static ButtonStyle get primaryElevatedStyle {
|
||||
return ElevatedButton.styleFrom(
|
||||
backgroundColor: primaryActionBackground,
|
||||
foregroundColor: primaryActionForeground,
|
||||
disabledBackgroundColor: primaryActionBackground.withOpacity(0.45),
|
||||
disabledForegroundColor: primaryActionForeground.withOpacity(0.7),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'validation_modal_theme.dart';
|
||||
|
||||
/// Page « Motifs du refus » : champ libre + Annuler (ferme la modale), Précédent (retour au choix Valider/Refuser), Envoyer. Ticket #107.
|
||||
class ValidationRefusForm extends StatefulWidget {
|
||||
/// Ferme la modale (abandon du flux).
|
||||
final VoidCallback onCancel;
|
||||
/// Retour à l’étape précédente du wizard (écran avec Valider / Refuser).
|
||||
final VoidCallback onPrevious;
|
||||
final ValueChanged<String?> onSubmit;
|
||||
|
||||
const ValidationRefusForm({
|
||||
super.key,
|
||||
required this.onCancel,
|
||||
required this.onPrevious,
|
||||
required this.onSubmit,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ValidationRefusForm> createState() => _ValidationRefusFormState();
|
||||
}
|
||||
|
||||
class _ValidationRefusFormState extends State<ValidationRefusForm> {
|
||||
final _controller = TextEditingController();
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
static const int _minLength = 20;
|
||||
|
||||
String? _validateMotifs(String? value) {
|
||||
final t = value?.trim() ?? '';
|
||||
if (t.isEmpty) return 'Les motifs du refus sont obligatoires.';
|
||||
if (t.length < _minLength) {
|
||||
return 'Veuillez indiquer au moins $_minLength caractères (${t.length}/$_minLength).';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'Indiquez les motifs du refus',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return Container(
|
||||
constraints: BoxConstraints.tight(Size(constraints.maxWidth, constraints.maxHeight)),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: Colors.grey.shade400),
|
||||
),
|
||||
child: TextFormField(
|
||||
controller: _controller,
|
||||
maxLines: null,
|
||||
minLines: 1,
|
||||
validator: _validateMotifs,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Saisissez les raisons du refus (minimum $_minLength caractères)',
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
alignLabelWithHint: true,
|
||||
filled: false,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: widget.onCancel,
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
const Spacer(),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: widget.onPrevious,
|
||||
child: const Text('Précédent'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
style: ValidationModalTheme.primaryElevatedStyle,
|
||||
onPressed: () {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
widget.onSubmit(_controller.text.trim());
|
||||
}
|
||||
},
|
||||
child: const Text('Envoyer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'validation_modal_theme.dart';
|
||||
|
||||
/// Affiche une confirmation avant d’appeler l’API de validation du dossier.
|
||||
/// Retourne `true` si l’utilisateur confirme.
|
||||
Future<bool> showValidationValiderConfirmDialog(
|
||||
BuildContext context, {
|
||||
required String body,
|
||||
}) async {
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (dialogContext) {
|
||||
return AlertDialog(
|
||||
title: const Text('Confirmer la validation'),
|
||||
content: Text(body),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
ElevatedButton(
|
||||
style: ValidationModalTheme.primaryElevatedStyle,
|
||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||
child: const Text('Confirmer'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
return result == true;
|
||||
}
|
||||
Reference in New Issue
Block a user