Squash depuis develop : POST /assistantes-maternelles/dossier (actif + mail MDP), AmDossierWizard create/review, validations inscription. Co-authored-by: Cursor <cursoragent@cursor.com>
837 lines
25 KiB
Dart
837 lines
25 KiB
Dart
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).
|
||
class ValidationDetailSection extends StatelessWidget {
|
||
/// Si null ou vide, pas de bandeau titre (gain de place vertical, ex. wizard AM).
|
||
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;
|
||
|
||
/// 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
|
||
Widget build(BuildContext context) {
|
||
return ValidationFormGrid(
|
||
title: title,
|
||
rowLayout: rowLayout,
|
||
rowFlex: rowFlex,
|
||
expandVertically: expandVertically,
|
||
fields: fields
|
||
.map(
|
||
(f) => ValidationLabeledField(
|
||
label: f.label,
|
||
field: ValidationReadOnlyField(value: f.value),
|
||
),
|
||
)
|
||
.toList(),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Grille label/champ réutilisable (validation, fiches admin).
|
||
class ValidationFormGrid extends StatelessWidget {
|
||
final String? title;
|
||
final List<ValidationLabeledField> fields;
|
||
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,
|
||
this.title,
|
||
required this.fields,
|
||
this.rowLayout,
|
||
this.rowFlex,
|
||
this.compact = false,
|
||
this.expandVertically = false,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final layout = rowLayout ?? List.filled(fields.length, 1);
|
||
int index = 0;
|
||
int rowIndex = 0;
|
||
final rowWidgets = <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++;
|
||
final labeled = rowFields
|
||
.map(
|
||
(f) => ValidationLabeledField(
|
||
label: f.label,
|
||
field: f.field,
|
||
expand: expandVertically,
|
||
),
|
||
)
|
||
.toList();
|
||
|
||
Widget row;
|
||
if (count == 1) {
|
||
row = labeled.first;
|
||
} else {
|
||
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: expandVertically ? MainAxisSize.max : MainAxisSize.min,
|
||
children: [
|
||
if (showTitle) ...[
|
||
Text(
|
||
title!.trim(),
|
||
style: TextStyle(
|
||
fontSize: compact
|
||
? ValidationFormMetrics.sectionTitleFontSize - 1
|
||
: ValidationFormMetrics.sectionTitleFontSize,
|
||
fontWeight: FontWeight.w600,
|
||
color: Colors.black87,
|
||
),
|
||
),
|
||
SizedBox(
|
||
height: compact
|
||
? 8
|
||
: ValidationFormMetrics.sectionTitleGapBelow,
|
||
),
|
||
],
|
||
...rowWidgets,
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Décoration commune lecture seule / édition (modales validation, fiches admin).
|
||
class ValidationFieldDecoration {
|
||
ValidationFieldDecoration._();
|
||
|
||
static InputDecoration input({String? hint, bool compact = false}) {
|
||
return InputDecoration(
|
||
isDense: true,
|
||
filled: true,
|
||
fillColor: Colors.grey.shade50,
|
||
hintText: hint,
|
||
contentPadding: EdgeInsets.symmetric(
|
||
horizontal: compact ? 10 : ValidationFormMetrics.fieldContentPaddingH,
|
||
vertical: compact ? 7 : ValidationFormMetrics.fieldContentPaddingV,
|
||
),
|
||
border: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(6),
|
||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||
),
|
||
enabledBorder: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(6),
|
||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||
),
|
||
focusedBorder: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(6),
|
||
borderSide: BorderSide(color: Colors.grey.shade500),
|
||
),
|
||
);
|
||
}
|
||
|
||
static InputDecoration readOnly({bool error = false, bool compact = false}) {
|
||
final borderColor = error ? Colors.red.shade400 : Colors.grey.shade300;
|
||
final fillColor = error ? Colors.red.shade50 : Colors.grey.shade50;
|
||
return input(compact: compact).copyWith(
|
||
filled: true,
|
||
fillColor: fillColor,
|
||
enabledBorder: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(6),
|
||
borderSide: BorderSide(color: borderColor),
|
||
),
|
||
focusedBorder: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(6),
|
||
borderSide: BorderSide(color: borderColor),
|
||
),
|
||
);
|
||
}
|
||
|
||
static BoxDecoration container({bool error = false}) {
|
||
return BoxDecoration(
|
||
color: error ? Colors.red.shade50 : Colors.grey.shade50,
|
||
borderRadius: BorderRadius.circular(6),
|
||
border: Border.all(
|
||
color: error ? Colors.red.shade400 : Colors.grey.shade300,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Libellé au-dessus d’un champ (même typo que [ValidationDetailSection]).
|
||
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: expand ? MainAxisSize.max : MainAxisSize.min,
|
||
children: [
|
||
Text(
|
||
label,
|
||
style: TextStyle(
|
||
fontSize: ValidationFormMetrics.fieldLabelFontSize,
|
||
fontWeight: FontWeight.w500,
|
||
color: Colors.grey.shade700,
|
||
),
|
||
),
|
||
SizedBox(height: ValidationFormMetrics.fieldLabelGapBelow),
|
||
if (expand) Expanded(child: field) else field,
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Champ texte éditable, même rendu que [ValidationReadOnlyField].
|
||
class ValidationEditableField extends StatelessWidget {
|
||
final TextEditingController controller;
|
||
final TextInputType keyboardType;
|
||
final List<TextInputFormatter>? inputFormatters;
|
||
final String? hintText;
|
||
final int maxLines;
|
||
final bool compact;
|
||
|
||
const ValidationEditableField({
|
||
super.key,
|
||
required this.controller,
|
||
this.keyboardType = TextInputType.text,
|
||
this.inputFormatters,
|
||
this.hintText,
|
||
this.maxLines = 1,
|
||
this.compact = false,
|
||
});
|
||
|
||
static const double _compactFieldHeight = 34;
|
||
|
||
static BoxDecoration _compactDecoration({bool error = false}) {
|
||
return BoxDecoration(
|
||
color: error ? Colors.red.shade50 : Colors.grey.shade50,
|
||
borderRadius: BorderRadius.circular(6),
|
||
border: Border.all(
|
||
color: error ? Colors.red.shade400 : Colors.grey.shade300,
|
||
),
|
||
);
|
||
}
|
||
|
||
static InputDecoration _compactInputDecoration({String? hint}) {
|
||
return InputDecoration(
|
||
isDense: true,
|
||
filled: false,
|
||
hintText: hint,
|
||
border: InputBorder.none,
|
||
enabledBorder: InputBorder.none,
|
||
focusedBorder: InputBorder.none,
|
||
contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9),
|
||
);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
if (maxLines > 1) {
|
||
return TextField(
|
||
controller: controller,
|
||
keyboardType: keyboardType,
|
||
inputFormatters: inputFormatters,
|
||
maxLines: maxLines,
|
||
style: const TextStyle(color: Colors.black87, fontSize: 14),
|
||
decoration: ValidationFieldDecoration.input(hint: hintText),
|
||
);
|
||
}
|
||
if (!compact) {
|
||
return _validationFieldFillHeight(
|
||
TextField(
|
||
controller: controller,
|
||
keyboardType: keyboardType,
|
||
inputFormatters: inputFormatters,
|
||
maxLines: 1,
|
||
textAlignVertical: TextAlignVertical.center,
|
||
style: ValidationFormMetrics.fieldTextStyle,
|
||
decoration: ValidationFieldDecoration.input(hint: hintText),
|
||
),
|
||
);
|
||
}
|
||
return SizedBox(
|
||
height: _compactFieldHeight,
|
||
child: DecoratedBox(
|
||
decoration: _compactDecoration(),
|
||
child: TextField(
|
||
controller: controller,
|
||
keyboardType: keyboardType,
|
||
inputFormatters: inputFormatters,
|
||
maxLines: 1,
|
||
textAlignVertical: TextAlignVertical.center,
|
||
style: const TextStyle(
|
||
color: Colors.black87,
|
||
fontSize: 13,
|
||
height: 1.0,
|
||
),
|
||
decoration: _compactInputDecoration(hint: hintText),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// É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;
|
||
final List<int>? rowLayout;
|
||
final Map<int, List<int>>? rowFlex;
|
||
final bool compact;
|
||
|
||
const ValidationEditableSection({
|
||
super.key,
|
||
required this.fields,
|
||
this.rowLayout,
|
||
this.rowFlex,
|
||
this.compact = false,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return ValidationFormGrid(
|
||
rowLayout: rowLayout,
|
||
rowFlex: rowFlex,
|
||
compact: compact,
|
||
fields: fields,
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Champ texte en lecture seule, même coque [TextField] que [ValidationEditableField].
|
||
class ValidationReadOnlyField extends StatefulWidget {
|
||
final String value;
|
||
final int? maxLines;
|
||
final bool compact;
|
||
final bool error;
|
||
|
||
const ValidationReadOnlyField({
|
||
super.key,
|
||
required this.value,
|
||
this.maxLines = 1,
|
||
this.compact = false,
|
||
this.error = false,
|
||
});
|
||
|
||
@override
|
||
State<ValidationReadOnlyField> createState() => _ValidationReadOnlyFieldState();
|
||
}
|
||
|
||
class _ValidationReadOnlyFieldState extends State<ValidationReadOnlyField> {
|
||
late final TextEditingController _controller;
|
||
|
||
static const double _compactFieldHeight = 34;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_controller = TextEditingController(text: widget.value);
|
||
}
|
||
|
||
@override
|
||
void didUpdateWidget(ValidationReadOnlyField oldWidget) {
|
||
super.didUpdateWidget(oldWidget);
|
||
if (oldWidget.value != widget.value) {
|
||
_controller.text = widget.value;
|
||
}
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_controller.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
if (!widget.compact && widget.maxLines == 1) {
|
||
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),
|
||
),
|
||
);
|
||
}
|
||
|
||
return Container(
|
||
width: double.infinity,
|
||
height: widget.compact && widget.maxLines == 1 ? _compactFieldHeight : null,
|
||
alignment: widget.compact ? Alignment.centerLeft : null,
|
||
padding: EdgeInsets.symmetric(
|
||
horizontal: widget.compact ? 10 : 12,
|
||
vertical: widget.compact ? 7 : 10,
|
||
),
|
||
decoration: ValidationFieldDecoration.container(error: widget.error),
|
||
child: Text(
|
||
widget.value,
|
||
style: TextStyle(
|
||
color: widget.error ? Colors.red.shade800 : Colors.black87,
|
||
fontSize: widget.compact ? 13 : 14,
|
||
height: widget.compact ? 1.0 : null,
|
||
fontWeight: widget.error ? FontWeight.w600 : null,
|
||
),
|
||
maxLines: widget.maxLines,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
);
|
||
}
|
||
}
|