Compare commits

...
3 Commits
Author SHA1 Message Date
jmartinandCursor 8ee2ca8ea6 fix(#156): caler la modale AM sur les TF et unifier create/review.
ValidationFormMetrics (titres, TF, écarts) pilote la hauteur ; photo
étirée sur le corps ; mêmes widgets create et validation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 16:49:59 +02:00
jmartinandCursor e3552667bc fix(#156): valider téléphone au blur et NIR en live.
Formatage NIR progressif + contrôles au fil de la saisie ; téléphone
validé à la perte de focus comme e-mail / CP.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 16:28:35 +02:00
jmartinandCursor 4ae334b247 fix(#156): superviser nom, e-mail et CP comme à l’inscription.
IdentityBlock editable : capitalisation live, e-mail/CP normalisés
et validés à la perte de focus (aligné création de compte).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 19:27:20 +02:00
7 changed files with 599 additions and 140 deletions
+37 -9
View File
@@ -49,12 +49,21 @@ String nirToRaw(String normalized) {
return s; return s;
} }
/// Formate pour affichage : 1 12 34 56 789 012 - 34 ou 1 12 34 2A 789 012 - 34 (Corse). /// Formate pour affichage (complet ou en cours de saisie) :
/// `1 12 34 56 789 012 - 34` ou `1 12 34 2A 789 012 - 34` (Corse).
String formatNir(String raw) { String formatNir(String raw) {
final r = nirToRaw(raw); final r = nirToRaw(raw).toUpperCase();
if (r.length < 15) return r; if (r.isEmpty) return '';
// Même structure pour tous : sexe + année + mois + département + commune + ordre-clé. final buf = StringBuffer();
return '${r.substring(0, 1)} ${r.substring(1, 3)} ${r.substring(3, 5)} ${r.substring(5, 7)} ${r.substring(7, 10)} ${r.substring(10, 13)} - ${r.substring(13, 15)}'; for (var i = 0; i < r.length && i < 15; i++) {
if (i == 1 || i == 3 || i == 5 || i == 7 || i == 10) {
buf.write(' ');
} else if (i == 13) {
buf.write(' - ');
}
buf.write(r[i]);
}
return buf.toString();
} }
/// Aligné sur le backend (NIR côté API / nir.util.ts) : sexe 13, département 2A ou 2B pour la Corse. /// Aligné sur le backend (NIR côté API / nir.util.ts) : sexe 13, département 2A ou 2B pour la Corse.
@@ -92,20 +101,39 @@ String? validateNir(String? value) {
return null; return null;
} }
/// Formateur de saisie : affiche le NIR formaté (1 12 34 56 789 012 - 34) et limite à 15 caractères utiles. /// Validation pendant la saisie : pas derreur si incomplet encore plausible ;
/// dès 15 caractères, même contrôles que [validateNir].
String? validateNirTyping(String? value) {
if (value == null || value.trim().isEmpty) return null;
final raw = nirToRaw(value).toUpperCase();
if (raw.isEmpty) return null;
if (raw[0] != '1' && raw[0] != '2' && raw[0] != '3') {
return 'Format NIR invalide (ex. 1 12 34 56 789 012 - 34 ou 2A pour la Corse)';
}
if (raw.length < 15) return null;
return validateNir(value);
}
/// Formateur de saisie : affiche le NIR formaté au fil de la frappe et limite à 15 caractères utiles.
class NirInputFormatter extends TextInputFormatter { class NirInputFormatter extends TextInputFormatter {
const NirInputFormatter();
@override @override
TextEditingValue formatEditUpdate( TextEditingValue formatEditUpdate(
TextEditingValue oldValue, TextEditingValue oldValue,
TextEditingValue newValue, TextEditingValue newValue,
) { ) {
final raw = normalizeNir(newValue.text); final raw = normalizeNir(newValue.text);
if (raw.isEmpty) return newValue; if (raw.isEmpty) {
return const TextEditingValue(
text: '',
selection: TextSelection.collapsed(offset: 0),
);
}
final formatted = formatNir(raw); final formatted = formatNir(raw);
final offset = formatted.length;
return TextEditingValue( return TextEditingValue(
text: formatted, text: formatted,
selection: TextSelection.collapsed(offset: offset), selection: TextSelection.collapsed(offset: formatted.length),
); );
} }
} }
@@ -33,7 +33,8 @@ class _AmDossierCreateModalState extends State<AmDossierCreateModal> {
} }
static const double _modalWidth = 930; static const double _modalWidth = 930;
static const double _bodyHeight = 435; /// Hauteur calculée depuis 4 lignes de TF (voir [AmDossierWizard.shellBodyHeight]).
static double get _bodyHeight => AmDossierWizard.shellBodyHeight;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -10,8 +10,10 @@ import 'package:p_tits_pas/services/api/api_config.dart';
import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/utils/date_display_utils.dart'; import 'package:p_tits_pas/utils/date_display_utils.dart';
import 'package:p_tits_pas/utils/email_utils.dart'; import 'package:p_tits_pas/utils/email_utils.dart';
import 'package:p_tits_pas/utils/name_format_utils.dart';
import 'package:p_tits_pas/utils/nir_utils.dart'; import 'package:p_tits_pas/utils/nir_utils.dart';
import 'package:p_tits_pas/utils/phone_utils.dart'; import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/utils/postal_utils.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.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 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
@@ -74,6 +76,10 @@ class AmDossierWizard extends StatefulWidget {
bool get isCreate => mode == AmDossierWizardMode.create; bool get isCreate => mode == AmDossierWizardMode.create;
/// Hauteur corps modale AM — dérivée de [ValidationFormMetrics] (4 lignes).
static double get shellBodyHeight =>
ValidationFormMetrics.shellBodyHeightForRows(4);
@override @override
State<AmDossierWizard> createState() => _AmDossierWizardState(); State<AmDossierWizard> createState() => _AmDossierWizardState();
} }
@@ -361,7 +367,8 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
if (_adresseCtrl.text.trim().isEmpty) { if (_adresseCtrl.text.trim().isEmpty) {
return 'Ladresse est requise.'; return 'Ladresse est requise.';
} }
if (_cpCtrl.text.trim().isEmpty) return 'Le code postal est requis.'; final cpErr = validateFrenchPostalCode(_cpCtrl.text);
if (cpErr != null) return cpErr;
if (_villeCtrl.text.trim().isEmpty) return 'La ville est requise.'; if (_villeCtrl.text.trim().isEmpty) return 'La ville est requise.';
return null; return null;
} }
@@ -466,21 +473,24 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
return <String, dynamic>{ return <String, dynamic>{
'email': normalizeEmailText(_emailCtrl.text), 'email': normalizeEmailText(_emailCtrl.text),
'prenom': _prenomCtrl.text.trim(), 'prenom': formatPersonNameCase(_prenomCtrl.text),
'nom': _nomCtrl.text.trim(), 'nom': formatPersonNameCase(_nomCtrl.text),
'telephone': normalizePhone(_telCtrl.text), 'telephone': normalizePhone(_telCtrl.text),
'adresse': 'adresse':
_adresseCtrl.text.trim().isNotEmpty ? _adresseCtrl.text.trim() : null, _adresseCtrl.text.trim().isNotEmpty ? _adresseCtrl.text.trim() : null,
'code_postal': 'code_postal':
_cpCtrl.text.trim().isNotEmpty ? _cpCtrl.text.trim() : null, _cpCtrl.text.trim().isNotEmpty ? _cpCtrl.text.trim() : null,
'ville': 'ville': _villeCtrl.text.trim().isNotEmpty
_villeCtrl.text.trim().isNotEmpty ? _villeCtrl.text.trim() : null, ? formatPersonNameCase(_villeCtrl.text)
: null,
'photo_base64': photoBase64, 'photo_base64': photoBase64,
'photo_filename': fn.isNotEmpty ? fn : 'photo_am.jpg', 'photo_filename': fn.isNotEmpty ? fn : 'photo_am.jpg',
'consentement_photo': true, 'consentement_photo': true,
'date_naissance': birthIso, 'date_naissance': birthIso,
'lieu_naissance_ville': _lieuNaissanceVilleCtrl.text.trim(), 'lieu_naissance_ville':
'lieu_naissance_pays': _lieuNaissancePaysCtrl.text.trim(), formatPersonNameCase(_lieuNaissanceVilleCtrl.text),
'lieu_naissance_pays':
formatPersonNameCase(_lieuNaissancePaysCtrl.text),
'nir': normalizeNir(_nirCtrl.text), 'nir': normalizeNir(_nirCtrl.text),
'numero_agrement': _agrementCtrl.text.trim(), 'numero_agrement': _agrementCtrl.text.trim(),
'date_agrement': agrementIso, 'date_agrement': agrementIso,
@@ -578,12 +588,7 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
Widget _buildStep0() { Widget _buildStep0() {
if (_isCreate) { if (_isCreate) {
return LayoutBuilder( return IdentityBlock.editable(
builder: (context, constraints) {
return SingleChildScrollView(
child: ConstrainedBox(
constraints: BoxConstraints(minWidth: constraints.maxWidth),
child: IdentityBlock.editable(
title: 'Identité et coordonnées', title: 'Identité et coordonnées',
nomController: _nomCtrl, nomController: _nomCtrl,
prenomController: _prenomCtrl, prenomController: _prenomCtrl,
@@ -592,42 +597,36 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
adresseController: _adresseCtrl, adresseController: _adresseCtrl,
codePostalController: _cpCtrl, codePostalController: _cpCtrl,
villeController: _villeCtrl, villeController: _villeCtrl,
),
),
);
},
); );
} }
final u = _dossier.user; final u = _dossier.user;
return LayoutBuilder( return IdentityBlock.readOnlyFromUser(
builder: (context, constraints) {
return SingleChildScrollView(
child: ConstrainedBox(
constraints: BoxConstraints(minWidth: constraints.maxWidth),
child: IdentityBlock.readOnlyFromUser(
u, u,
title: 'Identité et coordonnées', title: 'Identité et coordonnées',
emptyLabel: '', emptyLabel: '',
),
),
);
},
); );
} }
Widget _buildStep1() { Widget _buildStep1() {
// Modale calée sur les TF ; photo étirée sur toute la hauteur utile
// (largeur = [AdminAmPhotoFrame.columnWidthForHeight], cadre inclus).
return LayoutBuilder( return LayoutBuilder(
builder: (context, c) { builder: (context, c) {
final maxRowW = c.maxWidth; final maxRowW = c.maxWidth;
final maxRowH = c.maxHeight; final maxRowH = c.maxHeight.clamp(0.0, double.infinity);
const photoHeaderH = 0.0;
final bodyH = (maxRowH - photoHeaderH).clamp(0.0, double.infinity);
final idealPhotoW = bodyH * _idPhotoAspectRatio + 16;
final maxPhotoW = (maxRowW - _photoProGap - _proColumnMinWidth) final maxPhotoW = (maxRowW - _photoProGap - _proColumnMinWidth)
.clamp(0.0, double.infinity); .clamp(0.0, double.infinity);
var photoW = idealPhotoW.clamp(_photoColumnMinWidth, 360.0); var photoW = AdminAmPhotoFrame.columnWidthForHeight(maxRowH)
.clamp(_photoColumnMinWidth, 360.0);
if (photoW > maxPhotoW) photoW = maxPhotoW; if (photoW > maxPhotoW) photoW = maxPhotoW;
photoW = photoW.clamp(0.0, maxRowW - _photoProGap);
final form = _isCreate
? _buildCreateProFields()
: ValidationDetailSection(
title: 'Dossier professionnel',
fields: _photoProFields(_dossier),
rowLayout: _photoProRowLayout,
);
return Row( return Row(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -645,22 +644,9 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
), ),
const SizedBox(width: _photoProGap), const SizedBox(width: _photoProGap),
Expanded( Expanded(
child: LayoutBuilder( child: Align(
builder: (context, constraints) { alignment: Alignment.topCenter,
return SingleChildScrollView( child: form,
child: ConstrainedBox(
constraints:
BoxConstraints(minWidth: constraints.maxWidth),
child: _isCreate
? _buildCreateProFields()
: ValidationDetailSection(
title: 'Dossier professionnel',
fields: _photoProFields(_dossier),
rowLayout: _photoProRowLayout,
),
),
);
},
), ),
), ),
], ],
@@ -676,11 +662,7 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
fields: [ fields: [
ValidationLabeledField( ValidationLabeledField(
label: 'NIR', label: 'NIR',
field: ValidationEditableField( field: ValidationNirField(controller: _nirCtrl),
controller: _nirCtrl,
hintText: '15 caractères',
inputFormatters: [NirInputFormatter()],
),
), ),
ValidationLabeledField( ValidationLabeledField(
label: 'Date de naissance', label: 'Date de naissance',
@@ -695,12 +677,14 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
label: 'Ville de naissance', label: 'Ville de naissance',
field: ValidationEditableField( field: ValidationEditableField(
controller: _lieuNaissanceVilleCtrl, controller: _lieuNaissanceVilleCtrl,
inputFormatters: const [PersonNameInputFormatter()],
), ),
), ),
ValidationLabeledField( ValidationLabeledField(
label: 'Pays de naissance', label: 'Pays de naissance',
field: ValidationEditableField( field: ValidationEditableField(
controller: _lieuNaissancePaysCtrl, controller: _lieuNaissancePaysCtrl,
inputFormatters: const [PersonNameInputFormatter()],
), ),
), ),
ValidationLabeledField( ValidationLabeledField(
@@ -1,7 +1,55 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:p_tits_pas/utils/email_utils.dart';
import 'package:p_tits_pas/utils/nir_utils.dart';
import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/utils/postal_utils.dart';
import 'admin_detail_modal.dart'; import 'admin_detail_modal.dart';
/// Réglages des formulaires validation / wizard AM — **jouer sur ces 3 leviers**.
class ValidationFormMetrics {
ValidationFormMetrics._();
// --- 1. Titres de section ---
static const double sectionTitleFontSize = 16;
static const double sectionTitleGapBelow = 12;
// --- 2. TF : texte intérieur + padding vertical (= hauteur) ---
static const double fieldTextFontSize = 14;
static const double fieldContentPaddingV = 12;
static const double fieldContentPaddingH = 12;
/// Hauteur estimée du TF (texte + padding haut/bas + bordure).
static const double fieldHeight =
fieldTextFontSize + fieldContentPaddingV * 2 + 4;
// --- 3. Espace entre les lignes de TF ---
static const double rowGapBelow = 12;
// Libellé au-dessus du TF (titre du champ)
static const double fieldLabelFontSize = 13;
static const double fieldLabelGapBelow = 4;
static const TextStyle fieldTextStyle = TextStyle(
color: Colors.black87,
fontSize: fieldTextFontSize,
);
static double get sectionTitleBlockHeight =>
sectionTitleFontSize + sectionTitleGapBelow;
static double get labeledRowHeight =>
fieldLabelFontSize + fieldLabelGapBelow + fieldHeight + rowGapBelow;
/// Corps modale AM : padding wizard + titre + [rows] lignes + nav.
static double shellBodyHeightForRows(int rows) =>
20 * 2 + // padding wizard
4 + // espace haut
sectionTitleBlockHeight +
rows * labeledRowHeight +
24 + // avant nav
44; // boutons
}
/// Bloc type formulaire (titre de section + champs read-only) pour les modales de validation. /// 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. /// [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). /// [rowFlex] : flex par index de ligne (optionnel). Ex. {3: [2, 5]} = 4e ligne : code postal étroit (2), ville large (5).
@@ -16,12 +64,16 @@ class ValidationDetailSection extends StatelessWidget {
/// Flex par ligne (index de ligne -> [flex1, flex2, ...]). Ex. {3: [2, 5]} pour Code postal | Ville. /// Flex par ligne (index de ligne -> [flex1, flex2, ...]). Ex. {3: [2, 5]} pour Code postal | Ville.
final Map<int, List<int>>? rowFlex; final Map<int, List<int>>? rowFlex;
/// Remplit la hauteur disponible (wizard AM étapes 12).
final bool expandVertically;
const ValidationDetailSection({ const ValidationDetailSection({
super.key, super.key,
this.title, this.title,
required this.fields, required this.fields,
this.rowLayout, this.rowLayout,
this.rowFlex, this.rowFlex,
this.expandVertically = false,
}); });
@override @override
@@ -30,6 +82,7 @@ class ValidationDetailSection extends StatelessWidget {
title: title, title: title,
rowLayout: rowLayout, rowLayout: rowLayout,
rowFlex: rowFlex, rowFlex: rowFlex,
expandVertically: expandVertically,
fields: fields fields: fields
.map( .map(
(f) => ValidationLabeledField( (f) => ValidationLabeledField(
@@ -49,6 +102,8 @@ class ValidationFormGrid extends StatelessWidget {
final List<int>? rowLayout; final List<int>? rowLayout;
final Map<int, List<int>>? rowFlex; final Map<int, List<int>>? rowFlex;
final bool compact; final bool compact;
/// Répartit la hauteur dispo entre les lignes (remplit le blanc sans scroll).
final bool expandVertically;
const ValidationFormGrid({ const ValidationFormGrid({
super.key, super.key,
@@ -57,6 +112,7 @@ class ValidationFormGrid extends StatelessWidget {
this.rowLayout, this.rowLayout,
this.rowFlex, this.rowFlex,
this.compact = false, this.compact = false,
this.expandVertically = false,
}); });
@override @override
@@ -64,7 +120,7 @@ class ValidationFormGrid extends StatelessWidget {
final layout = rowLayout ?? List.filled(fields.length, 1); final layout = rowLayout ?? List.filled(fields.length, 1);
int index = 0; int index = 0;
int rowIndex = 0; int rowIndex = 0;
final rows = <Widget>[]; final rowWidgets = <Widget>[];
for (final count in layout) { for (final count in layout) {
if (index >= fields.length) break; if (index >= fields.length) break;
final rowFields = fields.skip(index).take(count).toList(); final rowFields = fields.skip(index).take(count).toList();
@@ -72,48 +128,73 @@ class ValidationFormGrid extends StatelessWidget {
if (rowFields.isEmpty) continue; if (rowFields.isEmpty) continue;
final flexForRow = rowFlex?[rowIndex]; final flexForRow = rowFlex?[rowIndex];
rowIndex++; rowIndex++;
final labeled = rowFields
.map(
(f) => ValidationLabeledField(
label: f.label,
field: f.field,
expand: expandVertically,
),
)
.toList();
Widget row;
if (count == 1) { if (count == 1) {
rows.add(Padding( row = labeled.first;
padding: EdgeInsets.only(bottom: compact ? 8 : 12),
child: rowFields.first,
));
} else { } else {
rows.add(Padding( row = Row(
padding: EdgeInsets.only(bottom: compact ? 8 : 12), crossAxisAlignment: expandVertically
child: Row( ? CrossAxisAlignment.stretch
crossAxisAlignment: CrossAxisAlignment.start, : CrossAxisAlignment.start,
children: [ children: [
for (int i = 0; i < rowFields.length; i++) ...[ for (int i = 0; i < labeled.length; i++) ...[
if (i > 0) SizedBox(width: compact ? 12 : 16), if (i > 0) SizedBox(width: compact ? 12 : 16),
Expanded( Expanded(
flex: (flexForRow != null && i < flexForRow.length) flex: (flexForRow != null && i < flexForRow.length)
? flexForRow[i] ? flexForRow[i]
: 1, : 1,
child: rowFields[i], child: labeled[i],
), ),
], ],
], ],
);
}
if (expandVertically) {
rowWidgets.add(Expanded(child: row));
} else {
rowWidgets.add(Padding(
padding: EdgeInsets.only(
bottom: compact ? 8 : ValidationFormMetrics.rowGapBelow,
), ),
child: row,
)); ));
} }
} }
final showTitle = title != null && title!.trim().isNotEmpty; final showTitle = title != null && title!.trim().isNotEmpty;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min, mainAxisSize: expandVertically ? MainAxisSize.max : MainAxisSize.min,
children: [ children: [
if (showTitle) ...[ if (showTitle) ...[
Text( Text(
title!.trim(), title!.trim(),
style: TextStyle( style: TextStyle(
fontSize: compact ? 15 : 16, fontSize: compact
? ValidationFormMetrics.sectionTitleFontSize - 1
: ValidationFormMetrics.sectionTitleFontSize,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: Colors.black87, color: Colors.black87,
), ),
), ),
SizedBox(height: compact ? 8 : 12), SizedBox(
height: compact
? 8
: ValidationFormMetrics.sectionTitleGapBelow,
),
], ],
...rows, ...rowWidgets,
], ],
); );
} }
@@ -130,8 +211,8 @@ class ValidationFieldDecoration {
fillColor: Colors.grey.shade50, fillColor: Colors.grey.shade50,
hintText: hint, hintText: hint,
contentPadding: EdgeInsets.symmetric( contentPadding: EdgeInsets.symmetric(
horizontal: compact ? 10 : 12, horizontal: compact ? 10 : ValidationFormMetrics.fieldContentPaddingH,
vertical: compact ? 7 : 10, vertical: compact ? 7 : ValidationFormMetrics.fieldContentPaddingV,
), ),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(6),
@@ -180,29 +261,31 @@ class ValidationFieldDecoration {
class ValidationLabeledField extends StatelessWidget { class ValidationLabeledField extends StatelessWidget {
final String label; final String label;
final Widget field; final Widget field;
final bool expand;
const ValidationLabeledField({ const ValidationLabeledField({
super.key, super.key,
required this.label, required this.label,
required this.field, required this.field,
this.expand = false,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: expand ? MainAxisSize.max : MainAxisSize.min,
children: [ children: [
Text( Text(
label, label,
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: ValidationFormMetrics.fieldLabelFontSize,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: Colors.grey.shade700, color: Colors.grey.shade700,
), ),
), ),
const SizedBox(height: 4), SizedBox(height: ValidationFormMetrics.fieldLabelGapBelow),
field, if (expand) Expanded(child: field) else field,
], ],
); );
} }
@@ -264,13 +347,16 @@ class ValidationEditableField extends StatelessWidget {
); );
} }
if (!compact) { if (!compact) {
return TextField( return _validationFieldFillHeight(
TextField(
controller: controller, controller: controller,
keyboardType: keyboardType, keyboardType: keyboardType,
inputFormatters: inputFormatters, inputFormatters: inputFormatters,
maxLines: 1, maxLines: 1,
style: const TextStyle(color: Colors.black87, fontSize: 14), textAlignVertical: TextAlignVertical.center,
style: ValidationFormMetrics.fieldTextStyle,
decoration: ValidationFieldDecoration.input(hint: hintText), decoration: ValidationFieldDecoration.input(hint: hintText),
),
); );
} }
return SizedBox( return SizedBox(
@@ -295,6 +381,347 @@ class ValidationEditableField extends StatelessWidget {
} }
} }
/// Étire le champ si le parent impose une hauteur (grille [expandVertically]).
Widget _validationFieldFillHeight(Widget field) {
return LayoutBuilder(
builder: (context, c) {
if (!c.hasBoundedHeight || !c.maxHeight.isFinite) return field;
return SizedBox(
height: c.maxHeight,
width: double.infinity,
child: field,
);
},
);
}
/// E-mail style validation — même supervision que login / création de compte :
/// [EmailMaxLengthFormatter] + à la perte de focus : trim/minuscules + validation.
class ValidationEmailField extends StatefulWidget {
final TextEditingController controller;
final String? hintText;
final bool allowEmpty;
const ValidationEmailField({
super.key,
required this.controller,
this.hintText,
this.allowEmpty = false,
});
@override
State<ValidationEmailField> createState() => _ValidationEmailFieldState();
}
class _ValidationEmailFieldState extends State<ValidationEmailField> {
final GlobalKey<FormFieldState<String>> _fieldKey =
GlobalKey<FormFieldState<String>>();
late final FocusNode _focusNode;
@override
void initState() {
super.initState();
_focusNode = FocusNode();
_focusNode.addListener(_onFocusChange);
}
void _onFocusChange() {
if (_focusNode.hasFocus) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _focusNode.hasFocus) return;
final c = widget.controller;
final normalized = normalizeEmailText(c.text);
if (normalized != c.text) {
c.value = TextEditingValue(
text: normalized,
selection: TextSelection.collapsed(offset: normalized.length),
);
}
_fieldKey.currentState?.validate();
});
}
@override
void dispose() {
_focusNode.removeListener(_onFocusChange);
_focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return _validationFieldFillHeight(
TextFormField(
key: _fieldKey,
controller: widget.controller,
focusNode: _focusNode,
keyboardType: TextInputType.emailAddress,
autocorrect: false,
enableSuggestions: false,
autofillHints: const [AutofillHints.email],
textInputAction: TextInputAction.next,
textAlignVertical: TextAlignVertical.center,
inputFormatters: const [EmailMaxLengthFormatter()],
style: ValidationFormMetrics.fieldTextStyle,
decoration:
ValidationFieldDecoration.input(hint: widget.hintText).copyWith(
errorStyle: TextStyle(color: Colors.red.shade700, fontSize: 11),
errorMaxLines: 2,
),
validator: (value) =>
validateEmail(value, allowEmpty: widget.allowEmpty),
),
);
}
}
/// Code postal FR — même supervision que création de compte :
/// chiffres uniquement (max 5) + validation à la perte de focus.
class ValidationPostalCodeField extends StatefulWidget {
final TextEditingController controller;
final String? hintText;
final bool allowEmpty;
const ValidationPostalCodeField({
super.key,
required this.controller,
this.hintText,
this.allowEmpty = false,
});
@override
State<ValidationPostalCodeField> createState() =>
_ValidationPostalCodeFieldState();
}
class _ValidationPostalCodeFieldState extends State<ValidationPostalCodeField> {
final GlobalKey<FormFieldState<String>> _fieldKey =
GlobalKey<FormFieldState<String>>();
late final FocusNode _focusNode;
@override
void initState() {
super.initState();
_focusNode = FocusNode();
_focusNode.addListener(_onFocusChange);
}
void _onFocusChange() {
if (_focusNode.hasFocus) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _focusNode.hasFocus) return;
final c = widget.controller;
final trimmed = c.text.trim();
if (trimmed != c.text) {
c.value = TextEditingValue(
text: trimmed,
selection: TextSelection.collapsed(offset: trimmed.length),
);
}
_fieldKey.currentState?.validate();
});
}
@override
void dispose() {
_focusNode.removeListener(_onFocusChange);
_focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return _validationFieldFillHeight(
TextFormField(
key: _fieldKey,
controller: widget.controller,
focusNode: _focusNode,
keyboardType: TextInputType.number,
textInputAction: TextInputAction.next,
textAlignVertical: TextAlignVertical.center,
inputFormatters: kFrenchPostalCodeInputFormatters,
style: ValidationFormMetrics.fieldTextStyle,
decoration: ValidationFieldDecoration.input(
hint: widget.hintText ?? '5 chiffres',
).copyWith(
errorStyle: TextStyle(color: Colors.red.shade700, fontSize: 11),
errorMaxLines: 2,
),
validator: (value) =>
validateFrenchPostalCode(value, allowEmpty: widget.allowEmpty),
),
);
}
}
/// Téléphone FR — formatters live + [validateFrenchNationalPhone] à la perte de focus.
class ValidationPhoneField extends StatefulWidget {
final TextEditingController controller;
final String? hintText;
final bool allowEmpty;
const ValidationPhoneField({
super.key,
required this.controller,
this.hintText,
this.allowEmpty = false,
});
@override
State<ValidationPhoneField> createState() => _ValidationPhoneFieldState();
}
class _ValidationPhoneFieldState extends State<ValidationPhoneField> {
final GlobalKey<FormFieldState<String>> _fieldKey =
GlobalKey<FormFieldState<String>>();
late final FocusNode _focusNode;
@override
void initState() {
super.initState();
_focusNode = FocusNode();
_focusNode.addListener(_onFocusChange);
}
void _onFocusChange() {
if (_focusNode.hasFocus) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _focusNode.hasFocus) return;
final c = widget.controller;
final digits = normalizePhone(c.text);
final formatted = digits.isEmpty ? '' : formatPhoneForDisplay(digits);
if (formatted != c.text) {
c.value = TextEditingValue(
text: formatted,
selection: TextSelection.collapsed(offset: formatted.length),
);
}
_fieldKey.currentState?.validate();
});
}
@override
void dispose() {
_focusNode.removeListener(_onFocusChange);
_focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return _validationFieldFillHeight(
TextFormField(
key: _fieldKey,
controller: widget.controller,
focusNode: _focusNode,
keyboardType: TextInputType.phone,
textInputAction: TextInputAction.next,
textAlignVertical: TextAlignVertical.center,
inputFormatters: frenchPhoneInputFormatters,
style: ValidationFormMetrics.fieldTextStyle,
decoration:
ValidationFieldDecoration.input(hint: widget.hintText).copyWith(
errorStyle: TextStyle(color: Colors.red.shade700, fontSize: 11),
errorMaxLines: 2,
),
validator: (value) =>
validateFrenchNationalPhone(value, allowEmpty: widget.allowEmpty),
),
);
}
}
/// NIR — formatage live ([NirInputFormatter]) + validation au fil de la saisie / blur.
class ValidationNirField extends StatefulWidget {
final TextEditingController controller;
final String? hintText;
final bool allowEmpty;
const ValidationNirField({
super.key,
required this.controller,
this.hintText,
this.allowEmpty = false,
});
@override
State<ValidationNirField> createState() => _ValidationNirFieldState();
}
class _ValidationNirFieldState extends State<ValidationNirField> {
final GlobalKey<FormFieldState<String>> _fieldKey =
GlobalKey<FormFieldState<String>>();
late final FocusNode _focusNode;
bool _blurred = false;
@override
void initState() {
super.initState();
_focusNode = FocusNode();
_focusNode.addListener(_onFocusChange);
}
void _onFocusChange() {
if (_focusNode.hasFocus) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _focusNode.hasFocus) return;
setState(() => _blurred = true);
final c = widget.controller;
final raw = nirToRaw(c.text).toUpperCase();
final formatted = raw.isEmpty ? '' : formatNir(raw);
if (formatted != c.text) {
c.value = TextEditingValue(
text: formatted,
selection: TextSelection.collapsed(offset: formatted.length),
);
}
_fieldKey.currentState?.validate();
});
}
@override
void dispose() {
_focusNode.removeListener(_onFocusChange);
_focusNode.dispose();
super.dispose();
}
String? _validator(String? value) {
if (_blurred) {
if (widget.allowEmpty && (value == null || value.trim().isEmpty)) {
return null;
}
return validateNir(value);
}
return validateNirTyping(value);
}
@override
Widget build(BuildContext context) {
return _validationFieldFillHeight(
TextFormField(
key: _fieldKey,
controller: widget.controller,
focusNode: _focusNode,
keyboardType: TextInputType.text,
textInputAction: TextInputAction.next,
textAlignVertical: TextAlignVertical.center,
autovalidateMode: AutovalidateMode.onUserInteraction,
inputFormatters: const [NirInputFormatter()],
style: ValidationFormMetrics.fieldTextStyle,
decoration: ValidationFieldDecoration.input(
hint: widget.hintText ?? '1 12 34 56 789 012 - 34',
).copyWith(
errorStyle: TextStyle(color: Colors.red.shade700, fontSize: 11),
errorMaxLines: 2,
),
onChanged: (_) => _fieldKey.currentState?.validate(),
validator: _validator,
),
);
}
}
/// Grille label/champ éditable (délègue à [ValidationFormGrid]). /// Grille label/champ éditable (délègue à [ValidationFormGrid]).
class ValidationEditableSection extends StatelessWidget { class ValidationEditableSection extends StatelessWidget {
final List<ValidationLabeledField> fields; final List<ValidationLabeledField> fields;
@@ -368,16 +795,19 @@ class _ValidationReadOnlyFieldState extends State<ValidationReadOnlyField> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (!widget.compact && widget.maxLines == 1) { if (!widget.compact && widget.maxLines == 1) {
return TextField( return _validationFieldFillHeight(
TextField(
controller: _controller, controller: _controller,
readOnly: true, readOnly: true,
enableInteractiveSelection: false, enableInteractiveSelection: false,
textAlignVertical: TextAlignVertical.center,
style: TextStyle( style: TextStyle(
color: widget.error ? Colors.red.shade800 : Colors.black87, color: widget.error ? Colors.red.shade800 : Colors.black87,
fontSize: 14, fontSize: ValidationFormMetrics.fieldTextFontSize,
fontWeight: widget.error ? FontWeight.w600 : null, fontWeight: widget.error ? FontWeight.w600 : null,
), ),
decoration: ValidationFieldDecoration.readOnly(error: widget.error), decoration: ValidationFieldDecoration.readOnly(error: widget.error),
),
); );
} }
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/dossier_unifie.dart'; import 'package:p_tits_pas/models/dossier_unifie.dart';
import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/am_dossier_wizard.dart';
import 'package:p_tits_pas/widgets/admin/validation_am_wizard.dart'; import 'package:p_tits_pas/widgets/admin/validation_am_wizard.dart';
import 'package:p_tits_pas/widgets/admin/validation_family_wizard.dart'; import 'package:p_tits_pas/widgets/admin/validation_family_wizard.dart';
@@ -76,8 +77,13 @@ class _ValidationDossierModalState extends State<ValidationDossierModal> {
/// Largeur modale = 1,5 × 620. /// Largeur modale = 1,5 × 620.
static const double _modalWidth = 930; // 620 * 1.5 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 _familyBodyHeight = 435;
static const double _bodyHeight = 435;
double get _bodyHeight {
final d = _dossier;
if (d != null && d.isAm) return AmDossierWizard.shellBodyHeight;
return _familyBodyHeight;
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
+27 -17
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/dossier_unifie.dart'; import 'package:p_tits_pas/models/dossier_unifie.dart';
import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/utils/name_format_utils.dart';
import 'package:p_tits_pas/utils/phone_utils.dart'; import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
@@ -67,7 +68,9 @@ class IdentityValues {
/// Bloc identité : Nom/Prénom, Tél/Email, Adresse, CP/Ville. /// Bloc identité : Nom/Prénom, Tél/Email, Adresse, CP/Ville.
/// Grille partagée (création de compte, validation AM/famille, fiches admin, etc.). /// Grille partagée (création de compte, validation AM/famille, fiches admin, etc.).
class IdentityBlock extends StatelessWidget { final String? title; class IdentityBlock extends StatelessWidget {
final String? title;
final bool expandVertically;
final String? _nom; final String? _nom;
final String? _prenom; final String? _prenom;
@@ -91,6 +94,7 @@ class IdentityBlock extends StatelessWidget { final String? title;
const IdentityBlock.readOnly({ const IdentityBlock.readOnly({
super.key, super.key,
this.title, this.title,
this.expandVertically = false,
required String nom, required String nom,
required String prenom, required String prenom,
required String telephone, required String telephone,
@@ -116,6 +120,7 @@ class IdentityBlock extends StatelessWidget { final String? title;
const IdentityBlock.editable({ const IdentityBlock.editable({
super.key, super.key,
this.title, this.title,
this.expandVertically = false,
required TextEditingController nomController, required TextEditingController nomController,
required TextEditingController prenomController, required TextEditingController prenomController,
required TextEditingController telephoneController, required TextEditingController telephoneController,
@@ -142,11 +147,13 @@ class IdentityBlock extends StatelessWidget { final String? title;
factory IdentityBlock.readOnlyValues({ factory IdentityBlock.readOnlyValues({
Key? key, Key? key,
String? title, String? title,
bool expandVertically = false,
required IdentityValues values, required IdentityValues values,
}) { }) {
return IdentityBlock.readOnly( return IdentityBlock.readOnly(
key: key, key: key,
title: title, title: title,
expandVertically: expandVertically,
nom: values.nom, nom: values.nom,
prenom: values.prenom, prenom: values.prenom,
telephone: values.telephone, telephone: values.telephone,
@@ -163,10 +170,12 @@ class IdentityBlock extends StatelessWidget { final String? title;
Key? key, Key? key,
String? title, String? title,
String emptyLabel = 'Non défini', String emptyLabel = 'Non défini',
bool expandVertically = false,
}) { }) {
return IdentityBlock.readOnlyValues( return IdentityBlock.readOnlyValues(
key: key, key: key,
title: title, title: title,
expandVertically: expandVertically,
values: IdentityValues.fromUser(user, emptyLabel: emptyLabel), values: IdentityValues.fromUser(user, emptyLabel: emptyLabel),
); );
} }
@@ -196,29 +205,29 @@ class IdentityBlock extends StatelessWidget { final String? title;
title: title, title: title,
rowLayout: rowLayout, rowLayout: rowLayout,
rowFlex: rowFlex, rowFlex: rowFlex,
expandVertically: expandVertically,
fields: [ fields: [
ValidationLabeledField( ValidationLabeledField(
label: 'Nom', label: 'Nom',
field: ValidationEditableField(controller: _nomCtrl!), field: ValidationEditableField(
controller: _nomCtrl!,
inputFormatters: const [PersonNameInputFormatter()],
),
), ),
ValidationLabeledField( ValidationLabeledField(
label: 'Prénom', label: 'Prénom',
field: ValidationEditableField(controller: _prenomCtrl!), field: ValidationEditableField(
controller: _prenomCtrl!,
inputFormatters: const [PersonNameInputFormatter()],
),
), ),
ValidationLabeledField( ValidationLabeledField(
label: 'Téléphone', label: 'Téléphone',
field: ValidationEditableField( field: ValidationPhoneField(controller: _telCtrl!),
controller: _telCtrl!,
keyboardType: TextInputType.phone,
inputFormatters: frenchPhoneInputFormatters,
),
), ),
ValidationLabeledField( ValidationLabeledField(
label: 'Email', label: 'Email',
field: ValidationEditableField( field: ValidationEmailField(controller: _emailCtrl!),
controller: _emailCtrl!,
keyboardType: TextInputType.emailAddress,
),
), ),
ValidationLabeledField( ValidationLabeledField(
label: 'Adresse (N° et Rue)', label: 'Adresse (N° et Rue)',
@@ -226,14 +235,14 @@ class IdentityBlock extends StatelessWidget { final String? title;
), ),
ValidationLabeledField( ValidationLabeledField(
label: 'Code postal', label: 'Code postal',
field: ValidationEditableField( field: ValidationPostalCodeField(controller: _cpCtrl!),
controller: _cpCtrl!,
keyboardType: TextInputType.number,
),
), ),
ValidationLabeledField( ValidationLabeledField(
label: 'Ville', label: 'Ville',
field: ValidationEditableField(controller: _villeCtrl!), field: ValidationEditableField(
controller: _villeCtrl!,
inputFormatters: const [PersonNameInputFormatter()],
),
), ),
], ],
); );
@@ -243,6 +252,7 @@ class IdentityBlock extends StatelessWidget { final String? title;
title: title, title: title,
rowLayout: rowLayout, rowLayout: rowLayout,
rowFlex: rowFlex, rowFlex: rowFlex,
expandVertically: expandVertically,
fields: [ fields: [
ValidationLabeledField( ValidationLabeledField(
label: 'Nom', label: 'Nom',
+1 -1
View File
@@ -54,7 +54,7 @@ class NirTextField extends StatelessWidget {
inputFontSize: inputFontSize, inputFontSize: inputFontSize,
keyboardType: TextInputType.text, keyboardType: TextInputType.text,
validator: validator ?? validateNir, validator: validator ?? validateNir,
inputFormatters: [NirInputFormatter()], inputFormatters: const [NirInputFormatter()],
enabled: enabled, enabled: enabled,
readOnly: readOnly, readOnly: readOnly,
style: style, style: style,