Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ee2ca8ea6 | ||
|
|
e3552667bc | ||
|
|
4ae334b247 |
@@ -49,12 +49,21 @@ String nirToRaw(String normalized) {
|
||||
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) {
|
||||
final r = nirToRaw(raw);
|
||||
if (r.length < 15) return r;
|
||||
// Même structure pour tous : sexe + année + mois + département + commune + ordre-clé.
|
||||
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)}';
|
||||
final r = nirToRaw(raw).toUpperCase();
|
||||
if (r.isEmpty) return '';
|
||||
final buf = StringBuffer();
|
||||
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 1–3, département 2A ou 2B pour la Corse.
|
||||
@@ -92,20 +101,39 @@ String? validateNir(String? value) {
|
||||
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 d’erreur 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 {
|
||||
const NirInputFormatter();
|
||||
|
||||
@override
|
||||
TextEditingValue formatEditUpdate(
|
||||
TextEditingValue oldValue,
|
||||
TextEditingValue newValue,
|
||||
) {
|
||||
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 offset = formatted.length;
|
||||
return TextEditingValue(
|
||||
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 _bodyHeight = 435;
|
||||
/// Hauteur calculée depuis 4 lignes de TF (voir [AmDossierWizard.shellBodyHeight]).
|
||||
static double get _bodyHeight => AmDossierWizard.shellBodyHeight;
|
||||
|
||||
@override
|
||||
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/utils/date_display_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/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_detail_modal.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;
|
||||
|
||||
/// Hauteur corps modale AM — dérivée de [ValidationFormMetrics] (4 lignes).
|
||||
static double get shellBodyHeight =>
|
||||
ValidationFormMetrics.shellBodyHeightForRows(4);
|
||||
|
||||
@override
|
||||
State<AmDossierWizard> createState() => _AmDossierWizardState();
|
||||
}
|
||||
@@ -361,7 +367,8 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
|
||||
if (_adresseCtrl.text.trim().isEmpty) {
|
||||
return 'L’adresse 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.';
|
||||
return null;
|
||||
}
|
||||
@@ -466,21 +473,24 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
|
||||
|
||||
return <String, dynamic>{
|
||||
'email': normalizeEmailText(_emailCtrl.text),
|
||||
'prenom': _prenomCtrl.text.trim(),
|
||||
'nom': _nomCtrl.text.trim(),
|
||||
'prenom': formatPersonNameCase(_prenomCtrl.text),
|
||||
'nom': formatPersonNameCase(_nomCtrl.text),
|
||||
'telephone': normalizePhone(_telCtrl.text),
|
||||
'adresse':
|
||||
_adresseCtrl.text.trim().isNotEmpty ? _adresseCtrl.text.trim() : null,
|
||||
'code_postal':
|
||||
_cpCtrl.text.trim().isNotEmpty ? _cpCtrl.text.trim() : null,
|
||||
'ville':
|
||||
_villeCtrl.text.trim().isNotEmpty ? _villeCtrl.text.trim() : null,
|
||||
'ville': _villeCtrl.text.trim().isNotEmpty
|
||||
? formatPersonNameCase(_villeCtrl.text)
|
||||
: null,
|
||||
'photo_base64': photoBase64,
|
||||
'photo_filename': fn.isNotEmpty ? fn : 'photo_am.jpg',
|
||||
'consentement_photo': true,
|
||||
'date_naissance': birthIso,
|
||||
'lieu_naissance_ville': _lieuNaissanceVilleCtrl.text.trim(),
|
||||
'lieu_naissance_pays': _lieuNaissancePaysCtrl.text.trim(),
|
||||
'lieu_naissance_ville':
|
||||
formatPersonNameCase(_lieuNaissanceVilleCtrl.text),
|
||||
'lieu_naissance_pays':
|
||||
formatPersonNameCase(_lieuNaissancePaysCtrl.text),
|
||||
'nir': normalizeNir(_nirCtrl.text),
|
||||
'numero_agrement': _agrementCtrl.text.trim(),
|
||||
'date_agrement': agrementIso,
|
||||
@@ -578,56 +588,45 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
|
||||
|
||||
Widget _buildStep0() {
|
||||
if (_isCreate) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return SingleChildScrollView(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minWidth: constraints.maxWidth),
|
||||
child: IdentityBlock.editable(
|
||||
title: 'Identité et coordonnées',
|
||||
nomController: _nomCtrl,
|
||||
prenomController: _prenomCtrl,
|
||||
telephoneController: _telCtrl,
|
||||
emailController: _emailCtrl,
|
||||
adresseController: _adresseCtrl,
|
||||
codePostalController: _cpCtrl,
|
||||
villeController: _villeCtrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
return IdentityBlock.editable(
|
||||
title: 'Identité et coordonnées',
|
||||
nomController: _nomCtrl,
|
||||
prenomController: _prenomCtrl,
|
||||
telephoneController: _telCtrl,
|
||||
emailController: _emailCtrl,
|
||||
adresseController: _adresseCtrl,
|
||||
codePostalController: _cpCtrl,
|
||||
villeController: _villeCtrl,
|
||||
);
|
||||
}
|
||||
final u = _dossier.user;
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return SingleChildScrollView(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minWidth: constraints.maxWidth),
|
||||
child: IdentityBlock.readOnlyFromUser(
|
||||
u,
|
||||
title: 'Identité et coordonnées',
|
||||
emptyLabel: '–',
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
return IdentityBlock.readOnlyFromUser(
|
||||
u,
|
||||
title: 'Identité et coordonnées',
|
||||
emptyLabel: '–',
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStep1() {
|
||||
// Modale calée sur les TF ; photo étirée sur toute la hauteur utile
|
||||
// (largeur = [AdminAmPhotoFrame.columnWidthForHeight], cadre inclus).
|
||||
return LayoutBuilder(
|
||||
builder: (context, c) {
|
||||
final maxRowW = c.maxWidth;
|
||||
final maxRowH = c.maxHeight;
|
||||
const photoHeaderH = 0.0;
|
||||
final bodyH = (maxRowH - photoHeaderH).clamp(0.0, double.infinity);
|
||||
final idealPhotoW = bodyH * _idPhotoAspectRatio + 16;
|
||||
final maxRowH = c.maxHeight.clamp(0.0, double.infinity);
|
||||
final maxPhotoW = (maxRowW - _photoProGap - _proColumnMinWidth)
|
||||
.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;
|
||||
photoW = photoW.clamp(0.0, maxRowW - _photoProGap);
|
||||
|
||||
final form = _isCreate
|
||||
? _buildCreateProFields()
|
||||
: ValidationDetailSection(
|
||||
title: 'Dossier professionnel',
|
||||
fields: _photoProFields(_dossier),
|
||||
rowLayout: _photoProRowLayout,
|
||||
);
|
||||
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
@@ -645,22 +644,9 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
|
||||
),
|
||||
const SizedBox(width: _photoProGap),
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return SingleChildScrollView(
|
||||
child: ConstrainedBox(
|
||||
constraints:
|
||||
BoxConstraints(minWidth: constraints.maxWidth),
|
||||
child: _isCreate
|
||||
? _buildCreateProFields()
|
||||
: ValidationDetailSection(
|
||||
title: 'Dossier professionnel',
|
||||
fields: _photoProFields(_dossier),
|
||||
rowLayout: _photoProRowLayout,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: form,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -676,11 +662,7 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
|
||||
fields: [
|
||||
ValidationLabeledField(
|
||||
label: 'NIR',
|
||||
field: ValidationEditableField(
|
||||
controller: _nirCtrl,
|
||||
hintText: '15 caractères',
|
||||
inputFormatters: [NirInputFormatter()],
|
||||
),
|
||||
field: ValidationNirField(controller: _nirCtrl),
|
||||
),
|
||||
ValidationLabeledField(
|
||||
label: 'Date de naissance',
|
||||
@@ -695,12 +677,14 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
|
||||
label: 'Ville de naissance',
|
||||
field: ValidationEditableField(
|
||||
controller: _lieuNaissanceVilleCtrl,
|
||||
inputFormatters: const [PersonNameInputFormatter()],
|
||||
),
|
||||
),
|
||||
ValidationLabeledField(
|
||||
label: 'Pays de naissance',
|
||||
field: ValidationEditableField(
|
||||
controller: _lieuNaissancePaysCtrl,
|
||||
inputFormatters: const [PersonNameInputFormatter()],
|
||||
),
|
||||
),
|
||||
ValidationLabeledField(
|
||||
|
||||
@@ -1,7 +1,55 @@
|
||||
import 'package:flutter/material.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';
|
||||
|
||||
/// 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.
|
||||
/// [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).
|
||||
@@ -16,12 +64,16 @@ class ValidationDetailSection extends StatelessWidget {
|
||||
/// Flex par ligne (index de ligne -> [flex1, flex2, ...]). Ex. {3: [2, 5]} pour Code postal | Ville.
|
||||
final Map<int, List<int>>? rowFlex;
|
||||
|
||||
/// Remplit la hauteur disponible (wizard AM étapes 1–2).
|
||||
final bool expandVertically;
|
||||
|
||||
const ValidationDetailSection({
|
||||
super.key,
|
||||
this.title,
|
||||
required this.fields,
|
||||
this.rowLayout,
|
||||
this.rowFlex,
|
||||
this.expandVertically = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -30,6 +82,7 @@ class ValidationDetailSection extends StatelessWidget {
|
||||
title: title,
|
||||
rowLayout: rowLayout,
|
||||
rowFlex: rowFlex,
|
||||
expandVertically: expandVertically,
|
||||
fields: fields
|
||||
.map(
|
||||
(f) => ValidationLabeledField(
|
||||
@@ -49,6 +102,8 @@ class ValidationFormGrid extends StatelessWidget {
|
||||
final List<int>? rowLayout;
|
||||
final Map<int, List<int>>? rowFlex;
|
||||
final bool compact;
|
||||
/// Répartit la hauteur dispo entre les lignes (remplit le blanc sans scroll).
|
||||
final bool expandVertically;
|
||||
|
||||
const ValidationFormGrid({
|
||||
super.key,
|
||||
@@ -57,6 +112,7 @@ class ValidationFormGrid extends StatelessWidget {
|
||||
this.rowLayout,
|
||||
this.rowFlex,
|
||||
this.compact = false,
|
||||
this.expandVertically = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -64,7 +120,7 @@ class ValidationFormGrid extends StatelessWidget {
|
||||
final layout = rowLayout ?? List.filled(fields.length, 1);
|
||||
int index = 0;
|
||||
int rowIndex = 0;
|
||||
final rows = <Widget>[];
|
||||
final rowWidgets = <Widget>[];
|
||||
for (final count in layout) {
|
||||
if (index >= fields.length) break;
|
||||
final rowFields = fields.skip(index).take(count).toList();
|
||||
@@ -72,48 +128,73 @@ class ValidationFormGrid extends StatelessWidget {
|
||||
if (rowFields.isEmpty) continue;
|
||||
final flexForRow = rowFlex?[rowIndex];
|
||||
rowIndex++;
|
||||
final labeled = rowFields
|
||||
.map(
|
||||
(f) => ValidationLabeledField(
|
||||
label: f.label,
|
||||
field: f.field,
|
||||
expand: expandVertically,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
Widget row;
|
||||
if (count == 1) {
|
||||
rows.add(Padding(
|
||||
padding: EdgeInsets.only(bottom: compact ? 8 : 12),
|
||||
child: rowFields.first,
|
||||
));
|
||||
row = labeled.first;
|
||||
} else {
|
||||
rows.add(Padding(
|
||||
padding: EdgeInsets.only(bottom: compact ? 8 : 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (int i = 0; i < rowFields.length; i++) ...[
|
||||
if (i > 0) SizedBox(width: compact ? 12 : 16),
|
||||
Expanded(
|
||||
flex: (flexForRow != null && i < flexForRow.length)
|
||||
? flexForRow[i]
|
||||
: 1,
|
||||
child: rowFields[i],
|
||||
),
|
||||
],
|
||||
row = Row(
|
||||
crossAxisAlignment: expandVertically
|
||||
? CrossAxisAlignment.stretch
|
||||
: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (int i = 0; i < labeled.length; i++) ...[
|
||||
if (i > 0) SizedBox(width: compact ? 12 : 16),
|
||||
Expanded(
|
||||
flex: (flexForRow != null && i < flexForRow.length)
|
||||
? flexForRow[i]
|
||||
: 1,
|
||||
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;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisSize: expandVertically ? MainAxisSize.max : MainAxisSize.min,
|
||||
children: [
|
||||
if (showTitle) ...[
|
||||
Text(
|
||||
title!.trim(),
|
||||
style: TextStyle(
|
||||
fontSize: compact ? 15 : 16,
|
||||
fontSize: compact
|
||||
? ValidationFormMetrics.sectionTitleFontSize - 1
|
||||
: ValidationFormMetrics.sectionTitleFontSize,
|
||||
fontWeight: FontWeight.w600,
|
||||
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,
|
||||
hintText: hint,
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: compact ? 10 : 12,
|
||||
vertical: compact ? 7 : 10,
|
||||
horizontal: compact ? 10 : ValidationFormMetrics.fieldContentPaddingH,
|
||||
vertical: compact ? 7 : ValidationFormMetrics.fieldContentPaddingV,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
@@ -180,29 +261,31 @@ class ValidationFieldDecoration {
|
||||
class ValidationLabeledField extends StatelessWidget {
|
||||
final String label;
|
||||
final Widget field;
|
||||
final bool expand;
|
||||
|
||||
const ValidationLabeledField({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.field,
|
||||
this.expand = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisSize: expand ? MainAxisSize.max : MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontSize: ValidationFormMetrics.fieldLabelFontSize,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.grey.shade700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
field,
|
||||
SizedBox(height: ValidationFormMetrics.fieldLabelGapBelow),
|
||||
if (expand) Expanded(child: field) else field,
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -264,13 +347,16 @@ class ValidationEditableField extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
if (!compact) {
|
||||
return TextField(
|
||||
controller: controller,
|
||||
keyboardType: keyboardType,
|
||||
inputFormatters: inputFormatters,
|
||||
maxLines: 1,
|
||||
style: const TextStyle(color: Colors.black87, fontSize: 14),
|
||||
decoration: ValidationFieldDecoration.input(hint: hintText),
|
||||
return _validationFieldFillHeight(
|
||||
TextField(
|
||||
controller: controller,
|
||||
keyboardType: keyboardType,
|
||||
inputFormatters: inputFormatters,
|
||||
maxLines: 1,
|
||||
textAlignVertical: TextAlignVertical.center,
|
||||
style: ValidationFormMetrics.fieldTextStyle,
|
||||
decoration: ValidationFieldDecoration.input(hint: hintText),
|
||||
),
|
||||
);
|
||||
}
|
||||
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]).
|
||||
class ValidationEditableSection extends StatelessWidget {
|
||||
final List<ValidationLabeledField> fields;
|
||||
@@ -368,16 +795,19 @@ class _ValidationReadOnlyFieldState extends State<ValidationReadOnlyField> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!widget.compact && widget.maxLines == 1) {
|
||||
return TextField(
|
||||
controller: _controller,
|
||||
readOnly: true,
|
||||
enableInteractiveSelection: false,
|
||||
style: TextStyle(
|
||||
color: widget.error ? Colors.red.shade800 : Colors.black87,
|
||||
fontSize: 14,
|
||||
fontWeight: widget.error ? FontWeight.w600 : null,
|
||||
return _validationFieldFillHeight(
|
||||
TextField(
|
||||
controller: _controller,
|
||||
readOnly: true,
|
||||
enableInteractiveSelection: false,
|
||||
textAlignVertical: TextAlignVertical.center,
|
||||
style: TextStyle(
|
||||
color: widget.error ? Colors.red.shade800 : Colors.black87,
|
||||
fontSize: ValidationFormMetrics.fieldTextFontSize,
|
||||
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:p_tits_pas/models/dossier_unifie.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_family_wizard.dart';
|
||||
|
||||
@@ -76,8 +77,13 @@ class _ValidationDossierModalState extends State<ValidationDossierModal> {
|
||||
|
||||
/// 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;
|
||||
static const double _familyBodyHeight = 435;
|
||||
|
||||
double get _bodyHeight {
|
||||
final d = _dossier;
|
||||
if (d != null && d.isAm) return AmDossierWizard.shellBodyHeight;
|
||||
return _familyBodyHeight;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:p_tits_pas/models/dossier_unifie.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/widgets/admin/common/validation_detail_section.dart';
|
||||
|
||||
@@ -67,7 +68,9 @@ class IdentityValues {
|
||||
|
||||
/// Bloc identité : Nom/Prénom, Tél/Email, Adresse, CP/Ville.
|
||||
/// 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? _prenom;
|
||||
@@ -91,6 +94,7 @@ class IdentityBlock extends StatelessWidget { final String? title;
|
||||
const IdentityBlock.readOnly({
|
||||
super.key,
|
||||
this.title,
|
||||
this.expandVertically = false,
|
||||
required String nom,
|
||||
required String prenom,
|
||||
required String telephone,
|
||||
@@ -116,6 +120,7 @@ class IdentityBlock extends StatelessWidget { final String? title;
|
||||
const IdentityBlock.editable({
|
||||
super.key,
|
||||
this.title,
|
||||
this.expandVertically = false,
|
||||
required TextEditingController nomController,
|
||||
required TextEditingController prenomController,
|
||||
required TextEditingController telephoneController,
|
||||
@@ -142,11 +147,13 @@ class IdentityBlock extends StatelessWidget { final String? title;
|
||||
factory IdentityBlock.readOnlyValues({
|
||||
Key? key,
|
||||
String? title,
|
||||
bool expandVertically = false,
|
||||
required IdentityValues values,
|
||||
}) {
|
||||
return IdentityBlock.readOnly(
|
||||
key: key,
|
||||
title: title,
|
||||
expandVertically: expandVertically,
|
||||
nom: values.nom,
|
||||
prenom: values.prenom,
|
||||
telephone: values.telephone,
|
||||
@@ -163,10 +170,12 @@ class IdentityBlock extends StatelessWidget { final String? title;
|
||||
Key? key,
|
||||
String? title,
|
||||
String emptyLabel = 'Non défini',
|
||||
bool expandVertically = false,
|
||||
}) {
|
||||
return IdentityBlock.readOnlyValues(
|
||||
key: key,
|
||||
title: title,
|
||||
expandVertically: expandVertically,
|
||||
values: IdentityValues.fromUser(user, emptyLabel: emptyLabel),
|
||||
);
|
||||
}
|
||||
@@ -196,29 +205,29 @@ class IdentityBlock extends StatelessWidget { final String? title;
|
||||
title: title,
|
||||
rowLayout: rowLayout,
|
||||
rowFlex: rowFlex,
|
||||
expandVertically: expandVertically,
|
||||
fields: [
|
||||
ValidationLabeledField(
|
||||
label: 'Nom',
|
||||
field: ValidationEditableField(controller: _nomCtrl!),
|
||||
field: ValidationEditableField(
|
||||
controller: _nomCtrl!,
|
||||
inputFormatters: const [PersonNameInputFormatter()],
|
||||
),
|
||||
),
|
||||
ValidationLabeledField(
|
||||
label: 'Prénom',
|
||||
field: ValidationEditableField(controller: _prenomCtrl!),
|
||||
field: ValidationEditableField(
|
||||
controller: _prenomCtrl!,
|
||||
inputFormatters: const [PersonNameInputFormatter()],
|
||||
),
|
||||
),
|
||||
ValidationLabeledField(
|
||||
label: 'Téléphone',
|
||||
field: ValidationEditableField(
|
||||
controller: _telCtrl!,
|
||||
keyboardType: TextInputType.phone,
|
||||
inputFormatters: frenchPhoneInputFormatters,
|
||||
),
|
||||
field: ValidationPhoneField(controller: _telCtrl!),
|
||||
),
|
||||
ValidationLabeledField(
|
||||
label: 'Email',
|
||||
field: ValidationEditableField(
|
||||
controller: _emailCtrl!,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
),
|
||||
field: ValidationEmailField(controller: _emailCtrl!),
|
||||
),
|
||||
ValidationLabeledField(
|
||||
label: 'Adresse (N° et Rue)',
|
||||
@@ -226,14 +235,14 @@ class IdentityBlock extends StatelessWidget { final String? title;
|
||||
),
|
||||
ValidationLabeledField(
|
||||
label: 'Code postal',
|
||||
field: ValidationEditableField(
|
||||
controller: _cpCtrl!,
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
field: ValidationPostalCodeField(controller: _cpCtrl!),
|
||||
),
|
||||
ValidationLabeledField(
|
||||
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,
|
||||
rowLayout: rowLayout,
|
||||
rowFlex: rowFlex,
|
||||
expandVertically: expandVertically,
|
||||
fields: [
|
||||
ValidationLabeledField(
|
||||
label: 'Nom',
|
||||
|
||||
@@ -54,7 +54,7 @@ class NirTextField extends StatelessWidget {
|
||||
inputFontSize: inputFontSize,
|
||||
keyboardType: TextInputType.text,
|
||||
validator: validator ?? validateNir,
|
||||
inputFormatters: [NirInputFormatter()],
|
||||
inputFormatters: const [NirInputFormatter()],
|
||||
enabled: enabled,
|
||||
readOnly: readOnly,
|
||||
style: style,
|
||||
|
||||
Reference in New Issue
Block a user