petitspas/frontend/lib/widgets/admin/common/validation_detail_section.dart
Julien Martin ae610733cc feat(#156): création dossier AM staff — API + wizard dashboard.
Squash depuis develop : POST /assistantes-maternelles/dossier (actif +
mail MDP), AmDossierWizard create/review, validations inscription.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 16:53:18 +02:00

837 lines
25 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 12).
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 dun 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,
),
);
}
}