feat(#152/#155): cleanup est_multiple + dashboard sans préfixe Admin (squash develop).
Suppression complète grossesse multiple / est_multiple (BDD, API, front). Rename option C : widgets partagés et panels staff sous widgets/dashboard/, AdminManagementWidget seul restant dans widgets/admin/.
This commit is contained in:
@@ -0,0 +1,467 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:p_tits_pas/models/dossier_list_item.dart';
|
||||
|
||||
/// Choix pour le dernier enfant d’un dossier famille (#160).
|
||||
enum DernierEnfantSuppressionChoice {
|
||||
enfantSeul,
|
||||
dossierAussi,
|
||||
}
|
||||
|
||||
/// Ligne d’impact (une personne / une fiche).
|
||||
class SuppressionPersonLine {
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final String? role;
|
||||
|
||||
const SuppressionPersonLine({
|
||||
required this.label,
|
||||
this.icon = Icons.person_outline,
|
||||
this.role,
|
||||
});
|
||||
|
||||
factory SuppressionPersonLine.parent(String label) => SuppressionPersonLine(
|
||||
label: label,
|
||||
icon: Icons.supervisor_account_outlined,
|
||||
role: 'Parent',
|
||||
);
|
||||
|
||||
factory SuppressionPersonLine.enfant(String label) => SuppressionPersonLine(
|
||||
label: label,
|
||||
icon: Icons.child_care_outlined,
|
||||
role: 'Enfant',
|
||||
);
|
||||
|
||||
factory SuppressionPersonLine.am(String label) => SuppressionPersonLine(
|
||||
label: label,
|
||||
icon: Icons.face,
|
||||
role: 'AM',
|
||||
);
|
||||
|
||||
factory SuppressionPersonLine.gestionnaire(String label) =>
|
||||
SuppressionPersonLine(
|
||||
label: label,
|
||||
icon: Icons.assignment_ind_outlined,
|
||||
role: 'Gestionnaire',
|
||||
);
|
||||
|
||||
factory SuppressionPersonLine.administrateur(String label) =>
|
||||
SuppressionPersonLine(
|
||||
label: label,
|
||||
icon: Icons.manage_accounts_outlined,
|
||||
role: 'Admin',
|
||||
);
|
||||
|
||||
factory SuppressionPersonLine.relais(String label) => SuppressionPersonLine(
|
||||
label: label,
|
||||
icon: Icons.apartment_outlined,
|
||||
role: 'Relais',
|
||||
);
|
||||
}
|
||||
|
||||
/// Widget unique pour toutes les boîtes de confirmation de suppression (#160).
|
||||
class SuppressionConfirmDialog extends StatelessWidget {
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final String? message;
|
||||
final List<SuppressionPersonLine> people;
|
||||
final List<String> footnotes;
|
||||
final List<Widget> actions;
|
||||
|
||||
const SuppressionConfirmDialog({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
this.message,
|
||||
this.people = const [],
|
||||
this.footnotes = const [],
|
||||
required this.actions,
|
||||
});
|
||||
|
||||
/// Variante oui/non standard (Annuler / Supprimer).
|
||||
static SuppressionConfirmDialog yesNo({
|
||||
required String title,
|
||||
String? subtitle,
|
||||
String? message,
|
||||
List<SuppressionPersonLine> people = const [],
|
||||
List<String> footnotes = const [],
|
||||
String confirmLabel = 'Supprimer',
|
||||
required VoidCallback onCancel,
|
||||
required VoidCallback onConfirm,
|
||||
}) {
|
||||
return SuppressionConfirmDialog(
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
message: message,
|
||||
people: people,
|
||||
footnotes: footnotes,
|
||||
actions: [
|
||||
TextButton(onPressed: onCancel, child: const Text('Annuler')),
|
||||
FilledButton(
|
||||
onPressed: onConfirm,
|
||||
style: _dangerButtonStyle,
|
||||
child: Text(confirmLabel),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
static ButtonStyle get _dangerButtonStyle => FilledButton.styleFrom(
|
||||
backgroundColor: Colors.red.shade700,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return AlertDialog(
|
||||
backgroundColor: const Color(0xFFF7F2FB),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
titlePadding: const EdgeInsets.fromLTRB(24, 20, 24, 0),
|
||||
contentPadding: const EdgeInsets.fromLTRB(24, 12, 24, 8),
|
||||
actionsPadding: const EdgeInsets.fromLTRB(16, 4, 16, 14),
|
||||
title: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.shade50,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.delete_outline,
|
||||
color: Colors.red.shade700,
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: const Color(0xFF2F2F2F),
|
||||
fontSize: 20,
|
||||
),
|
||||
),
|
||||
if ((subtitle ?? '').trim().isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
subtitle!.trim(),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: const Color(0xFF6D4EA1),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
content: SizedBox(
|
||||
width: 420,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if ((message ?? '').trim().isNotEmpty) ...[
|
||||
Text(
|
||||
message!.trim(),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: Colors.black87,
|
||||
height: 1.35,
|
||||
),
|
||||
),
|
||||
if (people.isNotEmpty || footnotes.isNotEmpty)
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
if (people.isNotEmpty) ...[
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.9),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: const Color(0xFFE5D8F2)),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Column(
|
||||
children: [
|
||||
for (var i = 0; i < people.length; i++) ...[
|
||||
if (i > 0)
|
||||
Divider(
|
||||
height: 1,
|
||||
indent: 44,
|
||||
endIndent: 12,
|
||||
color: Colors.grey.shade200,
|
||||
),
|
||||
_SuppressionPersonRow(line: people[i]),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (footnotes.isNotEmpty) const SizedBox(height: 12),
|
||||
],
|
||||
for (final note in footnotes)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Icon(
|
||||
Icons.info_outline,
|
||||
size: 16,
|
||||
color: Colors.orange.shade800,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
note,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: Colors.black54,
|
||||
height: 1.35,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: actions,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SuppressionPersonRow extends StatelessWidget {
|
||||
final SuppressionPersonLine line;
|
||||
|
||||
const _SuppressionPersonRow({required this.line});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(line.icon, size: 18, color: const Color(0xFF6D4EA1)),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
line.label,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
color: Color(0xFF2F2F2F),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if ((line.role ?? '').trim().isNotEmpty) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFEDE5FA),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
line.role!.trim(),
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF6D4EA1),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Affiche [SuppressionConfirmDialog] et renvoie `true` si confirmé.
|
||||
Future<bool> showSuppressionConfirmDialog(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
String? subtitle,
|
||||
String? message,
|
||||
List<SuppressionPersonLine> people = const [],
|
||||
List<String> footnotes = const [],
|
||||
String confirmLabel = 'Supprimer',
|
||||
}) async {
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => SuppressionConfirmDialog.yesNo(
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
message: message,
|
||||
people: people,
|
||||
footnotes: footnotes,
|
||||
confirmLabel: confirmLabel,
|
||||
onCancel: () => Navigator.of(ctx).pop(false),
|
||||
onConfirm: () => Navigator.of(ctx).pop(true),
|
||||
),
|
||||
);
|
||||
return result == true;
|
||||
}
|
||||
|
||||
/// Confirmation delete dossier famille / AM avec liste nominative.
|
||||
Future<bool> showDossierSuppressionConfirmDialog(
|
||||
BuildContext context, {
|
||||
required String numeroDossier,
|
||||
required bool isFamille,
|
||||
required List<SuppressionPersonLine> people,
|
||||
String? fallbackSummary,
|
||||
}) {
|
||||
final num = numeroDossier.trim();
|
||||
if (isFamille) {
|
||||
return showSuppressionConfirmDialog(
|
||||
context,
|
||||
title: 'Supprimer le dossier',
|
||||
subtitle: 'Dossier famille $num',
|
||||
people: people,
|
||||
footnotes: people.isEmpty && (fallbackSummary ?? '').isNotEmpty
|
||||
? [fallbackSummary!]
|
||||
: const [
|
||||
'Tous les comptes et fiches listés seront définitivement '
|
||||
'supprimés.',
|
||||
'Les placements AM des enfants seront clos.',
|
||||
],
|
||||
);
|
||||
}
|
||||
return showSuppressionConfirmDialog(
|
||||
context,
|
||||
title: 'Supprimer le dossier',
|
||||
subtitle: 'Dossier AM $num',
|
||||
people: people,
|
||||
footnotes: const [
|
||||
'Le compte et le dossier AM seront supprimés.',
|
||||
'Les enfants accueillis ne seront pas supprimés '
|
||||
'(placements clos).',
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Dialog dernier enfant : deux actions métier.
|
||||
Future<DernierEnfantSuppressionChoice?> showDernierEnfantSuppressionDialog(
|
||||
BuildContext context, {
|
||||
required String enfantName,
|
||||
required String familleLabel,
|
||||
required String numeroDossier,
|
||||
String? amLabel,
|
||||
}) {
|
||||
final am = (amLabel ?? '').trim();
|
||||
return showDialog<DernierEnfantSuppressionChoice>(
|
||||
context: context,
|
||||
builder: (ctx) => SuppressionConfirmDialog(
|
||||
title: 'Dernier enfant du dossier',
|
||||
subtitle: 'Dossier $numeroDossier'
|
||||
'${familleLabel.isEmpty ? '' : ' · $familleLabel'}',
|
||||
people: [SuppressionPersonLine.enfant(enfantName)],
|
||||
footnotes: [
|
||||
'« Enfant seulement » : le dossier reste sans enfant.',
|
||||
'« Dossier aussi » : parents et dossier sont également '
|
||||
'supprimés.',
|
||||
if (am.isNotEmpty) 'Le placement chez $am sera clos.',
|
||||
],
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
OutlinedButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(
|
||||
DernierEnfantSuppressionChoice.enfantSeul,
|
||||
),
|
||||
child: const Text('Enfant seulement'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(
|
||||
DernierEnfantSuppressionChoice.dossierAussi,
|
||||
),
|
||||
style: SuppressionConfirmDialog._dangerButtonStyle,
|
||||
child: const Text('Dossier aussi'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Notes d’impact pour suppression d’un enfant (AM optionnelle).
|
||||
List<String> enfantSuppressionFootnotes({
|
||||
required String? numeroDossier,
|
||||
required String familleLabel,
|
||||
String? amLabel,
|
||||
}) {
|
||||
final notes = <String>[];
|
||||
final num = (numeroDossier ?? '').trim();
|
||||
final famille = familleLabel.trim();
|
||||
final am = (amLabel ?? '').trim();
|
||||
if (num.isEmpty) {
|
||||
notes.add('Supprimer définitivement cette fiche enfant.');
|
||||
} else {
|
||||
notes.add(
|
||||
'L’enfant sera retiré du dossier de '
|
||||
'${famille.isEmpty ? 'la famille' : famille}.',
|
||||
);
|
||||
}
|
||||
if (am.isNotEmpty) {
|
||||
notes.add('Le placement chez $am sera clos.');
|
||||
}
|
||||
return notes;
|
||||
}
|
||||
|
||||
/// Bouton poubelle compact pour les cartes liste.
|
||||
Widget suppressionIconButton({
|
||||
required VoidCallback? onPressed,
|
||||
String tooltip = 'Supprimer',
|
||||
}) {
|
||||
return IconButton(
|
||||
icon: Icon(Icons.delete_outline, color: Colors.red.shade700),
|
||||
tooltip: tooltip,
|
||||
onPressed: onPressed,
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit les lignes parents / enfants depuis un dossier unifié.
|
||||
List<SuppressionPersonLine> suppressionPeopleFromDossier({
|
||||
required bool isFamille,
|
||||
required List<({String nom, String prenom, String email})> parents,
|
||||
required List<({String nom, String prenom})> enfants,
|
||||
String? amName,
|
||||
}) {
|
||||
final lines = <SuppressionPersonLine>[];
|
||||
if (isFamille) {
|
||||
for (final p in parents) {
|
||||
final label = formatDossierPersonLabel(
|
||||
nom: p.nom,
|
||||
prenom: p.prenom,
|
||||
email: p.email,
|
||||
);
|
||||
if (label.isEmpty) continue;
|
||||
lines.add(SuppressionPersonLine.parent(label));
|
||||
}
|
||||
for (final e in enfants) {
|
||||
final label = formatDossierPersonLabel(nom: e.nom, prenom: e.prenom);
|
||||
if (label.isEmpty) continue;
|
||||
lines.add(SuppressionPersonLine.enfant(label));
|
||||
}
|
||||
} else if ((amName ?? '').trim().isNotEmpty) {
|
||||
lines.add(SuppressionPersonLine.am(amName!.trim()));
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:p_tits_pas/widgets/dashboard/user_list_state.dart';
|
||||
|
||||
class UserList extends StatelessWidget {
|
||||
final bool isLoading;
|
||||
final String? error;
|
||||
final bool isEmpty;
|
||||
final String emptyMessage;
|
||||
final int itemCount;
|
||||
final Widget Function(BuildContext context, int index) itemBuilder;
|
||||
final EdgeInsetsGeometry padding;
|
||||
|
||||
const UserList({
|
||||
super.key,
|
||||
required this.isLoading,
|
||||
required this.error,
|
||||
required this.isEmpty,
|
||||
required this.emptyMessage,
|
||||
required this.itemCount,
|
||||
required this.itemBuilder,
|
||||
this.padding = const EdgeInsets.all(16),
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: padding,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
UserListState(
|
||||
isLoading: isLoading,
|
||||
error: error,
|
||||
isEmpty: isEmpty,
|
||||
emptyMessage: emptyMessage,
|
||||
list: ListView.builder(
|
||||
itemCount: itemCount,
|
||||
itemBuilder: itemBuilder,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 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,
|
||||
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 d’un 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user