Merge branch 'feature/146-modale-selection-enfant-am' into develop
Modale de sélection d'enfant (#146) pour rattachement AM/parent.
This commit is contained in:
commit
5ab8ae3423
@ -1,7 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.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/models/enfant_admin_model.dart';
|
|
||||||
import 'package:p_tits_pas/models/parent_child_summary.dart';
|
import 'package:p_tits_pas/models/parent_child_summary.dart';
|
||||||
import 'package:p_tits_pas/utils/am_vigilance.dart';
|
import 'package:p_tits_pas/utils/am_vigilance.dart';
|
||||||
import 'package:p_tits_pas/utils/date_display_utils.dart';
|
import 'package:p_tits_pas/utils/date_display_utils.dart';
|
||||||
@ -11,6 +10,7 @@ import 'package:p_tits_pas/services/user_service.dart';
|
|||||||
import 'package:p_tits_pas/widgets/admin/common/admin_am_children_capacity_grid.dart';
|
import 'package:p_tits_pas/widgets/admin/common/admin_am_children_capacity_grid.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.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_child_detail_modal.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/admin_select_enfant_modal.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_status_capsule.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/admin/common/validation_detail_section.dart';
|
||||||
import 'package:p_tits_pas/widgets/common/identity_block.dart';
|
import 'package:p_tits_pas/widgets/common/identity_block.dart';
|
||||||
@ -55,6 +55,10 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
|||||||
late List<ParentChildSummary> _children;
|
late List<ParentChildSummary> _children;
|
||||||
late Set<String> _baselineChildIds;
|
late Set<String> _baselineChildIds;
|
||||||
|
|
||||||
|
/// Enfants ajoutés localement qui étaient déjà chez une autre AM
|
||||||
|
/// (enfantId → amUserId d'origine). Au save : détacher puis rattacher.
|
||||||
|
final Map<String, String> _transferFromAmIds = {};
|
||||||
|
|
||||||
bool _saving = false;
|
bool _saving = false;
|
||||||
bool _dirty = false;
|
bool _dirty = false;
|
||||||
|
|
||||||
@ -311,6 +315,17 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
for (final id in currentIds.difference(_baselineChildIds)) {
|
for (final id in currentIds.difference(_baselineChildIds)) {
|
||||||
|
// Transfert : détacher l'ancienne AM sans recalculer ses places
|
||||||
|
// (le ! de vigilance apparaît côté liste AM).
|
||||||
|
final previousAmId = _transferFromAmIds[id] ??
|
||||||
|
(await UserService.findAmForEnfant(id))?.user.id;
|
||||||
|
if (previousAmId != null &&
|
||||||
|
previousAmId != widget.assistante.user.id) {
|
||||||
|
await UserService.detachEnfantFromAm(
|
||||||
|
amUserId: previousAmId,
|
||||||
|
enfantId: id,
|
||||||
|
);
|
||||||
|
}
|
||||||
await UserService.attachEnfantToAm(
|
await UserService.attachEnfantToAm(
|
||||||
amUserId: widget.assistante.user.id,
|
amUserId: widget.assistante.user.id,
|
||||||
enfantId: id,
|
enfantId: id,
|
||||||
@ -346,6 +361,7 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
|||||||
_dirty = false;
|
_dirty = false;
|
||||||
_saving = false;
|
_saving = false;
|
||||||
_baselineChildIds = currentIds;
|
_baselineChildIds = currentIds;
|
||||||
|
_transferFromAmIds.clear();
|
||||||
});
|
});
|
||||||
widget.onSaved?.call();
|
widget.onSaved?.call();
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@ -377,6 +393,7 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
|||||||
setState(() {
|
setState(() {
|
||||||
_children = kids;
|
_children = kids;
|
||||||
_baselineChildIds = kids.map((c) => c.id).toSet();
|
_baselineChildIds = kids.map((c) => c.id).toSet();
|
||||||
|
_transferFromAmIds.clear();
|
||||||
});
|
});
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
@ -442,55 +459,75 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
|||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_children = _children.where((c) => c.id != child.id).toList();
|
_children = _children.where((c) => c.id != child.id).toList();
|
||||||
|
_transferFromAmIds.remove(child.id);
|
||||||
_syncPlacesAfterChildrenChange();
|
_syncPlacesAfterChildrenChange();
|
||||||
_dirty = true;
|
_dirty = true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _attachChild() async {
|
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;
|
if (!mounted) return;
|
||||||
final selected = await showDialog<EnfantAdminModel>(
|
final selected = await AdminSelectEnfantModal.show(
|
||||||
context: context,
|
context,
|
||||||
builder: (ctx) => SimpleDialog(
|
excludeIds: _children.map((c) => c.id).toSet(),
|
||||||
title: const Text('Rattacher un enfant'),
|
title: 'Rattacher un enfant',
|
||||||
children: candidates
|
showSansGardeFilter: true,
|
||||||
.map(
|
|
||||||
(e) => SimpleDialogOption(
|
|
||||||
onPressed: () => Navigator.pop(ctx, e),
|
|
||||||
child: Text(e.fullName),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toList(),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
if (selected == null || !mounted) return;
|
if (selected == null || !mounted) return;
|
||||||
|
|
||||||
|
AssistanteMaternelleModel? previousAm;
|
||||||
|
try {
|
||||||
|
previousAm = await UserService.findAmForEnfant(selected.id);
|
||||||
|
} catch (_) {
|
||||||
|
previousAm = null;
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
final previousAmId = previousAm?.user.id;
|
||||||
|
final isTransfer = previousAmId != null &&
|
||||||
|
previousAmId != widget.assistante.user.id;
|
||||||
|
|
||||||
|
if (isTransfer) {
|
||||||
|
final amName = previousAm!.user.fullName.trim().isNotEmpty
|
||||||
|
? previousAm.user.fullName.trim()
|
||||||
|
: 'une autre assistante maternelle';
|
||||||
|
final gardeLabel =
|
||||||
|
(selected.gender ?? '').trim().toUpperCase() == 'F'
|
||||||
|
? 'déjà gardée'
|
||||||
|
: 'déjà gardé';
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Changer d\'affectation'),
|
||||||
|
content: Text(
|
||||||
|
'${selected.fullName} est $gardeLabel par $amName.\n\n'
|
||||||
|
'Confirmer le transfert vers cette assistante ? '
|
||||||
|
'L\'enfant sera détaché de $amName.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
|
child: const Text('Annuler'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
|
style: ValidationModalTheme.primaryElevatedStyle,
|
||||||
|
child: const Text('Confirmer'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed != true || !mounted) return;
|
||||||
|
}
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_children = [
|
_children = [
|
||||||
..._children,
|
..._children,
|
||||||
ParentChildSummary.fromEnfant(selected),
|
ParentChildSummary.fromEnfant(selected),
|
||||||
];
|
];
|
||||||
|
if (isTransfer) {
|
||||||
|
_transferFromAmIds[selected.id] = previousAmId!;
|
||||||
|
}
|
||||||
_syncPlacesAfterChildrenChange();
|
_syncPlacesAfterChildrenChange();
|
||||||
_dirty = true;
|
_dirty = true;
|
||||||
});
|
});
|
||||||
|
|||||||
@ -34,6 +34,8 @@ class AdminEnfantUserCard extends StatelessWidget {
|
|||||||
final List<String> subtitleLines;
|
final List<String> subtitleLines;
|
||||||
final List<Widget> actions;
|
final List<Widget> actions;
|
||||||
final VoidCallback? onCardTap;
|
final VoidCallback? onCardTap;
|
||||||
|
final EdgeInsetsGeometry? margin;
|
||||||
|
final EdgeInsetsGeometry? contentPadding;
|
||||||
|
|
||||||
const AdminEnfantUserCard({
|
const AdminEnfantUserCard({
|
||||||
super.key,
|
super.key,
|
||||||
@ -42,6 +44,8 @@ class AdminEnfantUserCard extends StatelessWidget {
|
|||||||
required this.subtitleLines,
|
required this.subtitleLines,
|
||||||
this.actions = const [],
|
this.actions = const [],
|
||||||
this.onCardTap,
|
this.onCardTap,
|
||||||
|
this.margin,
|
||||||
|
this.contentPadding,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory AdminEnfantUserCard.fromEnfant(
|
factory AdminEnfantUserCard.fromEnfant(
|
||||||
@ -49,6 +53,8 @@ class AdminEnfantUserCard extends StatelessWidget {
|
|||||||
List<String> extraSubtitleLines = const [],
|
List<String> extraSubtitleLines = const [],
|
||||||
List<Widget> actions = const [],
|
List<Widget> actions = const [],
|
||||||
VoidCallback? onCardTap,
|
VoidCallback? onCardTap,
|
||||||
|
EdgeInsetsGeometry? margin,
|
||||||
|
EdgeInsetsGeometry? contentPadding,
|
||||||
}) {
|
}) {
|
||||||
final parents = enfant.parentLinks
|
final parents = enfant.parentLinks
|
||||||
.map((l) => l.parentName ?? 'Parent')
|
.map((l) => l.parentName ?? 'Parent')
|
||||||
@ -68,6 +74,8 @@ class AdminEnfantUserCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
actions: actions,
|
actions: actions,
|
||||||
onCardTap: onCardTap,
|
onCardTap: onCardTap,
|
||||||
|
margin: margin,
|
||||||
|
contentPadding: contentPadding,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -76,6 +84,8 @@ class AdminEnfantUserCard extends StatelessWidget {
|
|||||||
List<String> extraSubtitleLines = const [],
|
List<String> extraSubtitleLines = const [],
|
||||||
List<Widget> actions = const [],
|
List<Widget> actions = const [],
|
||||||
VoidCallback? onCardTap,
|
VoidCallback? onCardTap,
|
||||||
|
EdgeInsetsGeometry? margin,
|
||||||
|
EdgeInsetsGeometry? contentPadding,
|
||||||
}) {
|
}) {
|
||||||
return AdminEnfantUserCard(
|
return AdminEnfantUserCard(
|
||||||
title: child.fullName,
|
title: child.fullName,
|
||||||
@ -88,6 +98,8 @@ class AdminEnfantUserCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
actions: actions,
|
actions: actions,
|
||||||
onCardTap: onCardTap,
|
onCardTap: onCardTap,
|
||||||
|
margin: margin,
|
||||||
|
contentPadding: contentPadding,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -100,6 +112,8 @@ class AdminEnfantUserCard extends StatelessWidget {
|
|||||||
subtitleLines: subtitleLines,
|
subtitleLines: subtitleLines,
|
||||||
actions: actions,
|
actions: actions,
|
||||||
onCardTap: onCardTap,
|
onCardTap: onCardTap,
|
||||||
|
margin: margin,
|
||||||
|
contentPadding: contentPadding,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import 'package:flutter/material.dart';
|
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/utils/phone_utils.dart';
|
||||||
import 'package:p_tits_pas/models/parent_child_summary.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/models/parent_model.dart';
|
||||||
@ -7,6 +6,7 @@ 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_children_affiliation_panel.dart';
|
import 'package:p_tits_pas/widgets/admin/common/admin_children_affiliation_panel.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/admin_select_enfant_modal.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_status_capsule.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';
|
||||||
@ -321,41 +321,11 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _attachChild() async {
|
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;
|
if (!mounted) return;
|
||||||
final selected = await showDialog<EnfantAdminModel>(
|
final selected = await AdminSelectEnfantModal.show(
|
||||||
context: context,
|
context,
|
||||||
builder: (ctx) => SimpleDialog(
|
excludeIds: _children.map((c) => c.id).toSet(),
|
||||||
title: const Text('Rattacher un enfant'),
|
title: 'Rattacher un enfant',
|
||||||
children: candidates
|
|
||||||
.map(
|
|
||||||
(e) => SimpleDialogOption(
|
|
||||||
onPressed: () => Navigator.pop(ctx, e),
|
|
||||||
child: Text(e.fullName),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toList(),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
if (selected == null || !mounted) return;
|
if (selected == null || !mounted) return;
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,72 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:p_tits_pas/models/enfant_admin_model.dart';
|
||||||
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
|
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/admin_enfant_user_card.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/admin_select_list_modal.dart';
|
||||||
|
|
||||||
|
/// Sélection d'un enfant à rattacher (fiche AM / fiche parent) — ticket #146.
|
||||||
|
/// S'appuie sur [AdminSelectListModal] (shell partagé avec #147).
|
||||||
|
class AdminSelectEnfantModal {
|
||||||
|
AdminSelectEnfantModal._();
|
||||||
|
|
||||||
|
static Future<EnfantAdminModel?> show(
|
||||||
|
BuildContext context, {
|
||||||
|
Set<String> excludeIds = const {},
|
||||||
|
String title = 'Rattacher un enfant',
|
||||||
|
/// Affiché uniquement depuis la fiche AM : filtre les enfants déjà en garde.
|
||||||
|
bool showSansGardeFilter = false,
|
||||||
|
}) {
|
||||||
|
return AdminSelectListModal.show<EnfantAdminModel>(
|
||||||
|
context,
|
||||||
|
title: title,
|
||||||
|
searchHint: 'Rechercher par nom ou prénom…',
|
||||||
|
emptyMessage: 'Aucun enfant disponible à rattacher',
|
||||||
|
noResultsMessage: showSansGardeFilter
|
||||||
|
? 'Aucun enfant sans garde pour cette recherche'
|
||||||
|
: 'Aucun résultat pour cette recherche',
|
||||||
|
toggleFilter: showSansGardeFilter
|
||||||
|
? AdminSelectToggleFilter<EnfantAdminModel>(
|
||||||
|
label: 'Sans garde',
|
||||||
|
initialValue: true,
|
||||||
|
whenEnabled: (e) =>
|
||||||
|
normalizeEnfantStatus(e.status) == 'sans_garde',
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
loadItems: () async {
|
||||||
|
final list = await UserService.getEnfants();
|
||||||
|
return list
|
||||||
|
.where((e) => !excludeIds.contains(e.id))
|
||||||
|
.toList()
|
||||||
|
..sort(
|
||||||
|
(a, b) =>
|
||||||
|
a.fullName.toLowerCase().compareTo(b.fullName.toLowerCase()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
matchesQuery: (e, q) {
|
||||||
|
final name = e.fullName.toLowerCase();
|
||||||
|
final fn = (e.firstName ?? '').toLowerCase();
|
||||||
|
final ln = (e.lastName ?? '').toLowerCase();
|
||||||
|
return name.contains(q) || fn.contains(q) || ln.contains(q);
|
||||||
|
},
|
||||||
|
itemBuilder: (context, e, onSelect) {
|
||||||
|
return AdminEnfantUserCard.fromEnfant(
|
||||||
|
e,
|
||||||
|
onCardTap: onSelect,
|
||||||
|
margin: const EdgeInsets.only(bottom: 4),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10,
|
||||||
|
vertical: 5,
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.add_link),
|
||||||
|
tooltip: 'Rattacher',
|
||||||
|
onPressed: onSelect,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
334
frontend/lib/widgets/admin/common/admin_select_list_modal.dart
Normal file
334
frontend/lib/widgets/admin/common/admin_select_list_modal.dart
Normal file
@ -0,0 +1,334 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
|
||||||
|
|
||||||
|
/// Filtre optionnel (switch) sur la même ligne que la barre de recherche.
|
||||||
|
class AdminSelectToggleFilter<T> {
|
||||||
|
final String label;
|
||||||
|
final bool initialValue;
|
||||||
|
|
||||||
|
/// Si le switch est activé, ne garde que les éléments pour lesquels
|
||||||
|
/// [whenEnabled] renvoie `true`.
|
||||||
|
final bool Function(T item) whenEnabled;
|
||||||
|
|
||||||
|
const AdminSelectToggleFilter({
|
||||||
|
required this.label,
|
||||||
|
required this.whenEnabled,
|
||||||
|
this.initialValue = true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shell générique « rechercher + liste + sélection » pour les modales admin.
|
||||||
|
/// Utilisé par la sélection d'enfant (#146) et prévu pour la sélection d'AM (#147).
|
||||||
|
class AdminSelectListModal<T> extends StatefulWidget {
|
||||||
|
final String title;
|
||||||
|
final String searchHint;
|
||||||
|
final Future<List<T>> Function() loadItems;
|
||||||
|
final bool Function(T item, String query) matchesQuery;
|
||||||
|
final Widget Function(
|
||||||
|
BuildContext context,
|
||||||
|
T item,
|
||||||
|
VoidCallback onSelect,
|
||||||
|
) itemBuilder;
|
||||||
|
final String emptyMessage;
|
||||||
|
final String noResultsMessage;
|
||||||
|
final double modalWidth;
|
||||||
|
|
||||||
|
/// Hauteur d'une carte (pour dimensionner la liste à ≥ [minVisibleCards]).
|
||||||
|
final double cardExtent;
|
||||||
|
|
||||||
|
/// Nombre minimum de cartes visibles dans la zone scrollable.
|
||||||
|
final int minVisibleCards;
|
||||||
|
|
||||||
|
/// Switch optionnel à droite du champ de recherche (ex. « Sans garde »).
|
||||||
|
final AdminSelectToggleFilter<T>? toggleFilter;
|
||||||
|
|
||||||
|
const AdminSelectListModal({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.loadItems,
|
||||||
|
required this.matchesQuery,
|
||||||
|
required this.itemBuilder,
|
||||||
|
this.searchHint = 'Rechercher…',
|
||||||
|
this.emptyMessage = 'Aucun élément disponible',
|
||||||
|
this.noResultsMessage = 'Aucun résultat pour cette recherche',
|
||||||
|
this.modalWidth = 930,
|
||||||
|
this.cardExtent = 52,
|
||||||
|
this.minVisibleCards = 8,
|
||||||
|
this.toggleFilter,
|
||||||
|
});
|
||||||
|
|
||||||
|
static Future<T?> show<T>(
|
||||||
|
BuildContext context, {
|
||||||
|
required String title,
|
||||||
|
required Future<List<T>> Function() loadItems,
|
||||||
|
required bool Function(T item, String query) matchesQuery,
|
||||||
|
required Widget Function(
|
||||||
|
BuildContext context,
|
||||||
|
T item,
|
||||||
|
VoidCallback onSelect,
|
||||||
|
) itemBuilder,
|
||||||
|
String searchHint = 'Rechercher…',
|
||||||
|
String emptyMessage = 'Aucun élément disponible',
|
||||||
|
String noResultsMessage = 'Aucun résultat pour cette recherche',
|
||||||
|
double modalWidth = 930,
|
||||||
|
double cardExtent = 52,
|
||||||
|
int minVisibleCards = 8,
|
||||||
|
AdminSelectToggleFilter<T>? toggleFilter,
|
||||||
|
}) {
|
||||||
|
return showDialog<T>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AdminSelectListModal<T>(
|
||||||
|
title: title,
|
||||||
|
loadItems: loadItems,
|
||||||
|
matchesQuery: matchesQuery,
|
||||||
|
itemBuilder: itemBuilder,
|
||||||
|
searchHint: searchHint,
|
||||||
|
emptyMessage: emptyMessage,
|
||||||
|
noResultsMessage: noResultsMessage,
|
||||||
|
modalWidth: modalWidth,
|
||||||
|
cardExtent: cardExtent,
|
||||||
|
minVisibleCards: minVisibleCards,
|
||||||
|
toggleFilter: toggleFilter,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AdminSelectListModal<T>> createState() =>
|
||||||
|
_AdminSelectListModalState<T>();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AdminSelectListModalState<T> extends State<AdminSelectListModal<T>> {
|
||||||
|
final _searchCtrl = TextEditingController();
|
||||||
|
List<T> _all = [];
|
||||||
|
bool _loading = true;
|
||||||
|
String? _error;
|
||||||
|
late bool _toggleOn;
|
||||||
|
|
||||||
|
double get _listHeight =>
|
||||||
|
widget.cardExtent * widget.minVisibleCards;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_toggleOn = widget.toggleFilter?.initialValue ?? false;
|
||||||
|
_searchCtrl.addListener(() => setState(() {}));
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_searchCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
setState(() {
|
||||||
|
_loading = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final list = await widget.loadItems();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_all = list;
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_loading = false;
|
||||||
|
_error = e.toString().replaceFirst('Exception: ', '');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<T> get _filtered {
|
||||||
|
var list = _all;
|
||||||
|
final toggle = widget.toggleFilter;
|
||||||
|
if (toggle != null && _toggleOn) {
|
||||||
|
list = list.where(toggle.whenEnabled).toList();
|
||||||
|
}
|
||||||
|
final q = _searchCtrl.text.trim().toLowerCase();
|
||||||
|
if (q.isEmpty) return list;
|
||||||
|
return list.where((item) => widget.matchesQuery(item, q)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final toggle = widget.toggleFilter;
|
||||||
|
|
||||||
|
return Dialog(
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(maxWidth: widget.modalWidth),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(18, 16, 4, 0),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
widget.title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
tooltip: 'Fermer',
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _searchCtrl,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
isDense: true,
|
||||||
|
hintText: widget.searchHint,
|
||||||
|
prefixIcon: const Icon(Icons.search, size: 20),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (toggle != null) ...[
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(
|
||||||
|
toggle.label,
|
||||||
|
style: const TextStyle(fontSize: 13),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Switch(
|
||||||
|
value: _toggleOn,
|
||||||
|
activeColor:
|
||||||
|
ValidationModalTheme.primaryActionBackground,
|
||||||
|
onChanged: (v) => setState(() => _toggleOn = v),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SizedBox(
|
||||||
|
height: _listHeight,
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: _buildBody(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Spacer(),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Annuler'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBody() {
|
||||||
|
if (_loading) {
|
||||||
|
return const Center(
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
color: ValidationModalTheme.primaryActionBackground,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_error != null) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.error_outline, size: 40, color: Colors.red.shade400),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
_error!,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(color: Colors.red.shade700),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: _load,
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
label: const Text('Réessayer'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final items = _filtered;
|
||||||
|
if (_all.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Text(
|
||||||
|
widget.emptyMessage,
|
||||||
|
style: const TextStyle(fontSize: 14, color: Colors.black54),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Text(
|
||||||
|
widget.noResultsMessage,
|
||||||
|
style: const TextStyle(fontSize: 14, color: Colors.black54),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView.builder(
|
||||||
|
padding: const EdgeInsets.fromLTRB(8, 8, 8, 4),
|
||||||
|
itemExtent: widget.cardExtent,
|
||||||
|
itemCount: items.length,
|
||||||
|
itemBuilder: (context, i) {
|
||||||
|
final item = items[i];
|
||||||
|
return widget.itemBuilder(
|
||||||
|
context,
|
||||||
|
item,
|
||||||
|
() => Navigator.of(context).pop(item),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -15,6 +15,7 @@ class AdminUserCard extends StatefulWidget {
|
|||||||
final String? vigilanceTooltip;
|
final String? vigilanceTooltip;
|
||||||
final VoidCallback? onCardTap;
|
final VoidCallback? onCardTap;
|
||||||
final EdgeInsetsGeometry? margin;
|
final EdgeInsetsGeometry? margin;
|
||||||
|
final EdgeInsetsGeometry? contentPadding;
|
||||||
|
|
||||||
const AdminUserCard({
|
const AdminUserCard({
|
||||||
super.key,
|
super.key,
|
||||||
@ -30,6 +31,7 @@ class AdminUserCard extends StatefulWidget {
|
|||||||
this.vigilanceTooltip,
|
this.vigilanceTooltip,
|
||||||
this.onCardTap,
|
this.onCardTap,
|
||||||
this.margin,
|
this.margin,
|
||||||
|
this.contentPadding,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -69,7 +71,8 @@ class _AdminUserCardState extends State<AdminUserCard> {
|
|||||||
side: BorderSide(color: widget.borderColor ?? Colors.grey.shade300),
|
side: BorderSide(color: widget.borderColor ?? Colors.grey.shade300),
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
|
padding: widget.contentPadding ??
|
||||||
|
const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
_buildAvatar(avatarUrl),
|
_buildAvatar(avatarUrl),
|
||||||
@ -88,7 +91,10 @@ class _AdminUserCardState extends State<AdminUserCard> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
|
// flex: 0 → largeur du nom ; le reste va aux infos
|
||||||
|
// (évite le partage 50/50 qui tronque « Responsables »).
|
||||||
Flexible(
|
Flexible(
|
||||||
|
flex: 0,
|
||||||
fit: FlexFit.loose,
|
fit: FlexFit.loose,
|
||||||
child: Text(
|
child: Text(
|
||||||
widget.title.isNotEmpty ? widget.title : 'Sans nom',
|
widget.title.isNotEmpty ? widget.title : 'Sans nom',
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user