refactor(#155): phase 2 — panels staff sous widgets/dashboard/.

Déplace les panels/wizards/validation/common partagés hors de
widgets/admin/ ; ne conserve que AdminManagementWidget (variante A).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-14 11:05:23 +02:00
co-authored by Cursor
parent a312d9c0fa
commit b474842e19
34 changed files with 57 additions and 57 deletions
@@ -0,0 +1,860 @@
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 'package:p_tits_pas/widgets/dashboard/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 * 1.25 + sectionTitleGapBelow;
/// Libellé : marge au-dessus de [fieldLabelFontSize] (métriques police).
static double get labeledRowHeight =>
fieldLabelFontSize * 1.25 +
fieldLabelGapBelow +
fieldHeight +
rowGapBelow;
/// Corps modale AM / famille : padding wizard + titre + [rows] lignes + nav.
static double shellBodyHeightForRows(int rows) =>
20 * 2 + // padding wizard
4 + // espace haut
sectionTitleBlockHeight +
rows * labeledRowHeight +
24 + // avant nav
48; // boutons (+ marge anti-overflow)
}
/// 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<DetailField> 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,
labelTrailing: f.labelTrailing,
),
)
.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;
/// Widget aligné à droite sur la ligne du libellé (ex. switch « Même adresse »).
final Widget? labelTrailing;
const ValidationLabeledField({
super.key,
required this.label,
required this.field,
this.expand = false,
this.labelTrailing,
});
@override
Widget build(BuildContext context) {
final labelStyle = TextStyle(
fontSize: ValidationFormMetrics.fieldLabelFontSize,
fontWeight: FontWeight.w500,
color: Colors.grey.shade700,
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: expand ? MainAxisSize.max : MainAxisSize.min,
children: [
if (labelTrailing == null)
Text(label, style: labelStyle)
else
Row(
children: [
Expanded(child: Text(label, style: labelStyle)),
labelTrailing!,
],
),
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;
final bool enabled;
const ValidationEditableField({
super.key,
required this.controller,
this.keyboardType = TextInputType.text,
this.inputFormatters,
this.hintText,
this.maxLines = 1,
this.compact = false,
this.enabled = true,
});
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,
enabled: enabled,
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,
enabled: enabled,
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,
enabled: enabled,
keyboardType: keyboardType,
inputFormatters: inputFormatters,
maxLines: 1,
textAlignVertical: TextAlignVertical.center,
style: const TextStyle(
color: Colors.black87,
fontSize: 13,
height: 1.0,
),
decoration: _compactInputDecoration(hint: hintText),
),
),
);
}
}
/// Hauteur TF fixe ; en grille [expandVertically], étire jusqu’à la hauteur dispo.
Widget _validationFieldFillHeight(Widget field) {
return LayoutBuilder(
builder: (context, c) {
final h = (c.hasBoundedHeight && c.maxHeight.isFinite)
? c.maxHeight
: ValidationFormMetrics.fieldHeight;
return SizedBox(
height: h,
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;
final bool enabled;
const ValidationPostalCodeField({
super.key,
required this.controller,
this.hintText,
this.allowEmpty = false,
this.enabled = true,
});
@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,
enabled: widget.enabled,
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,
),
);
}
}