feat(#131): fiche AM éditable en 3 onglets et widgets admin partagés.

Modale admin AM (identité, dossier pro, enfants), API front avec repli, note back affiliation, et extraction gélule statut / liste enfants pour la fiche parent.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
MARTIN Julien 2026-06-25 17:33:41 +02:00
parent 2fd97ddecb
commit 479a32b4bf
10 changed files with 1417 additions and 181 deletions

View File

@ -0,0 +1,132 @@
# #131 — Fiche AM éditable + affiliation enfants (note front → back)
**Ticket :** #131 (partie AM, doc `28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md` §6.1)
**Date :** 2026-06-01
**Statut front :** modale livrée (2 onglets) — **API affiliation AM↔enfant à implémenter**
---
## 1. Comportement UI (front)
Modale `AdminAmEditModal` — même shell que la fiche parent (~930 px) :
| Onglet | Contenu |
|--------|---------|
| **Identité & professionnel** | `IdentityBlock` éditable + grille pro (agrément, ville résidence, capacité, places, NIR/agrément date en lecture seule, biographie, switch disponible) + gélule statut |
| **Enfants accueillis** | Liste cartes enfants (réutilise `AdminChildrenAffiliationPanel` / `AdminEnfantUserCard`) + rattacher / détacher |
En-tête : prénom nom · sous-titre `Zone · Agrément · Dossier`.
---
## 2. Endpoints consommés
### Déjà existants (partiels)
| Méthode | Route | Usage |
|---------|-------|-------|
| `GET` | `/api/v1/assistantes-maternelles` | Liste AM |
| `GET` | `/api/v1/assistantes-maternelles/:userId` | Détail (403 possible pour `administrateur` → fallback liste) |
| `PATCH` | `/api/v1/users/:userId` | Identité + statut (admin / super_admin uniquement) |
| `PATCH` | `/api/v1/assistantes-maternelles/:userId` | Champs pro (gestionnaire / super_admin) |
### À créer (recommandé — miroir parent #131 / #115)
| Méthode | Route | Rôle |
|---------|-------|------|
| `PATCH` | `/api/v1/assistantes-maternelles/:userId/fiche` | Mise à jour unifiée identité + pro + statut (`super_admin`, `gestionnaire`, `administrateur`) |
| `POST` | `/api/v1/assistantes-maternelles/:userId/enfants/:enfantId` | Rattacher un enfant |
| `DELETE` | `/api/v1/assistantes-maternelles/:userId/enfants/:enfantId` | Détacher un enfant |
| `GET` | `/api/v1/assistantes-maternelles/:userId` | Inclure `amChildren[]` (relation enfant) |
Le front appelle déjà ces routes ; en labsence de `PATCH …/fiche`, il tente un fallback `PATCH users` + `PATCH assistantes-maternelles` (échoue selon le rôle connecté).
---
## 3. Modèle de données affiliation AM ↔ enfant
**À définir côté BDD** (pas de table dédiée aujourdhui, contrairement à `enfants_parents`) :
Proposition alignée parent :
```sql
-- Piste : enfants_assistantes_maternelles
CREATE TABLE enfants_assistantes_maternelles (
id_am UUID NOT NULL REFERENCES utilisateurs(id) ON DELETE CASCADE,
id_enfant UUID NOT NULL REFERENCES enfants(id) ON DELETE CASCADE,
PRIMARY KEY (id_am, id_enfant)
);
```
Réponse API attendue sur `GET /assistantes-maternelles/:id` :
```json
{
"user_id": "uuid-am",
"user": { "id": "…", "prenom": "Claire", "nom": "MARTIN", "statut": "actif" },
"approval_number": "AGR-2024-12345",
"residence_city": "Bezons",
"max_children": 4,
"places_available": 2,
"available": true,
"amChildren": [
{
"child": {
"id": "uuid-enfant",
"first_name": "Emma",
"last_name": "MARTIN",
"status": "actif",
"birth_date": "2023-02-15"
}
}
]
}
```
Le front parse `amChildren` / `am_children` / `assistanteChildren` (même logique que `parentChildren`).
---
## 4. Body `PATCH …/fiche` suggéré
```json
{
"nom": "MARTIN",
"prenom": "Claire",
"email": "claire@example.com",
"telephone": "0612345678",
"adresse": "5 place Bellecour",
"ville": "Lyon",
"code_postal": "69002",
"statut": "actif",
"approval_number": "AGR-2024-12345",
"residence_city": "Lyon",
"max_children": 4,
"places_available": 2,
"biography": "…",
"available": true
}
```
NIR et date dagrément : lecture seule dans la modale (modification hors périmètre admin v1).
---
## 5. Fichiers front concernés
| Fichier | Rôle |
|---------|------|
| `frontend/lib/widgets/admin/common/admin_am_edit_modal.dart` | Modale 2 onglets |
| `frontend/lib/widgets/admin/common/admin_children_affiliation_panel.dart` | Liste enfants partagée parent/AM |
| `frontend/lib/widgets/admin/common/admin_status_capsule.dart` | Gélule statut partagée |
| `frontend/lib/models/assistante_maternelle_model.dart` | Parse champs pro + `amChildren` |
| `frontend/lib/services/user_service.dart` | `getAssistanteMaternelle`, `updateAmFiche`, `attachEnfantToAm`, `detachEnfantFromAm` |
| `frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart` | Ouverture modale au clic Modifier |
---
## 6. Références
- Fiche parent : `PATCH /parents/:id/fiche`, `POST|DELETE /parents/:id/enfants/:enfantId`
- Ticket Gitea **#131**, **#115**
- `docs/archive/temporaires/TEMP_131-back-fiche-parent-co-parent.md`

View File

@ -1,30 +1,102 @@
import 'package:p_tits_pas/models/parent_child_summary.dart';
import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/models/user.dart';
class AssistanteMaternelleModel { class AssistanteMaternelleModel {
final AppUser user; final AppUser user;
final String? approvalNumber; final String? approvalNumber;
final String? nir;
final String? residenceCity; final String? residenceCity;
final int? maxChildren; final int? maxChildren;
final int? placesAvailable; final int? placesAvailable;
final String? biography;
final bool? available;
final String? agreementDate;
final List<ParentChildSummary> children;
AssistanteMaternelleModel({ AssistanteMaternelleModel({
required this.user, required this.user,
this.approvalNumber, this.approvalNumber,
this.nir,
this.residenceCity, this.residenceCity,
this.maxChildren, this.maxChildren,
this.placesAvailable, this.placesAvailable,
this.biography,
this.available,
this.agreementDate,
this.children = const [],
}); });
factory AssistanteMaternelleModel.fromJson(Map<String, dynamic> json) { factory AssistanteMaternelleModel.fromJson(Map<String, dynamic> json) {
final userJson = json['user'] ?? json; 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 user = AppUser.fromJson(userJson);
final children = _parseChildren(root);
return AssistanteMaternelleModel( return AssistanteMaternelleModel(
user: user, user: user,
approvalNumber: json['numero_agrement'] as String?, approvalNumber: _str(
residenceCity: json['ville_residence'] as String?, root['approval_number'] ?? root['numero_agrement'],
maxChildren: json['nb_max_enfants'] as int?, ),
placesAvailable: json['place_disponible'] as int?, nir: _str(root['nir'] ?? root['nir_chiffre']),
residenceCity: _str(
root['residence_city'] ?? root['ville_residence'],
),
maxChildren: _int(root['max_children'] ?? root['nb_max_enfants']),
placesAvailable: _int(
root['places_available'] ?? root['place_disponible'],
),
biography: _str(root['biography'] ?? root['biographie']),
available: root['available'] as bool? ?? root['disponible'] as bool?,
agreementDate: _dateString(
root['agreement_date'] ?? root['date_agrement'],
),
children: children,
); );
} }
static String? _str(dynamic v) {
if (v == null) return null;
final s = v.toString().trim();
return s.isEmpty ? null : s;
}
static int? _int(dynamic v) {
if (v == null) return null;
if (v is int) return v;
return int.tryParse(v.toString());
}
static String? _dateString(dynamic v) {
if (v == null) return null;
return v.toString().split('T').first;
}
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['amChildren'] ??
json['am_children'] ??
json['assistanteChildren'] ??
json['assistante_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

@ -513,7 +513,150 @@ class UserService {
} }
final List<dynamic> data = jsonDecode(response.body); final List<dynamic> data = jsonDecode(response.body);
return data.map((e) => AssistanteMaternelleModel.fromJson(e)).toList(); return data
.map((e) => AssistanteMaternelleModel.fromJson(
Map<String, dynamic>.from(e as Map),
))
.toList();
}
static Future<AssistanteMaternelleModel> getAssistanteMaternelle(
String userId,
) async {
final response = await http.get(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.assistantesMaternelles}/$userId'),
headers: await _headers(),
);
if (response.statusCode == 200) {
final decoded = jsonDecode(response.body);
return AssistanteMaternelleModel.fromJson(
Map<String, dynamic>.from(decoded is Map ? decoded : {}),
);
}
if (response.statusCode == 403 || response.statusCode == 404) {
final all = await getAssistantesMaternelles();
return all.firstWhere(
(a) => a.user.id == userId,
orElse: () => throw Exception('Assistante maternelle introuvable'),
);
}
final err = jsonDecode(response.body) as Map<String, dynamic>?;
throw Exception(_toStr(err?['message']) ?? 'Erreur chargement AM');
}
/// Mise à jour fiche AM (identité + champs pro). Ticket #131.
static Future<AssistanteMaternelleModel> updateAmFiche({
required String amUserId,
required Map<String, dynamic> body,
}) async {
final ficheResponse = await http.patch(
Uri.parse(
'${ApiConfig.baseUrl}${ApiConfig.assistantesMaternelles}/$amUserId/fiche',
),
headers: await _headers(),
body: jsonEncode(body),
);
if (ficheResponse.statusCode == 200) {
return _amModelFromBody(ficheResponse.body);
}
if (ficheResponse.statusCode != 404) {
throw Exception(
_extractErrorMessage(ficheResponse.body, 'Erreur mise à jour AM'),
);
}
final userFields = <String, dynamic>{};
for (final k in [
'nom',
'prenom',
'email',
'telephone',
'adresse',
'ville',
'code_postal',
'statut',
]) {
if (body.containsKey(k)) userFields[k] = body[k];
}
final proFields = <String, dynamic>{};
for (final entry in body.entries) {
if (!userFields.containsKey(entry.key)) {
proFields[entry.key] = entry.value;
}
}
if (userFields.isNotEmpty) {
final userResponse = await http.patch(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$amUserId'),
headers: await _headers(),
body: jsonEncode(userFields),
);
if (userResponse.statusCode != 200) {
throw Exception(
_extractErrorMessage(userResponse.body, 'Erreur mise à jour identité'),
);
}
}
if (proFields.isNotEmpty) {
final proResponse = await http.patch(
Uri.parse(
'${ApiConfig.baseUrl}${ApiConfig.assistantesMaternelles}/$amUserId',
),
headers: await _headers(),
body: jsonEncode(proFields),
);
if (proResponse.statusCode != 200) {
throw Exception(
_extractErrorMessage(proResponse.body, 'Erreur mise à jour fiche pro'),
);
}
}
return getAssistanteMaternelle(amUserId);
}
static Future<AssistanteMaternelleModel> attachEnfantToAm({
required String amUserId,
required String enfantId,
}) async {
final response = await http.post(
Uri.parse(
'${ApiConfig.baseUrl}${ApiConfig.assistantesMaternelles}/$amUserId/enfants/$enfantId',
),
headers: await _headers(),
);
if (response.statusCode != 200 && response.statusCode != 201) {
throw Exception(_extractErrorMessage(response.body, 'Erreur rattachement enfant'));
}
return _amModelFromBody(response.body);
}
static Future<AssistanteMaternelleModel> detachEnfantFromAm({
required String amUserId,
required String enfantId,
}) async {
final response = await http.delete(
Uri.parse(
'${ApiConfig.baseUrl}${ApiConfig.assistantesMaternelles}/$amUserId/enfants/$enfantId',
),
headers: await _headers(),
);
if (response.statusCode != 200 && response.statusCode != 204) {
throw Exception(_extractErrorMessage(response.body, 'Erreur détachement enfant'));
}
if (response.body.isEmpty) {
return getAssistanteMaternelle(amUserId);
}
return _amModelFromBody(response.body);
}
static AssistanteMaternelleModel _amModelFromBody(String body) {
final decoded = jsonDecode(body);
return AssistanteMaternelleModel.fromJson(
Map<String, dynamic>.from(decoded is Map ? decoded : {}),
);
} }
// Récupérer la liste des administrateurs (via /users filtré ou autre) // Récupérer la liste des administrateurs (via /users filtré ou autre)

View File

@ -1,8 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/assistante_maternelle_model.dart'; import 'package:p_tits_pas/models/assistante_maternelle_model.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/user_service.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_am_edit_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
import 'package:p_tits_pas/widgets/admin/common/user_list.dart'; import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
@ -102,55 +101,10 @@ class _AssistanteMaternelleManagementWidgetState
void _openAssistanteDetails(AssistanteMaternelleModel assistante) { void _openAssistanteDetails(AssistanteMaternelleModel assistante) {
showDialog<void>( showDialog<void>(
context: context, context: context,
builder: (context) => AdminDetailModal( builder: (context) => AdminAmEditModal(
title: assistante.user.fullName.isEmpty assistante: assistante,
? 'Assistante maternelle' onSaved: _loadAssistantes,
: assistante.user.fullName,
subtitle: assistante.user.email,
fields: [
AdminDetailField(label: 'ID', value: _v(assistante.user.id)),
AdminDetailField(
label: 'Numero agrement',
value: _v(assistante.approvalNumber),
),
AdminDetailField(
label: 'Ville residence',
value: _v(assistante.residenceCity),
),
AdminDetailField(
label: 'Capacite max',
value: assistante.maxChildren?.toString() ?? '-',
),
AdminDetailField(
label: 'Places disponibles',
value: assistante.placesAvailable?.toString() ?? '-',
),
AdminDetailField(
label: 'Telephone',
value: _v(assistante.user.telephone) != '' ? formatPhoneForDisplay(_v(assistante.user.telephone)) : '',
),
AdminDetailField(label: 'Adresse', value: _v(assistante.user.adresse)),
AdminDetailField(label: 'Ville', value: _v(assistante.user.ville)),
AdminDetailField(
label: 'Code postal',
value: _v(assistante.user.codePostal),
),
],
onEdit: () {
Navigator.of(context).pop();
ScaffoldMessenger.of(this.context).showSnackBar(
const SnackBar(content: Text('Action Modifier a implementer')),
);
},
onDelete: () {
Navigator.of(context).pop();
ScaffoldMessenger.of(this.context).showSnackBar(
const SnackBar(content: Text('Action Supprimer a implementer')),
);
},
), ),
); );
} }
String _v(String? value) => (value == null || value.isEmpty) ? '-' : value;
} }

View File

@ -0,0 +1,721 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
import 'package:p_tits_pas/models/enfant_admin_model.dart';
import 'package:p_tits_pas/models/parent_child_summary.dart';
import 'package:p_tits_pas/utils/date_display_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/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_children_affiliation_panel.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_status_capsule.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 AM éditable (ticket #131) identité | fiche pro (photo) | enfants.
class AdminAmEditModal extends StatefulWidget {
final AssistanteMaternelleModel assistante;
final VoidCallback? onSaved;
const AdminAmEditModal({
super.key,
required this.assistante,
this.onSaved,
});
@override
State<AdminAmEditModal> createState() => _AdminAmEditModalState();
}
class _AdminAmEditModalState extends State<AdminAmEditModal>
with SingleTickerProviderStateMixin {
late final TabController _tabCtrl;
late final TextEditingController _nomCtrl;
late final TextEditingController _prenomCtrl;
late final TextEditingController _emailCtrl;
late final TextEditingController _telCtrl;
late final TextEditingController _adresseCtrl;
late final TextEditingController _villeCtrl;
late final TextEditingController _cpCtrl;
late final TextEditingController _agrementCtrl;
late final TextEditingController _capaciteCtrl;
late final TextEditingController _placesCtrl;
late String _statut;
late bool _disponible;
late List<ParentChildSummary> _children;
late final ScrollController _childrenScrollCtrl;
bool _saving = false;
bool _dirty = false;
static const double _modalWidth = 930;
static const double _photoProGap = 24;
static const double _proColumnMinWidth = 260;
static const double _photoColumnMinWidth = 160;
static const double _proTabHeight = 300;
static const List<int> _photoProRowLayout = [2, 2, 2];
double _tabViewHeight(int index) {
switch (index) {
case 1:
return _proTabHeight;
case 2:
return 280;
default:
return 292;
}
}
@override
void initState() {
super.initState();
_tabCtrl = TabController(length: 3, vsync: this);
final u = widget.assistante.user;
final am = widget.assistante;
_nomCtrl = TextEditingController(text: u.nom ?? '');
_prenomCtrl = TextEditingController(text: u.prenom ?? '');
_emailCtrl = TextEditingController(text: u.email);
_telCtrl = TextEditingController(
text: formatPhoneForDisplay(u.telephone ?? ''),
);
_adresseCtrl = TextEditingController(text: u.adresse ?? '');
_villeCtrl = TextEditingController(text: u.ville ?? '');
_cpCtrl = TextEditingController(text: u.codePostal ?? '');
_agrementCtrl = TextEditingController(text: am.approvalNumber ?? '');
_capaciteCtrl = TextEditingController(
text: am.maxChildren?.toString() ?? '',
);
_placesCtrl = TextEditingController(
text: am.placesAvailable?.toString() ?? '',
);
_statut = u.statut ?? 'en_attente';
_disponible = am.available ?? true;
_children = List.of(am.children);
_childrenScrollCtrl = ScrollController();
for (final c in [
_nomCtrl,
_prenomCtrl,
_emailCtrl,
_telCtrl,
_adresseCtrl,
_villeCtrl,
_cpCtrl,
_agrementCtrl,
_capaciteCtrl,
_placesCtrl,
]) {
c.addListener(_markDirty);
}
_nomCtrl.addListener(_onNameFieldChanged);
_prenomCtrl.addListener(_onNameFieldChanged);
_tabCtrl.addListener(_onTabChanged);
_reloadChildren();
}
void _onTabChanged() {
if (!_tabCtrl.indexIsChanging) setState(() {});
}
void _onNameFieldChanged() => setState(() {});
@override
void dispose() {
_tabCtrl.dispose();
for (final c in [
_nomCtrl,
_prenomCtrl,
_emailCtrl,
_telCtrl,
_adresseCtrl,
_villeCtrl,
_cpCtrl,
_agrementCtrl,
_capaciteCtrl,
_placesCtrl,
]) {
c.dispose();
}
_childrenScrollCtrl.dispose();
super.dispose();
}
void _markDirty() {
if (!_dirty) setState(() => _dirty = true);
}
String _headerTitle() {
final fn = _prenomCtrl.text.trim();
final ln = _nomCtrl.text.trim();
if (fn.isEmpty && ln.isEmpty) {
final fallback = widget.assistante.user.fullName.trim();
return fallback.isNotEmpty ? fallback : 'Assistante maternelle';
}
return '$fn $ln'.trim();
}
String? _headerSubtitle() {
final parts = <String>[];
final zone = (widget.assistante.residenceCity ?? '').trim();
if (zone.isNotEmpty) parts.add('Zone : $zone');
final agrement = _agrementCtrl.text.trim();
if (agrement.isNotEmpty) parts.add('Agrément : $agrement');
final dossier = widget.assistante.user.numeroDossier?.trim();
if (dossier != null && dossier.isNotEmpty) {
parts.add('Dossier $dossier');
}
if (parts.isEmpty) return null;
return parts.join(' · ');
}
String _formatNirDisplay() {
final raw = widget.assistante.nir?.trim() ?? '';
if (raw.isEmpty) return '';
final digits = nirToRaw(raw).toUpperCase();
return digits.length == 15 ? formatNir(digits) : raw;
}
int? _parseIntField(TextEditingController c) {
final t = c.text.trim();
if (t.isEmpty) return null;
return int.tryParse(t);
}
Future<void> _save() async {
if (!_dirty) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Confirmer'),
content: const Text(
'Enregistrer les modifications de la fiche assistante maternelle ?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Sauvegarder'),
),
],
),
);
if (confirmed != true || !mounted) return;
setState(() => _saving = true);
try {
await UserService.updateAmFiche(
amUserId: widget.assistante.user.id,
body: {
'nom': _nomCtrl.text.trim(),
'prenom': _prenomCtrl.text.trim(),
'email': _emailCtrl.text.trim(),
'telephone': normalizePhone(_telCtrl.text),
'adresse': _adresseCtrl.text.trim(),
'ville': _villeCtrl.text.trim(),
'code_postal': _cpCtrl.text.trim(),
'statut': _statut,
'approval_number': _agrementCtrl.text.trim(),
if (_parseIntField(_capaciteCtrl) != null)
'max_children': _parseIntField(_capaciteCtrl),
if (_parseIntField(_placesCtrl) != null)
'places_available': _parseIntField(_placesCtrl),
'available': _disponible,
},
);
if (!mounted) return;
setState(() {
_dirty = false;
_saving = false;
});
widget.onSaved?.call();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Fiche assistante maternelle enregistrée')),
);
} catch (e) {
if (!mounted) return;
setState(() => _saving = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
);
}
}
Future<void> _reloadChildren() async {
try {
final refreshed =
await UserService.getAssistanteMaternelle(widget.assistante.user.id);
final all = await UserService.getEnfants();
final byId = {for (final e in all) e.id: e};
final kids = refreshed.children.map((c) {
final full = byId[c.id];
if (full != null) return ParentChildSummary.fromEnfant(full);
return c;
}).toList();
if (!mounted) return;
setState(() => _children = kids);
} catch (_) {}
}
Future<void> _openChild(ParentChildSummary child) async {
try {
final enfant = await UserService.getEnfant(child.id);
if (!mounted) return;
await showDialog<void>(
context: context,
builder: (ctx) => AdminChildDetailModal(
enfant: enfant,
onSaved: _reloadChildren,
),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
);
}
}
Future<void> _detachChild(ParentChildSummary child) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Détacher l\'enfant'),
content: Text(
'Retirer ${child.fullName} de la fiche de cette assistante ?\n'
'(L\'enfant ne sera pas supprimé.)',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () => Navigator.pop(ctx, true),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red.shade700,
),
child: const Text('Détacher'),
),
],
),
);
if (confirmed != true || !mounted) return;
try {
await UserService.detachEnfantFromAm(
amUserId: widget.assistante.user.id,
enfantId: child.id,
);
if (!mounted) return;
await _reloadChildren();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Enfant détaché')),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
);
}
}
Future<void> _attachChild() async {
List<EnfantAdminModel> all;
try {
all = await UserService.getEnfants();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
);
return;
}
final linkedIds = _children.map((c) => c.id).toSet();
final candidates = all.where((e) => !linkedIds.contains(e.id)).toList();
if (candidates.isEmpty) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Aucun enfant disponible à rattacher')),
);
return;
}
if (!mounted) return;
final selected = await showDialog<EnfantAdminModel>(
context: context,
builder: (ctx) => SimpleDialog(
title: const Text('Rattacher un enfant'),
children: candidates
.map(
(e) => SimpleDialogOption(
onPressed: () => Navigator.pop(ctx, e),
child: Text(e.fullName),
),
)
.toList(),
),
);
if (selected == null || !mounted) return;
try {
await UserService.attachEnfantToAm(
amUserId: widget.assistante.user.id,
enfantId: selected.id,
);
if (!mounted) return;
await _reloadChildren();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Enfant rattaché')),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
);
}
}
String _v(String? s) =>
(s != null && s.trim().isNotEmpty) ? s.trim() : '';
Widget _identityTab() {
return SingleChildScrollView(
child: IdentityBlock.editable(
title: 'Identité et coordonnées',
nomController: _nomCtrl,
prenomController: _prenomCtrl,
telephoneController: _telCtrl,
emailController: _emailCtrl,
adresseController: _adresseCtrl,
codePostalController: _cpCtrl,
villeController: _villeCtrl,
),
);
}
Widget _proFieldsGrid() {
final u = widget.assistante.user;
return ValidationFormGrid(
title: 'Dossier professionnel',
rowLayout: _photoProRowLayout,
compact: true,
fields: [
ValidationLabeledField(
label: 'NIR',
field: ValidationReadOnlyField(
value: _formatNirDisplay(),
compact: true,
),
),
ValidationLabeledField(
label: 'Date de naissance',
field: ValidationReadOnlyField(
value: formatIsoDateFr(u.dateNaissance),
compact: true,
),
),
ValidationLabeledField(
label: 'Ville de naissance',
field: ValidationReadOnlyField(
value: _v(u.lieuNaissanceVille),
compact: true,
),
),
ValidationLabeledField(
label: 'Pays de naissance',
field: ValidationReadOnlyField(
value: _v(u.lieuNaissancePays),
compact: true,
),
),
ValidationLabeledField(
label: 'N° Agrément',
field: ValidationEditableField(
controller: _agrementCtrl,
compact: true,
),
),
ValidationLabeledField(
label: 'Date d\'agrément',
field: ValidationReadOnlyField(
value: formatIsoDateFr(widget.assistante.agreementDate),
compact: true,
),
),
],
);
}
Widget _proTab() {
return LayoutBuilder(
builder: (context, c) {
final maxRowW = c.maxWidth;
final maxRowH = c.maxHeight;
final bodyH = maxRowH;
final idealPhotoW = bodyH * AdminAmPhotoFrame.idPhotoAspectRatio + 16;
final maxPhotoW = (maxRowW - _photoProGap - _proColumnMinWidth)
.clamp(0.0, double.infinity);
var photoW = idealPhotoW.clamp(_photoColumnMinWidth, 220.0);
if (photoW > maxPhotoW) photoW = maxPhotoW;
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
width: photoW,
child: AdminAmPhotoFrame(
photoUrl: widget.assistante.user.photoUrl,
),
),
const SizedBox(width: _photoProGap),
Expanded(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_proFieldsGrid(),
SwitchListTile(
contentPadding: EdgeInsets.zero,
dense: true,
visualDensity: VisualDensity.compact,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
title: const Text(
'Disponible pour accueillir',
style: TextStyle(fontSize: 13),
),
value: _disponible,
onChanged: (v) => setState(() {
_disponible = v;
_dirty = true;
}),
),
],
),
),
),
],
);
},
);
}
Widget _childrenCapacityFields() {
return ValidationEditableSection(
compact: true,
rowLayout: const [2],
fields: [
ValidationLabeledField(
label: 'Capacité max (enfants)',
field: ValidationEditableField(
controller: _capaciteCtrl,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
compact: true,
),
),
ValidationLabeledField(
label: 'Places disponibles',
field: ValidationEditableField(
controller: _placesCtrl,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
compact: true,
),
),
],
);
}
Widget _childrenTab() {
return LayoutBuilder(
builder: (context, constraints) {
const headerBlock = 88.0;
const capacityBlock = 56.0;
const gaps = 20.0;
final listHeight = (constraints.maxHeight -
headerBlock -
capacityBlock -
gaps)
.clamp(72.0, AdminChildrenAffiliationPanel.defaultViewportHeight);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_childrenCapacityFields(),
const SizedBox(height: 10),
Text(
'Enfants accueillis : ${_children.length}',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
const SizedBox(height: 2),
Text(
'Enfants rattachés à cette assistante (accueil / garde).',
style: TextStyle(fontSize: 12, color: Colors.grey.shade700),
),
const SizedBox(height: 8),
Expanded(
child: AdminChildrenAffiliationPanel(
height: listHeight,
children: _children,
scrollController: _childrenScrollCtrl,
onOpen: _openChild,
onDetach: _detachChild,
emptyMessage: 'Aucun enfant rattaché à cette assistante',
),
),
],
);
},
);
}
Widget _buildFooter() {
final isChildrenTab = _tabCtrl.index == 2;
return Row(
children: [
TextButton(
onPressed: _saving ? null : () => Navigator.of(context).pop(),
child: const Text('Fermer'),
),
const Spacer(),
if (isChildrenTab)
TextButton.icon(
onPressed: _attachChild,
icon: const Icon(Icons.link, size: 18),
label: const Text('Rattacher un enfant'),
),
if (isChildrenTab) 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) {
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: _modalWidth),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(18, 16, 4, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_headerTitle(),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
),
),
if (_headerSubtitle() != null) ...[
const SizedBox(height: 4),
Text(
_headerSubtitle()!,
style: const TextStyle(
fontSize: 13,
color: Colors.black54,
),
),
],
],
),
),
const SizedBox(width: 12),
SizedBox(
width: 160,
child: AdminStatusCapsule(
statut: _statut,
onChanged: (v) => setState(() {
_statut = v;
_dirty = true;
}),
),
),
IconButton(
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 40,
minHeight: 40,
),
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
tooltip: 'Fermer',
),
],
),
),
TabBar(
controller: _tabCtrl,
onTap: (_) => setState(() {}),
labelColor: ValidationModalTheme.primaryActionBackground,
unselectedLabelColor: Colors.black54,
indicatorColor: ValidationModalTheme.primaryActionBackground,
tabs: const [
Tab(text: 'Identité'),
Tab(text: 'Fiche professionnelle'),
Tab(text: 'Enfants accueillis'),
],
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 0),
child: SizedBox(
height: _tabViewHeight(_tabCtrl.index),
child: TabBarView(
controller: _tabCtrl,
children: [
_identityTab(),
_proTab(),
_childrenTab(),
],
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 12),
child: _buildFooter(),
),
],
),
),
);
}
}

View File

@ -0,0 +1,112 @@
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';
/// Cadre photo identité AM (35×45 mm) même logique que [ValidationAmWizard].
class AdminAmPhotoFrame extends StatelessWidget {
final String? photoUrl;
static const double idPhotoAspectRatio = 35 / 45;
const AdminAmPhotoFrame({super.key, this.photoUrl});
/// Largeur colonne photo pour remplir [height] (cadre inclus).
static double columnWidthForHeight(double height) {
const frame = 16.0;
final innerH = (height - frame).clamp(0.0, double.infinity);
return innerH * idPhotoAspectRatio + frame;
}
@override
Widget build(BuildContext context) {
final fullUrl = ApiConfig.absoluteMediaUrl(photoUrl);
return LayoutBuilder(
builder: (context, c) {
const uniformFrame = 8.0;
final maxPhotoW =
(c.maxWidth - 2 * uniformFrame).clamp(0.0, double.infinity);
final maxPhotoH =
(c.maxHeight - 2 * uniformFrame).clamp(0.0, double.infinity);
const ar = idPhotoAspectRatio;
double ph = maxPhotoH;
double pw = ph * ar;
if (pw > maxPhotoW) {
pw = maxPhotoW;
ph = pw / ar;
}
return Container(
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade300),
),
clipBehavior: Clip.antiAlias,
child: Padding(
padding: const EdgeInsets.all(uniformFrame),
child: Align(
alignment: Alignment.topCenter,
child: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: SizedBox(
width: pw,
height: ph,
child: _photoContent(fullUrl, pw, ph),
),
),
),
),
);
},
);
}
Widget _photoContent(String fullUrl, double pw, double ph) {
if (fullUrl.isEmpty) {
return ColoredBox(
color: Colors.grey.shade200,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.person_off_outlined, size: 36, color: Colors.grey.shade400),
const SizedBox(height: 6),
Text(
'Aucune photo',
style: TextStyle(color: Colors.grey.shade600, fontSize: 11),
),
],
),
);
}
return AuthNetworkImage(
url: fullUrl,
width: pw,
height: ph,
fit: BoxFit.cover,
loadingBuilder: (_, child, progress) {
if (progress == null) return child;
return ColoredBox(
color: Colors.grey.shade200,
child: Center(
child: SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2,
value: progress.expectedTotalBytes != null
? progress.cumulativeBytesLoaded /
(progress.expectedTotalBytes!)
: null,
),
),
),
);
},
errorBuilder: (_, __, ___) => ColoredBox(
color: Colors.grey.shade200,
child: Icon(Icons.broken_image_outlined, size: 36, color: Colors.grey.shade400),
),
);
}
}

View File

@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/parent_child_summary.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_enfant_user_card.dart';
/// Liste scrollable d'enfants rattachés (fiche parent / fiche AM).
class AdminChildrenAffiliationPanel extends StatelessWidget {
final List<ParentChildSummary> children;
final ScrollController scrollController;
final void Function(ParentChildSummary child) onOpen;
final void Function(ParentChildSummary child) onDetach;
final String emptyMessage;
final double? height;
static const double _itemHeight = 58;
static const double defaultViewportHeight = _itemHeight * 2.5 + 8;
const AdminChildrenAffiliationPanel({
super.key,
required this.children,
required this.scrollController,
required this.onOpen,
required this.onDetach,
this.emptyMessage = 'Aucun enfant rattaché',
this.height,
});
@override
Widget build(BuildContext context) {
final panelHeight = height ?? defaultViewportHeight;
return Container(
height: panelHeight,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade300),
),
clipBehavior: Clip.antiAlias,
child: children.isEmpty
? Center(
child: Text(
emptyMessage,
style: const TextStyle(fontSize: 14, color: Colors.black54),
),
)
: ListView.builder(
controller: scrollController,
padding: const EdgeInsets.fromLTRB(8, 8, 8, 4),
itemExtent: _itemHeight,
itemCount: children.length,
itemBuilder: (_, i) {
final c = children[i];
return AdminEnfantUserCard.fromSummary(
c,
actions: [
IconButton(
icon: const Icon(Icons.visibility_outlined),
tooltip: 'Voir / modifier',
onPressed: () => onOpen(c),
),
IconButton(
tooltip: 'Détacher',
icon: Icon(Icons.link_off, color: Colors.orange.shade800),
onPressed: () => onDetach(c),
),
],
);
},
),
);
}
}

View File

@ -6,7 +6,8 @@ import 'package:p_tits_pas/models/parent_model.dart';
import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_enfant_user_card.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_children_affiliation_panel.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_status_capsule.dart';
import 'package:p_tits_pas/widgets/common/identity_block.dart'; import 'package:p_tits_pas/widgets/common/identity_block.dart';
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart'; import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
@ -42,13 +43,7 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
bool _saving = false; bool _saving = false;
bool _dirty = false; bool _dirty = false;
static const _statuts = ['actif', 'en_attente', 'suspendu', 'refuse'];
static const double _modalWidth = 930; static const double _modalWidth = 930;
/// Hauteur d'une ligne enfant (carte + marge) — viewport = 2,5 lignes visibles.
static const double _childListItemHeight = 58;
static const double _childrenListViewportHeight =
_childListItemHeight * 2.5 + 8;
@override @override
void initState() { void initState() {
@ -124,21 +119,6 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
return 'Co-parent : $name'; return 'Co-parent : $name';
} }
String _displayStatus(String status) {
switch (status) {
case 'actif':
return 'Actif';
case 'en_attente':
return 'En attente';
case 'suspendu':
return 'Suspendu';
case 'refuse':
return 'Refusé';
default:
return status;
}
}
Future<void> _save() async { Future<void> _save() async {
if (!_dirty) return; if (!_dirty) return;
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
@ -337,39 +317,12 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
} }
} }
Widget _statusDropdown() { Widget _childrenPanel() {
return Container( return AdminChildrenAffiliationPanel(
height: 34, children: _children,
decoration: BoxDecoration( scrollController: _childrenScrollCtrl,
color: Colors.white, onOpen: _openChild,
borderRadius: BorderRadius.circular(18), onDetach: _detachChild,
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;
});
},
),
),
); );
} }
@ -385,49 +338,6 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
); );
} }
Widget _childrenPanel() {
return Container(
height: _childrenListViewportHeight,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade300),
),
clipBehavior: Clip.antiAlias,
child: _children.isEmpty
? const Center(
child: Text(
'Aucun enfant rattaché',
style: TextStyle(fontSize: 14, color: Colors.black54),
),
)
: ListView.builder(
controller: _childrenScrollCtrl,
padding: const EdgeInsets.fromLTRB(8, 8, 8, 4),
itemExtent: _childListItemHeight,
itemCount: _children.length,
itemBuilder: (_, i) {
final c = _children[i];
return AdminEnfantUserCard.fromSummary(
c,
actions: [
IconButton(
icon: const Icon(Icons.visibility_outlined),
tooltip: 'Voir / modifier',
onPressed: () => _openChild(c),
),
IconButton(
tooltip: 'Détacher',
icon: Icon(Icons.link_off, color: Colors.orange.shade800),
onPressed: () => _detachChild(c),
),
],
);
},
),
);
}
Widget _buildFooter() { Widget _buildFooter() {
return Row( return Row(
children: [ children: [
@ -465,10 +375,7 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
return Dialog( return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: ConstrainedBox( child: ConstrainedBox(
constraints: BoxConstraints( constraints: const BoxConstraints(maxWidth: _modalWidth),
maxWidth: _modalWidth,
maxHeight: MediaQuery.of(context).size.height * 0.85,
),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@ -502,7 +409,16 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
SizedBox(width: 160, child: _statusDropdown()), SizedBox(
width: 160,
child: AdminStatusCapsule(
statut: _statut,
onChanged: (v) => setState(() {
_statut = v;
_dirty = true;
}),
),
),
IconButton( IconButton(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
constraints: const BoxConstraints( constraints: const BoxConstraints(
@ -518,13 +434,13 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
), ),
const Divider(height: 1), const Divider(height: 1),
Padding( Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 16), padding: const EdgeInsets.fromLTRB(20, 12, 20, 12),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
_identityFields(), _identityFields(),
const SizedBox(height: 12), const SizedBox(height: 10),
Text( Text(
'Nombre d\'enfants : ${_children.length}', 'Nombre d\'enfants : ${_children.length}',
style: const TextStyle( style: const TextStyle(
@ -533,9 +449,9 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
color: Colors.black87, color: Colors.black87,
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 6),
_childrenPanel(), _childrenPanel(),
const SizedBox(height: 16), const SizedBox(height: 12),
_buildFooter(), _buildFooter(),
], ],
), ),

View File

@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
/// Gélule de sélection du statut utilisateur (fiches admin parent / AM).
class AdminStatusCapsule extends StatelessWidget {
final String statut;
final ValueChanged<String>? onChanged;
static const statuts = ['actif', 'en_attente', 'suspendu', 'refuse'];
const AdminStatusCapsule({
super.key,
required this.statut,
this.onChanged,
});
static String displayStatus(String status) {
switch (status) {
case 'actif':
return 'Actif';
case 'en_attente':
return 'En attente';
case 'suspendu':
return 'Suspendu';
case 'refuse':
return 'Refusé';
default:
return status;
}
}
@override
Widget build(BuildContext context) {
final value = statuts.contains(statut) ? statut : statuts.first;
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: value,
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: onChanged == null
? null
: (v) {
if (v != null) onChanged!(v);
},
),
),
);
}
}

View File

@ -48,6 +48,7 @@ class ValidationFormGrid extends StatelessWidget {
final List<ValidationLabeledField> fields; final List<ValidationLabeledField> fields;
final List<int>? rowLayout; final List<int>? rowLayout;
final Map<int, List<int>>? rowFlex; final Map<int, List<int>>? rowFlex;
final bool compact;
const ValidationFormGrid({ const ValidationFormGrid({
super.key, super.key,
@ -55,6 +56,7 @@ class ValidationFormGrid extends StatelessWidget {
required this.fields, required this.fields,
this.rowLayout, this.rowLayout,
this.rowFlex, this.rowFlex,
this.compact = false,
}); });
@override @override
@ -72,17 +74,17 @@ class ValidationFormGrid extends StatelessWidget {
rowIndex++; rowIndex++;
if (count == 1) { if (count == 1) {
rows.add(Padding( rows.add(Padding(
padding: const EdgeInsets.only(bottom: 12), padding: EdgeInsets.only(bottom: compact ? 8 : 12),
child: rowFields.first, child: rowFields.first,
)); ));
} else { } else {
rows.add(Padding( rows.add(Padding(
padding: const EdgeInsets.only(bottom: 12), padding: EdgeInsets.only(bottom: compact ? 8 : 12),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
for (int i = 0; i < rowFields.length; i++) ...[ for (int i = 0; i < rowFields.length; i++) ...[
if (i > 0) const SizedBox(width: 16), if (i > 0) SizedBox(width: compact ? 12 : 16),
Expanded( Expanded(
flex: (flexForRow != null && i < flexForRow.length) flex: (flexForRow != null && i < flexForRow.length)
? flexForRow[i] ? flexForRow[i]
@ -103,13 +105,13 @@ class ValidationFormGrid extends StatelessWidget {
if (showTitle) ...[ if (showTitle) ...[
Text( Text(
title!.trim(), title!.trim(),
style: const TextStyle( style: TextStyle(
fontSize: 16, fontSize: compact ? 15 : 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: Colors.black87, color: Colors.black87,
), ),
), ),
const SizedBox(height: 12), SizedBox(height: compact ? 8 : 12),
], ],
...rows, ...rows,
], ],
@ -121,13 +123,16 @@ class ValidationFormGrid extends StatelessWidget {
class ValidationFieldDecoration { class ValidationFieldDecoration {
ValidationFieldDecoration._(); ValidationFieldDecoration._();
static InputDecoration input({String? hint}) { static InputDecoration input({String? hint, bool compact = false}) {
return InputDecoration( return InputDecoration(
isDense: true, isDense: true,
filled: true, filled: true,
fillColor: Colors.grey.shade50, fillColor: Colors.grey.shade50,
hintText: hint, hintText: hint,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), contentPadding: EdgeInsets.symmetric(
horizontal: compact ? 10 : 12,
vertical: compact ? 7 : 10,
),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(6),
borderSide: BorderSide(color: Colors.grey.shade300), borderSide: BorderSide(color: Colors.grey.shade300),
@ -191,6 +196,7 @@ class ValidationEditableField extends StatelessWidget {
final List<TextInputFormatter>? inputFormatters; final List<TextInputFormatter>? inputFormatters;
final String? hintText; final String? hintText;
final int maxLines; final int maxLines;
final bool compact;
const ValidationEditableField({ const ValidationEditableField({
super.key, super.key,
@ -199,17 +205,44 @@ class ValidationEditableField extends StatelessWidget {
this.inputFormatters, this.inputFormatters,
this.hintText, this.hintText,
this.maxLines = 1, this.maxLines = 1,
this.compact = false,
}); });
static const double _compactFieldHeight = 34;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return TextField( if (!compact || maxLines > 1) {
controller: controller, return TextField(
keyboardType: keyboardType, controller: controller,
inputFormatters: inputFormatters, keyboardType: keyboardType,
maxLines: maxLines, inputFormatters: inputFormatters,
style: const TextStyle(color: Colors.black87, fontSize: 14), maxLines: maxLines,
decoration: ValidationFieldDecoration.input(hint: hintText), style: TextStyle(
color: Colors.black87,
fontSize: compact ? 13 : 14,
),
decoration: ValidationFieldDecoration.input(
hint: hintText,
compact: compact,
),
);
}
return SizedBox(
height: _compactFieldHeight,
child: TextField(
controller: controller,
keyboardType: keyboardType,
inputFormatters: inputFormatters,
maxLines: 1,
style: const TextStyle(color: Colors.black87, fontSize: 13, height: 1.15),
decoration: ValidationFieldDecoration.input(
hint: hintText,
compact: true,
).copyWith(
contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9),
),
),
); );
} }
} }
@ -219,12 +252,14 @@ class ValidationEditableSection extends StatelessWidget {
final List<ValidationLabeledField> fields; final List<ValidationLabeledField> fields;
final List<int>? rowLayout; final List<int>? rowLayout;
final Map<int, List<int>>? rowFlex; final Map<int, List<int>>? rowFlex;
final bool compact;
const ValidationEditableSection({ const ValidationEditableSection({
super.key, super.key,
required this.fields, required this.fields,
this.rowLayout, this.rowLayout,
this.rowFlex, this.rowFlex,
this.compact = false,
}); });
@override @override
@ -232,6 +267,7 @@ class ValidationEditableSection extends StatelessWidget {
return ValidationFormGrid( return ValidationFormGrid(
rowLayout: rowLayout, rowLayout: rowLayout,
rowFlex: rowFlex, rowFlex: rowFlex,
compact: compact,
fields: fields, fields: fields,
); );
} }
@ -241,22 +277,35 @@ class ValidationEditableSection extends StatelessWidget {
class ValidationReadOnlyField extends StatelessWidget { class ValidationReadOnlyField extends StatelessWidget {
final String value; final String value;
final int? maxLines; final int? maxLines;
final bool compact;
const ValidationReadOnlyField({ const ValidationReadOnlyField({
super.key, super.key,
required this.value, required this.value,
this.maxLines = 1, this.maxLines = 1,
this.compact = false,
}); });
static const double _compactFieldHeight = 34;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), height: compact && maxLines == 1 ? _compactFieldHeight : null,
alignment: compact ? Alignment.centerLeft : null,
padding: EdgeInsets.symmetric(
horizontal: compact ? 10 : 12,
vertical: compact ? 7 : 10,
),
decoration: ValidationFieldDecoration.container(), decoration: ValidationFieldDecoration.container(),
child: Text( child: Text(
value, value,
style: const TextStyle(color: Colors.black87, fontSize: 14), style: TextStyle(
color: Colors.black87,
fontSize: compact ? 13 : 14,
height: compact ? 1.2 : null,
),
maxLines: maxLines, maxLines: maxLines,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),