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 fields; /// Nombre de champs par ligne (1 = plein largeur, 2 = deux côte à côte). Ex. [2, 2, 1, 2] pour identité. final List? rowLayout; /// Flex par ligne (index de ligne -> [flex1, flex2, ...]). Ex. {3: [2, 5]} pour Code postal | Ville. final Map>? 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 fields; final List? rowLayout; final Map>? 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 = []; 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? 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 createState() => _ValidationEmailFieldState(); } class _ValidationEmailFieldState extends State { final GlobalKey> _fieldKey = GlobalKey>(); 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 createState() => _ValidationPostalCodeFieldState(); } class _ValidationPostalCodeFieldState extends State { final GlobalKey> _fieldKey = GlobalKey>(); 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 createState() => _ValidationPhoneFieldState(); } class _ValidationPhoneFieldState extends State { final GlobalKey> _fieldKey = GlobalKey>(); 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 createState() => _ValidationNirFieldState(); } class _ValidationNirFieldState extends State { final GlobalKey> _fieldKey = GlobalKey>(); 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 fields; final List? rowLayout; final Map>? 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 createState() => _ValidationReadOnlyFieldState(); } class _ValidationReadOnlyFieldState extends State { 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, ), ); } }