feat(#140): fiches admin famille — IdentityBlock, parsing enfants et avatars web

Extrait IdentityBlock réutilisable, aligne les modales parent/enfant sur le style validation, corrige le parsing parentChildren et les URLs /uploads en Flutter web.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
MARTIN Julien 2026-06-21 17:42:00 +02:00
parent 1dddc67933
commit 8494341b56
11 changed files with 1075 additions and 458 deletions

View File

@ -89,8 +89,10 @@ class EnfantParentLink {
EnfantParentLink({required this.parentId, this.parentName});
factory EnfantParentLink.fromJson(Map<String, dynamic> json) {
final parent = json['parent'];
final parentId =
(json['parentId'] ?? json['id_parent'] ?? '').toString();
String? name;
final parent = json['parent'];
if (parent is Map<String, dynamic>) {
final user = parent['user'];
if (user is Map<String, dynamic>) {
@ -99,7 +101,7 @@ class EnfantParentLink {
}
}
return EnfantParentLink(
parentId: (json['parentId'] ?? json['id_parent'] ?? '').toString(),
parentId: parentId,
parentName: name,
);
}

View File

@ -23,9 +23,38 @@ class ParentChildSummary {
factory ParentChildSummary.fromJson(Map<String, dynamic> json) {
return ParentChildSummary(
id: (json['id'] ?? '').toString(),
firstName: json['first_name'] as String?,
lastName: json['last_name'] as String?,
status: (json['status'] ?? '').toString(),
firstName: (json['first_name'] ?? json['prenom'])?.toString(),
lastName: (json['last_name'] ?? json['nom'])?.toString(),
status: (json['status'] ?? json['statut'] ?? '').toString(),
);
}
/// Parse un lien `parentChildren` (objet enfant imbriqué ou id seul).
static ParentChildSummary? fromParentChildLink(Map<String, dynamic> link) {
final childRaw = link['child'] ?? link['enfant'];
if (childRaw is Map) {
return ParentChildSummary.fromJson(Map<String, dynamic>.from(childRaw));
}
if (link.containsKey('first_name') ||
link.containsKey('prenom') ||
link.containsKey('id')) {
final id = (link['id'] ?? '').toString();
if (id.isNotEmpty &&
(link.containsKey('first_name') || link.containsKey('prenom'))) {
return ParentChildSummary.fromJson(link);
}
}
final enfantId =
link['enfantId'] ?? link['id_enfant'] ?? link['enfant_id'];
if (enfantId != null && enfantId.toString().isNotEmpty) {
return ParentChildSummary(
id: enfantId.toString(),
status: '',
);
}
return null;
}
}

View File

@ -13,28 +13,44 @@ class ParentModel {
});
factory ParentModel.fromJson(Map<String, dynamic> json) {
final userJson = Map<String, dynamic>.from(json['user'] ?? json);
if (json['numero_dossier'] != null && userJson['numero_dossier'] == null) {
userJson['numero_dossier'] = json['numero_dossier'];
final root = Map<String, dynamic>.from(json);
final userJson = Map<String, dynamic>.from(root['user'] ?? root);
if (root['numero_dossier'] != null && userJson['numero_dossier'] == null) {
userJson['numero_dossier'] = root['numero_dossier'];
}
final user = AppUser.fromJson(userJson);
final children = <ParentChildSummary>[];
final links = json['parentChildren'] as List?;
if (links != null) {
for (final link in links) {
if (link is Map<String, dynamic> && link['child'] is Map<String, dynamic>) {
children.add(
ParentChildSummary.fromJson(link['child'] as Map<String, dynamic>),
);
}
}
}
final children = _parseChildren(root);
final links = root['parentChildren'] ?? root['parent_children'];
final linkCount = links is List ? links.length : 0;
return ParentModel(
user: user,
childrenCount: children.isNotEmpty ? children.length : (links?.length ?? 0),
childrenCount: children.isNotEmpty ? children.length : linkCount,
children: children,
);
}
static List<ParentChildSummary> _parseChildren(Map<String, dynamic> json) {
final children = <ParentChildSummary>[];
final seen = <String>{};
void add(ParentChildSummary? child) {
if (child == null || child.id.isEmpty || seen.contains(child.id)) return;
seen.add(child.id);
children.add(child);
}
final links = json['parentChildren'] ?? json['parent_children'];
if (links is List) {
for (final link in links) {
if (link is! Map) continue;
add(ParentChildSummary.fromParentChildLink(
Map<String, dynamic>.from(link),
));
}
}
return children;
}
}

View File

@ -367,7 +367,9 @@ class UserService {
}
final List<dynamic> data = jsonDecode(response.body);
return data.map((e) => ParentModel.fromJson(e)).toList();
return data
.map((e) => ParentModel.fromJson(Map<String, dynamic>.from(e as Map)))
.toList();
}
static Future<ParentModel> getParent(String userId) async {
@ -379,7 +381,10 @@ class UserService {
final err = jsonDecode(response.body) as Map<String, dynamic>?;
throw Exception(_toStr(err?['message']) ?? 'Erreur chargement parent');
}
return ParentModel.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
final decoded = jsonDecode(response.body);
return ParentModel.fromJson(
Map<String, dynamic>.from(decoded is Map ? decoded : {}),
);
}
static Future<ParentModel> updateParentFiche({
@ -394,7 +399,7 @@ class UserService {
if (response.statusCode != 200) {
throw Exception(_extractErrorMessage(response.body, 'Erreur mise à jour parent'));
}
return ParentModel.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
return _parentModelFromBody(response.body);
}
static Future<ParentModel> attachEnfantToParent({
@ -410,7 +415,7 @@ class UserService {
if (response.statusCode != 200 && response.statusCode != 201) {
throw Exception(_extractErrorMessage(response.body, 'Erreur rattachement enfant'));
}
return ParentModel.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
return _parentModelFromBody(response.body);
}
static Future<ParentModel> detachEnfantFromParent({
@ -429,7 +434,7 @@ class UserService {
if (response.body.isEmpty) {
return getParent(parentUserId);
}
return ParentModel.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
return _parentModelFromBody(response.body);
}
static Future<List<EnfantAdminModel>> getEnfants() async {
@ -473,6 +478,13 @@ class UserService {
return EnfantAdminModel.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
}
static ParentModel _parentModelFromBody(String body) {
final decoded = jsonDecode(body);
return ParentModel.fromJson(
Map<String, dynamic>.from(decoded is Map ? decoded : {}),
);
}
static String _extractErrorMessage(String body, String fallback) {
try {
final decoded = jsonDecode(body);

View File

@ -32,6 +32,12 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
static const _statuses = ['a_naitre', 'actif', 'scolarise'];
static const _genders = ['H', 'F', 'Autre'];
static const _labelStyle = TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Colors.black87,
);
@override
void initState() {
super.initState();
@ -41,7 +47,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
_birthCtrl = TextEditingController(text: e.birthDate ?? '');
_dueCtrl = TextEditingController(text: e.dueDate ?? '');
_status = _statuses.contains(e.status) ? e.status : 'actif';
_gender = _genders.contains(e.gender) ? e.gender! : 'H';
_gender = _normalizeGender(e.gender);
_consentPhoto = e.consentPhoto;
_isMultiple = e.isMultiple;
for (final c in [_prenomCtrl, _nomCtrl, _birthCtrl, _dueCtrl]) {
@ -57,6 +63,13 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
super.dispose();
}
static String _normalizeGender(String? raw) {
final g = (raw ?? '').trim();
if (g == 'M') return 'H';
if (_genders.contains(g)) return g;
return 'H';
}
void _markDirty() {
if (!_dirty) setState(() => _dirty = true);
}
@ -72,7 +85,8 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
'last_name': _nomCtrl.text.trim(),
'status': _status,
'gender': _gender,
if (_birthCtrl.text.trim().isNotEmpty) 'birth_date': _birthCtrl.text.trim(),
if (_birthCtrl.text.trim().isNotEmpty)
'birth_date': _birthCtrl.text.trim(),
if (_dueCtrl.text.trim().isNotEmpty) 'due_date': _dueCtrl.text.trim(),
'consent_photo': _consentPhoto,
'is_multiple': _isMultiple,
@ -96,124 +110,215 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
}
}
String _parentsLine() {
final names = widget.enfant.parentLinks
.map((l) {
final n = (l.parentName ?? '').trim();
return n.isNotEmpty ? n : 'Parent rattaché';
})
.where((s) => s.isNotEmpty)
.toList();
if (names.isEmpty) return '';
return names.join(', ');
}
@override
Widget build(BuildContext context) {
final parents = widget.enfant.parentLinks
.map((l) => l.parentName ?? l.parentId)
.where((s) => s.isNotEmpty)
.join(', ');
final parents = _parentsLine();
final showDueDate = _status == 'a_naitre';
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560, maxHeight: 640),
child: Padding(
padding: const EdgeInsets.all(18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: Text(
widget.enfant.fullName,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700),
child: SizedBox(
width: 480,
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 640),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.enfant.fullName,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
),
),
if (parents.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
'Responsables : $parents',
style: const TextStyle(
fontSize: 13,
color: Colors.black54,
),
),
],
],
),
),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close),
tooltip: 'Fermer',
),
],
),
const SizedBox(height: 12),
const Divider(height: 1),
const SizedBox(height: 12),
Flexible(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _labeledField(
'Prénom',
TextField(
controller: _prenomCtrl,
decoration: _decoration(),
),
),
),
const SizedBox(width: 12),
Expanded(
child: _labeledField(
'Nom',
TextField(
controller: _nomCtrl,
decoration: _decoration(),
),
),
),
],
),
const SizedBox(height: 12),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _labeledDropdown(
'Statut',
_status,
_statuses
.map((s) => MapEntry(s, _statusLabel(s)))
.toList(),
(v) => setState(() {
_status = v;
_dirty = true;
}),
),
),
const SizedBox(width: 12),
Expanded(
child: _labeledDropdown(
'Genre',
_gender,
_genders
.map((g) => MapEntry(g, _genderLabel(g)))
.toList(),
(v) => setState(() {
_gender = v;
_dirty = true;
}),
),
),
],
),
const SizedBox(height: 12),
if (showDueDate)
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _labeledField(
'Date de naissance',
TextField(
controller: _birthCtrl,
decoration:
_decoration(hint: 'AAAA-MM-JJ'),
),
),
),
const SizedBox(width: 12),
Expanded(
child: _labeledField(
'Date prévue',
TextField(
controller: _dueCtrl,
decoration:
_decoration(hint: 'AAAA-MM-JJ'),
),
),
),
],
)
else
_labeledField(
'Date de naissance',
TextField(
controller: _birthCtrl,
decoration: _decoration(hint: 'AAAA-MM-JJ'),
),
),
const SizedBox(height: 16),
_switchRow(
'Consentement photo',
_consentPhoto,
(v) => setState(() {
_consentPhoto = v;
_dirty = true;
}),
),
_switchRow(
'Naissance multiple',
_isMultiple,
(v) => setState(() {
_isMultiple = v;
_dirty = true;
}),
),
],
),
),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close),
),
],
),
if (parents.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text('Responsables : $parents', style: const TextStyle(color: Colors.black54)),
),
const Divider(),
Expanded(
child: SingleChildScrollView(
child: Column(
children: [
_rowField('Prénom', TextField(controller: _prenomCtrl, decoration: _decoration())),
_rowField('Nom', TextField(controller: _nomCtrl, decoration: _decoration())),
_rowDropdown(
'Statut',
_status,
_statuses.map((s) => MapEntry(s, _statusLabel(s))).toList(),
(v) => setState(() {
_status = v;
_dirty = true;
}),
),
_rowDropdown(
'Genre',
_gender,
_genders.map((g) => MapEntry(g, g)).toList(),
(v) => setState(() {
_gender = v;
_dirty = true;
}),
),
_rowField(
'Date naissance',
TextField(
controller: _birthCtrl,
decoration: _decoration(hint: 'AAAA-MM-JJ'),
),
),
_rowField(
'Date prévue',
TextField(
controller: _dueCtrl,
decoration: _decoration(hint: 'AAAA-MM-JJ'),
),
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Consentement photo'),
value: _consentPhoto,
onChanged: (v) => setState(() {
_consentPhoto = v;
_dirty = true;
}),
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Naissance multiple'),
value: _isMultiple,
onChanged: (v) => setState(() {
_isMultiple = v;
_dirty = true;
}),
),
],
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Fermer'),
),
const SizedBox(width: 8),
ElevatedButton.icon(
onPressed: !_dirty || _saving ? null : _save,
icon: _saving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save),
label: const Text('Sauvegarder'),
),
],
),
),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Fermer'),
),
const SizedBox(width: 8),
ElevatedButton.icon(
onPressed: !_dirty || _saving ? null : _save,
icon: _saving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save),
label: const Text('Sauvegarder'),
),
],
),
],
],
),
),
),
),
@ -221,53 +326,62 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
}
InputDecoration _decoration({String? hint}) {
return InputDecoration(isDense: true, border: const OutlineInputBorder(), hintText: hint);
}
Widget _rowField(String label, Widget field) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 120,
child: Padding(
padding: const EdgeInsets.only(top: 12),
child: Text(label, style: const TextStyle(fontWeight: FontWeight.w600)),
),
),
Expanded(child: field),
],
),
return InputDecoration(
isDense: true,
border: const OutlineInputBorder(),
hintText: hint,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
);
}
Widget _rowDropdown(
Widget _labeledField(String label, Widget field) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(label, style: _labelStyle),
const SizedBox(height: 4),
field,
],
);
}
Widget _labeledDropdown(
String label,
String value,
List<MapEntry<String, String>> items,
ValueChanged<String> onChanged,
) {
final safeValue =
items.any((e) => e.key == value) ? value : items.first.key;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(label, style: _labelStyle),
const SizedBox(height: 4),
DropdownButtonFormField<String>(
value: safeValue,
isExpanded: true,
decoration: _decoration(),
items: items
.map((e) => DropdownMenuItem(value: e.key, child: Text(e.value)))
.toList(),
onChanged: (v) {
if (v != null) onChanged(v);
},
),
],
);
}
Widget _switchRow(String label, bool value, ValueChanged<bool> onChanged) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
SizedBox(
width: 120,
child: Text(label, style: const TextStyle(fontWeight: FontWeight.w600)),
),
Expanded(
child: DropdownButtonFormField<String>(
value: items.any((e) => e.key == value) ? value : items.first.key,
decoration: _decoration(),
items: items
.map((e) => DropdownMenuItem(value: e.key, child: Text(e.value)))
.toList(),
onChanged: (v) {
if (v != null) onChanged(v);
},
),
Expanded(child: Text(label, style: _labelStyle)),
Switch(
value: value,
onChanged: onChanged,
),
],
),
@ -286,4 +400,17 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
return status;
}
}
String _genderLabel(String gender) {
switch (gender) {
case 'H':
return 'Garçon';
case 'F':
return 'Fille';
case 'Autre':
return 'Autre';
default:
return gender;
}
}
}

View File

@ -1,12 +1,16 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/enfant_admin_model.dart';
import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/models/parent_child_summary.dart';
import 'package:p_tits_pas/models/parent_model.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
import 'package:p_tits_pas/widgets/common/identity_block.dart';
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
/// Fiche parent éditable (doc 28 §6.1, tickets #131 / #138).
/// Shell et typo alignés sur [ValidationDossierModal] / wizards validation.
class AdminParentEditModal extends StatefulWidget {
final ParentModel parent;
final VoidCallback? onSaved;
@ -37,6 +41,10 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
static const _statuts = ['actif', 'en_attente', 'suspendu', 'refuse'];
static const double _modalWidth = 930;
/// Corps plus haut que les modales validation pour agrandir la liste enfants.
static const double _bodyHeight = 580;
@override
void initState() {
super.initState();
@ -44,20 +52,39 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
_nomCtrl = TextEditingController(text: u.nom ?? '');
_prenomCtrl = TextEditingController(text: u.prenom ?? '');
_emailCtrl = TextEditingController(text: u.email);
_telCtrl = TextEditingController(text: u.telephone ?? '');
_telCtrl = TextEditingController(
text: formatPhoneForDisplay(u.telephone ?? ''),
);
_adresseCtrl = TextEditingController(text: u.adresse ?? '');
_villeCtrl = TextEditingController(text: u.ville ?? '');
_cpCtrl = TextEditingController(text: u.codePostal ?? '');
_statut = u.statut ?? 'en_attente';
_children = List.of(widget.parent.children);
for (final c in [_nomCtrl, _prenomCtrl, _emailCtrl, _telCtrl, _adresseCtrl, _villeCtrl, _cpCtrl]) {
for (final c in [
_nomCtrl,
_prenomCtrl,
_emailCtrl,
_telCtrl,
_adresseCtrl,
_villeCtrl,
_cpCtrl,
]) {
c.addListener(_markDirty);
}
_reloadChildren();
}
@override
void dispose() {
for (final c in [_nomCtrl, _prenomCtrl, _emailCtrl, _telCtrl, _adresseCtrl, _villeCtrl, _cpCtrl]) {
for (final c in [
_nomCtrl,
_prenomCtrl,
_emailCtrl,
_telCtrl,
_adresseCtrl,
_villeCtrl,
_cpCtrl,
]) {
c.dispose();
}
super.dispose();
@ -88,10 +115,18 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Confirmer'),
content: const Text('Enregistrer les modifications de la fiche parent ?'),
content: const Text(
'Enregistrer les modifications de la fiche parent ?',
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Annuler')),
ElevatedButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('Sauvegarder')),
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Sauvegarder'),
),
],
),
);
@ -105,7 +140,7 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
'nom': _nomCtrl.text.trim(),
'prenom': _prenomCtrl.text.trim(),
'email': _emailCtrl.text.trim(),
'telephone': _telCtrl.text.trim(),
'telephone': normalizePhone(_telCtrl.text),
'adresse': _adresseCtrl.text.trim(),
'ville': _villeCtrl.text.trim(),
'code_postal': _cpCtrl.text.trim(),
@ -138,9 +173,7 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
context: context,
builder: (ctx) => AdminChildDetailModal(
enfant: enfant,
onSaved: () async {
await _reloadChildren();
},
onSaved: _reloadChildren,
),
);
} catch (e) {
@ -154,8 +187,26 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
Future<void> _reloadChildren() async {
try {
final refreshed = await UserService.getParent(widget.parent.user.id);
var kids = List.of(refreshed.children);
if (kids.any((c) => c.fullName == 'Enfant')) {
final all = await UserService.getEnfants();
final byId = {for (final e in all) e.id: e};
kids = kids
.map((c) {
if (c.fullName != 'Enfant') return c;
final e = byId[c.id];
if (e == null) return c;
return ParentChildSummary(
id: c.id,
firstName: e.firstName,
lastName: e.lastName,
status: c.status.isNotEmpty ? c.status : e.status,
);
})
.toList();
}
if (!mounted) return;
setState(() => _children = List.of(refreshed.children));
setState(() => _children = kids);
} catch (_) {}
}
@ -165,13 +216,19 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
builder: (ctx) => AlertDialog(
title: const Text('Détacher l\'enfant'),
content: Text(
'Retirer ${child.fullName} de la fiche de ce parent ?\n(L\'enfant ne sera pas supprimé.)',
'Retirer ${child.fullName} de la fiche de ce parent ?\n'
'(L\'enfant ne sera pas supprimé.)',
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Annuler')),
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () => Navigator.pop(ctx, true),
style: ElevatedButton.styleFrom(backgroundColor: Colors.red.shade700),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red.shade700,
),
child: const Text('Détacher'),
),
],
@ -254,209 +311,197 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
}
}
Widget _field(String label, TextEditingController ctrl, {TextInputType? keyboard}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 130,
child: Padding(
padding: const EdgeInsets.only(top: 12),
child: Text(label, style: const TextStyle(fontWeight: FontWeight.w600)),
),
),
Expanded(
child: TextFormField(
controller: ctrl,
keyboardType: keyboard,
decoration: const InputDecoration(isDense: true, border: OutlineInputBorder()),
),
),
],
Widget _statusDropdown() {
return Container(
height: 34,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: Colors.black26),
),
padding: const EdgeInsets.symmetric(horizontal: 12),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: _statuts.contains(_statut) ? _statut : _statuts.first,
isExpanded: true,
isDense: true,
style: const TextStyle(fontSize: 13, color: Colors.black87),
icon: const Icon(Icons.arrow_drop_down, size: 20),
items: _statuts
.map(
(s) => DropdownMenuItem(
value: s,
child: Text(_displayStatus(s)),
),
)
.toList(),
onChanged: (v) {
if (v == null) return;
setState(() {
_statut = v;
_dirty = true;
});
},
),
),
);
}
Widget _identityFields() {
return IdentityBlock.editable(
nomController: _nomCtrl,
prenomController: _prenomCtrl,
telephoneController: _telCtrl,
emailController: _emailCtrl,
adresseController: _adresseCtrl,
codePostalController: _cpCtrl,
villeController: _villeCtrl,
);
}
Widget _childrenPanel() {
return Container(
width: double.infinity,
decoration: ValidationFieldDecoration.container(),
child: _children.isEmpty
? const Center(
child: Padding(
padding: EdgeInsets.all(20),
child: Text(
'Aucun enfant rattaché',
style: TextStyle(fontSize: 14, color: Colors.black54),
),
),
)
: ListView.separated(
padding: const EdgeInsets.symmetric(vertical: 4),
itemCount: _children.length,
separatorBuilder: (_, __) => Divider(
height: 1,
color: Colors.grey.shade300,
),
itemBuilder: (_, i) {
final c = _children[i];
return ListTile(
dense: true,
title: InkWell(
onTap: () => _openChild(c),
child: Text(
c.fullName,
style: const TextStyle(
fontSize: 14,
color: Colors.black87,
decoration: TextDecoration.underline,
),
),
),
subtitle: Text(
_childStatusLabel(c.status),
style: const TextStyle(fontSize: 12, color: Colors.black54),
),
trailing: IconButton(
tooltip: 'Détacher',
icon: Icon(Icons.link_off, color: Colors.orange.shade800),
onPressed: () => _detachChild(c),
),
);
},
),
);
}
Widget _buildFooter() {
return Row(
children: [
TextButton(
onPressed: _saving ? null : () => Navigator.of(context).pop(),
child: const Text('Fermer'),
),
const Spacer(),
TextButton.icon(
onPressed: _attachChild,
icon: const Icon(Icons.link, size: 18),
label: const Text('Rattacher un enfant'),
),
const SizedBox(width: 12),
ElevatedButton(
style: ValidationModalTheme.primaryElevatedStyle,
onPressed: !_dirty || _saving ? null : _save,
child: _saving
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text(_dirty ? 'Sauvegarder' : 'Aucune modification'),
),
],
);
}
@override
Widget build(BuildContext context) {
final numero = widget.parent.user.numeroDossier;
final maxH = MediaQuery.of(context).size.height * 0.85;
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 680, maxHeight: 720),
child: Padding(
padding: const EdgeInsets.all(18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.parent.user.fullName.isEmpty
? 'Parent'
: widget.parent.user.fullName,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700),
),
Text(widget.parent.user.email, style: const TextStyle(color: Colors.black54)),
],
),
),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close),
),
],
),
const Divider(),
Expanded(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (numero != null && numero.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
const SizedBox(
width: 130,
child: Text('N° dossier', style: TextStyle(fontWeight: FontWeight.w600)),
),
Expanded(child: Text(numero)),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
const SizedBox(
width: 130,
child: Text('Statut', style: TextStyle(fontWeight: FontWeight.w600)),
),
Expanded(
child: DropdownButtonFormField<String>(
value: _statuts.contains(_statut) ? _statut : _statuts.first,
decoration: const InputDecoration(isDense: true, border: OutlineInputBorder()),
items: _statuts
.map(
(s) => DropdownMenuItem(
value: s,
child: Text(_displayStatus(s)),
),
)
.toList(),
onChanged: (v) {
if (v == null) return;
setState(() {
_statut = v;
_dirty = true;
});
},
),
),
],
),
),
_field('Nom', _nomCtrl),
_field('Prénom', _prenomCtrl),
_field('Email', _emailCtrl, keyboard: TextInputType.emailAddress),
_field('Téléphone', _telCtrl, keyboard: TextInputType.phone),
if (_telCtrl.text.isNotEmpty)
Padding(
padding: const EdgeInsets.only(left: 130, bottom: 4),
child: Text(
formatPhoneForDisplay(_telCtrl.text),
style: const TextStyle(fontSize: 12, color: Colors.black54),
),
),
_field('Adresse', _adresseCtrl),
_field('Ville', _villeCtrl),
_field('Code postal', _cpCtrl),
const SizedBox(height: 12),
Text(
'Nombre d\'enfants : ${_children.length}',
style: const TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 8),
Container(
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.all(8),
child: _children.isEmpty
? const Text('Aucun enfant rattaché', style: TextStyle(color: Colors.black54))
: Column(
children: _children
.map(
(c) => ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
title: InkWell(
onTap: () => _openChild(c),
child: Text(
c.fullName,
style: const TextStyle(
decoration: TextDecoration.underline,
color: Colors.blue,
),
),
),
subtitle: Text(_childStatusLabel(c.status)),
trailing: IconButton(
tooltip: 'Détacher',
icon: Icon(Icons.link_off, color: Colors.orange.shade800),
onPressed: () => _detachChild(c),
),
),
)
.toList(),
),
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: _attachChild,
icon: const Icon(Icons.link),
label: const Text('Rattacher un enfant'),
),
),
],
constraints: BoxConstraints(maxWidth: _modalWidth, maxHeight: maxH),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
const Padding(
padding: EdgeInsets.fromLTRB(18, 18, 0, 12),
child: Text(
'Fiche parent',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700),
),
),
const Spacer(),
SizedBox(width: 160, child: _statusDropdown()),
const SizedBox(width: 4),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
tooltip: 'Fermer',
),
],
),
const Divider(height: 1),
SizedBox(
height: _bodyHeight,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_identityFields(),
Row(
children: [
Text(
'Nombre d\'enfants : ${_children.length}',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
],
),
const SizedBox(height: 8),
Expanded(child: _childrenPanel()),
const SizedBox(height: 24),
_buildFooter(),
],
),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: _saving ? null : () => Navigator.of(context).pop(),
child: const Text('Fermer'),
),
const SizedBox(width: 8),
ElevatedButton.icon(
onPressed: !_dirty || _saving ? null : _save,
icon: _saving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save),
label: Text(_dirty ? 'Sauvegarder' : 'Aucune modification'),
),
],
),
],
),
),
],
),
),
);

View File

@ -1,4 +1,6 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/services/api/api_config.dart';
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
class AdminUserCard extends StatefulWidget {
final String title;
@ -37,6 +39,7 @@ class _AdminUserCardState extends State<AdminUserCard> {
widget.subtitleLines.where((e) => e.trim().isNotEmpty).join(' ');
final actionsWidth =
widget.actions.isNotEmpty ? widget.actions.length * 30.0 : 0.0;
final avatarUrl = ApiConfig.absoluteMediaUrl(widget.avatarUrl);
return MouseRegion(
onEnter: (_) => setState(() => _isHovered = true),
@ -60,20 +63,7 @@ class _AdminUserCardState extends State<AdminUserCard> {
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
child: Row(
children: [
CircleAvatar(
radius: 14,
backgroundColor: const Color(0xFFEDE5FA),
backgroundImage: widget.avatarUrl != null
? NetworkImage(widget.avatarUrl!)
: null,
child: widget.avatarUrl == null
? Icon(
widget.fallbackIcon,
size: 16,
color: const Color(0xFF6B3FA0),
)
: null,
),
_buildAvatar(avatarUrl),
const SizedBox(width: 10),
Expanded(
child: Row(
@ -140,4 +130,35 @@ class _AdminUserCardState extends State<AdminUserCard> {
),
);
}
Widget _buildAvatar(String url) {
const size = 28.0;
const bg = Color(0xFFEDE5FA);
const iconColor = Color(0xFF6B3FA0);
if (url.isEmpty) {
return CircleAvatar(
radius: 14,
backgroundColor: bg,
child: Icon(widget.fallbackIcon, size: 16, color: iconColor),
);
}
return ClipOval(
child: SizedBox(
width: size,
height: size,
child: AuthNetworkImage(
url: url,
width: size,
height: size,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => ColoredBox(
color: bg,
child: Icon(widget.fallbackIcon, size: 16, color: iconColor),
),
),
),
);
}
}

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'admin_detail_modal.dart';
/// Bloc type formulaire (titre de section + champs read-only) pour les modales de validation.
@ -23,6 +24,39 @@ class ValidationDetailSection extends StatelessWidget {
this.rowFlex,
});
@override
Widget build(BuildContext context) {
return ValidationFormGrid(
title: title,
rowLayout: rowLayout,
rowFlex: rowFlex,
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;
const ValidationFormGrid({
super.key,
this.title,
required this.fields,
this.rowLayout,
this.rowFlex,
});
@override
Widget build(BuildContext context) {
final layout = rowLayout ?? List.filled(fields.length, 1);
@ -39,7 +73,7 @@ class ValidationDetailSection extends StatelessWidget {
if (count == 1) {
rows.add(Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _buildFieldCell(rowFields.first),
child: rowFields.first,
));
} else {
rows.add(Padding(
@ -53,7 +87,7 @@ class ValidationDetailSection extends StatelessWidget {
flex: (flexForRow != null && i < flexForRow.length)
? flexForRow[i]
: 1,
child: _buildFieldCell(rowFields[i]),
child: rowFields[i],
),
],
],
@ -81,14 +115,62 @@ class ValidationDetailSection extends StatelessWidget {
],
);
}
}
Widget _buildFieldCell(AdminDetailField field) {
/// Décoration commune lecture seule / édition (modales validation, fiches admin).
class ValidationFieldDecoration {
ValidationFieldDecoration._();
static InputDecoration input({String? hint}) {
return InputDecoration(
isDense: true,
filled: true,
fillColor: Colors.grey.shade50,
hintText: hint,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
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 BoxDecoration container() {
return BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: Colors.grey.shade300),
);
}
}
/// Libellé au-dessus dun champ (même typo que [ValidationDetailSection]).
class ValidationLabeledField extends StatelessWidget {
final String label;
final Widget field;
const ValidationLabeledField({
super.key,
required this.label,
required this.field,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
field.label,
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
@ -96,13 +178,66 @@ class ValidationDetailSection extends StatelessWidget {
),
),
const SizedBox(height: 4),
ValidationReadOnlyField(value: field.value),
field,
],
);
}
}
/// Champ texte en lecture seule, style formulaire (fond gris léger, bordure). Réutilisable en éditable plus tard.
/// 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;
const ValidationEditableField({
super.key,
required this.controller,
this.keyboardType = TextInputType.text,
this.inputFormatters,
this.hintText,
this.maxLines = 1,
});
@override
Widget build(BuildContext context) {
return TextField(
controller: controller,
keyboardType: keyboardType,
inputFormatters: inputFormatters,
maxLines: maxLines,
style: const TextStyle(color: Colors.black87, fontSize: 14),
decoration: ValidationFieldDecoration.input(hint: hintText),
);
}
}
/// 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;
const ValidationEditableSection({
super.key,
required this.fields,
this.rowLayout,
this.rowFlex,
});
@override
Widget build(BuildContext context) {
return ValidationFormGrid(
rowLayout: rowLayout,
rowFlex: rowFlex,
fields: fields,
);
}
}
/// Champ texte en lecture seule, style formulaire (fond gris léger, bordure).
class ValidationReadOnlyField extends StatelessWidget {
final String value;
final int? maxLines;
@ -118,11 +253,7 @@ class ValidationReadOnlyField extends StatelessWidget {
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: Colors.grey.shade300),
),
decoration: ValidationFieldDecoration.container(),
child: Text(
value,
style: const TextStyle(color: Colors.black87, fontSize: 14),

View File

@ -1,13 +1,13 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/dossier_unifie.dart';
import 'package:p_tits_pas/utils/date_display_utils.dart';
import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/utils/nir_utils.dart';
import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/services/api/api_config.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
import 'package:p_tits_pas/widgets/common/identity_block.dart';
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
import 'validation_modal_theme.dart';
import 'validation_refus_form.dart';
@ -60,21 +60,6 @@ class _ValidationAmWizardState extends State<ValidationAmWizard> {
void _emitStep() => widget.onStepChanged?.call(_step, _stepCount);
/// Même ordre et disposition que le formulaire de création de compte (Nom/Prénom, Tél/Email, Adresse, CP/Ville).
List<AdminDetailField> _personalFields(AppUser u) => [
AdminDetailField(label: 'Nom', value: _v(u.nom)),
AdminDetailField(label: 'Prénom', value: _v(u.prenom)),
AdminDetailField(
label: 'Téléphone',
value: _v(u.telephone) != ''
? formatPhoneForDisplay(_v(u.telephone))
: ''),
AdminDetailField(label: 'Email', value: _v(u.email)),
AdminDetailField(label: 'Adresse (N° et Rue)', value: _v(u.adresse)),
AdminDetailField(label: 'Code postal', value: _v(u.codePostal)),
AdminDetailField(label: 'Ville', value: _v(u.ville)),
];
/// Panneau photo + grille droite : NIR|naissance, ville|pays, agrément|date, capa|places.
List<AdminDetailField> _photoProFields(DossierAM d) {
final u = d.user;
@ -110,10 +95,7 @@ class _ValidationAmWizardState extends State<ValidationAmWizard> {
];
}
static const List<int> _personalRowLayout = [2, 2, 1, 2];
static const Map<int, List<int>> _personalRowFlex = {
3: [2, 5]
}; // Code postal étroit, Ville large
static const List<int> _photoProRowLayout = [2, 2, 2, 2];
/// Proportion photo didentité (35×45 mm).
static const double _idPhotoAspectRatio = 35 / 45;
@ -266,11 +248,10 @@ class _ValidationAmWizardState extends State<ValidationAmWizard> {
return SingleChildScrollView(
child: ConstrainedBox(
constraints: BoxConstraints(minWidth: constraints.maxWidth),
child: ValidationDetailSection(
child: IdentityBlock.readOnlyFromUser(
u,
title: 'Identité et coordonnées',
fields: _personalFields(u),
rowLayout: _personalRowLayout,
rowFlex: _personalRowFlex,
emptyLabel: '',
),
),
);
@ -311,7 +292,7 @@ class _ValidationAmWizardState extends State<ValidationAmWizard> {
child: ValidationDetailSection(
title: 'Dossier professionnel',
fields: _photoProFields(d),
rowLayout: const [2, 2, 2, 2],
rowLayout: _photoProRowLayout,
),
),
);

View File

@ -3,11 +3,10 @@ import 'package:flutter/gestures.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:p_tits_pas/models/dossier_unifie.dart';
import 'package:p_tits_pas/utils/date_display_utils.dart';
import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/services/api/api_config.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
import 'package:p_tits_pas/widgets/common/identity_block.dart';
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
import 'validation_modal_theme.dart';
import 'validation_refus_form.dart';
@ -108,26 +107,6 @@ class _ValidationFamilyWizardState extends State<ValidationFamilyWizard> {
static String _formatBirthDate(String? s) =>
formatIsoDateFr(s, ifEmpty: 'Non défini');
/// Même ordre et disposition que le formulaire de création (Nom/Prénom, Tél/Email, Adresse, CP/Ville).
List<AdminDetailField> _parentFields(ParentDossier p) => [
AdminDetailField(label: 'Nom', value: _v(p.nom)),
AdminDetailField(label: 'Prénom', value: _v(p.prenom)),
AdminDetailField(
label: 'Téléphone',
value: _v(p.telephone) != 'Non défini'
? formatPhoneForDisplay(_v(p.telephone))
: 'Non défini'),
AdminDetailField(label: 'Email', value: _v(p.email)),
AdminDetailField(label: 'Adresse (N° et Rue)', value: _v(p.adresse)),
AdminDetailField(label: 'Code postal', value: _v(p.codePostal)),
AdminDetailField(label: 'Ville', value: _v(p.ville)),
];
static const List<int> _parentRowLayout = [2, 2, 1, 2];
static const Map<int, List<int>> _parentRowFlex = {
3: [2, 5]
}; // Code postal étroit, Ville large
static String _fullPhotoUrl(String? url) => ApiConfig.absoluteMediaUrl(url);
@override
@ -153,11 +132,9 @@ class _ValidationFamilyWizardState extends State<ValidationFamilyWizard> {
final d = widget.dossier;
switch (_step) {
case 0:
return ValidationDetailSection(
return IdentityBlock.readOnlyFromParentDossier(
d.parents.first,
title: 'Parent principal',
fields: _parentFields(d.parents.first),
rowLayout: _parentRowLayout,
rowFlex: _parentRowFlex,
);
case 1:
return _buildParent2Step();
@ -178,11 +155,9 @@ class _ValidationFamilyWizardState extends State<ValidationFamilyWizard> {
style: TextStyle(color: Colors.black87)),
);
}
return ValidationDetailSection(
return IdentityBlock.readOnlyFromParentDossier(
widget.dossier.parents[1],
title: 'Deuxième parent',
fields: _parentFields(widget.dossier.parents[1]),
rowLayout: _parentRowLayout,
rowFlex: _parentRowFlex,
);
}

View File

@ -0,0 +1,278 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/dossier_unifie.dart';
import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
/// Valeurs affichées dans un [IdentityBlock] en lecture seule.
class IdentityValues {
final String nom;
final String prenom;
final String telephone;
final String email;
final String adresse;
final String codePostal;
final String ville;
const IdentityValues({
required this.nom,
required this.prenom,
required this.telephone,
required this.email,
required this.adresse,
required this.codePostal,
required this.ville,
});
static String _fmt(String? s, String empty) {
final t = (s ?? '').trim();
return t.isEmpty ? empty : t;
}
static String _fmtPhone(String? s, String empty) {
final tel = _fmt(s, empty);
return tel != empty ? formatPhoneForDisplay(tel) : tel;
}
factory IdentityValues.fromUser(
AppUser user, {
String emptyLabel = 'Non défini',
}) {
return IdentityValues(
nom: _fmt(user.nom, emptyLabel),
prenom: _fmt(user.prenom, emptyLabel),
telephone: _fmtPhone(user.telephone, emptyLabel),
email: _fmt(user.email, emptyLabel),
adresse: _fmt(user.adresse, emptyLabel),
codePostal: _fmt(user.codePostal, emptyLabel),
ville: _fmt(user.ville, emptyLabel),
);
}
factory IdentityValues.fromParentDossier(
ParentDossier parent, {
String emptyLabel = 'Non défini',
}) {
return IdentityValues(
nom: _fmt(parent.nom, emptyLabel),
prenom: _fmt(parent.prenom, emptyLabel),
telephone: _fmtPhone(parent.telephone, emptyLabel),
email: _fmt(parent.email, emptyLabel),
adresse: _fmt(parent.adresse, emptyLabel),
codePostal: _fmt(parent.codePostal, emptyLabel),
ville: _fmt(parent.ville, emptyLabel),
);
}
}
/// Bloc identité : Nom/Prénom, Tél/Email, Adresse, CP/Ville.
/// Grille partagée (création de compte, validation AM/famille, fiches admin, etc.).
class IdentityBlock extends StatelessWidget { final String? title;
final String? _nom;
final String? _prenom;
final String? _telephone;
final String? _email;
final String? _adresse;
final String? _codePostal;
final String? _ville;
final TextEditingController? _nomCtrl;
final TextEditingController? _prenomCtrl;
final TextEditingController? _telCtrl;
final TextEditingController? _emailCtrl;
final TextEditingController? _adresseCtrl;
final TextEditingController? _cpCtrl;
final TextEditingController? _villeCtrl;
static const List<int> rowLayout = [2, 2, 1, 2];
static const Map<int, List<int>> rowFlex = {3: [2, 5]};
const IdentityBlock.readOnly({
super.key,
this.title,
required String nom,
required String prenom,
required String telephone,
required String email,
required String adresse,
required String codePostal,
required String ville,
}) : _nom = nom,
_prenom = prenom,
_telephone = telephone,
_email = email,
_adresse = adresse,
_codePostal = codePostal,
_ville = ville,
_nomCtrl = null,
_prenomCtrl = null,
_telCtrl = null,
_emailCtrl = null,
_adresseCtrl = null,
_cpCtrl = null,
_villeCtrl = null;
const IdentityBlock.editable({
super.key,
this.title,
required TextEditingController nomController,
required TextEditingController prenomController,
required TextEditingController telephoneController,
required TextEditingController emailController,
required TextEditingController adresseController,
required TextEditingController codePostalController,
required TextEditingController villeController,
}) : _nom = null,
_prenom = null,
_telephone = null,
_email = null,
_adresse = null,
_codePostal = null,
_ville = null,
_nomCtrl = nomController,
_prenomCtrl = prenomController,
_telCtrl = telephoneController,
_emailCtrl = emailController,
_adresseCtrl = adresseController,
_cpCtrl = codePostalController,
_villeCtrl = villeController;
/// Lecture seule à partir de [IdentityValues].
factory IdentityBlock.readOnlyValues({
Key? key,
String? title,
required IdentityValues values,
}) {
return IdentityBlock.readOnly(
key: key,
title: title,
nom: values.nom,
prenom: values.prenom,
telephone: values.telephone,
email: values.email,
adresse: values.adresse,
codePostal: values.codePostal,
ville: values.ville,
);
}
/// Lecture seule depuis un [AppUser] (validation AM, fiche AM, etc.).
factory IdentityBlock.readOnlyFromUser(
AppUser user, {
Key? key,
String? title,
String emptyLabel = 'Non défini',
}) {
return IdentityBlock.readOnlyValues(
key: key,
title: title,
values: IdentityValues.fromUser(user, emptyLabel: emptyLabel),
);
}
/// Lecture seule depuis un [ParentDossier] (validation famille).
factory IdentityBlock.readOnlyFromParentDossier(
ParentDossier parent, {
Key? key,
String? title,
String emptyLabel = 'Non défini',
}) {
return IdentityBlock.readOnlyValues(
key: key,
title: title,
values: IdentityValues.fromParentDossier(
parent,
emptyLabel: emptyLabel,
),
);
}
bool get _isEditable => _nomCtrl != null;
@override
Widget build(BuildContext context) {
if (_isEditable) {
return ValidationFormGrid(
title: title,
rowLayout: rowLayout,
rowFlex: rowFlex,
fields: [
ValidationLabeledField(
label: 'Nom',
field: ValidationEditableField(controller: _nomCtrl!),
),
ValidationLabeledField(
label: 'Prénom',
field: ValidationEditableField(controller: _prenomCtrl!),
),
ValidationLabeledField(
label: 'Téléphone',
field: ValidationEditableField(
controller: _telCtrl!,
keyboardType: TextInputType.phone,
inputFormatters: frenchPhoneInputFormatters,
),
),
ValidationLabeledField(
label: 'Email',
field: ValidationEditableField(
controller: _emailCtrl!,
keyboardType: TextInputType.emailAddress,
),
),
ValidationLabeledField(
label: 'Adresse (N° et Rue)',
field: ValidationEditableField(controller: _adresseCtrl!),
),
ValidationLabeledField(
label: 'Code postal',
field: ValidationEditableField(
controller: _cpCtrl!,
keyboardType: TextInputType.number,
),
),
ValidationLabeledField(
label: 'Ville',
field: ValidationEditableField(controller: _villeCtrl!),
),
],
);
}
return ValidationFormGrid(
title: title,
rowLayout: rowLayout,
rowFlex: rowFlex,
fields: [
ValidationLabeledField(
label: 'Nom',
field: ValidationReadOnlyField(value: _nom!),
),
ValidationLabeledField(
label: 'Prénom',
field: ValidationReadOnlyField(value: _prenom!),
),
ValidationLabeledField(
label: 'Téléphone',
field: ValidationReadOnlyField(value: _telephone!),
),
ValidationLabeledField(
label: 'Email',
field: ValidationReadOnlyField(value: _email!),
),
ValidationLabeledField(
label: 'Adresse (N° et Rue)',
field: ValidationReadOnlyField(value: _adresse!),
),
ValidationLabeledField(
label: 'Code postal',
field: ValidationReadOnlyField(value: _codePostal!),
),
ValidationLabeledField(
label: 'Ville',
field: ValidationReadOnlyField(value: _ville!),
),
],
);
}
}