Compare commits

...
Author SHA1 Message Date
jmartinandCursor 6fe2b89a61 fix(#151): autoriser GET /relais pour gestionnaire + robustesse combo
- Back: RoleType.GESTIONNAIRE sur GET list/get (CRUD write admin-only)
- Front: ne plus effacer la sélection si le chargement échoue
- Fallback affichage depuis relaisNom de l'utilisateur

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 16:52:46 +02:00
jmartin d6a9b3fd66 Merge branch 'feature/149-clic-case-libre-rattacher-enfant' into develop
Clic case libre → rattacher enfant (#149).
2026-07-17 16:19:14 +02:00
jmartinandCursor 134b9781c8 feat(#149): clic sur case libre pour rattacher un enfant (fiche AM).
Les emplacements vides de la grille ouvrent le même flux que le lien footer, avec hover et respect de la capacité max.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 16:19:12 +02:00
jmartin b936d27445 Merge branch 'feature/148-rattacher-enfant-capacite-max' into develop
Fix capacité max — lien rattacher enfant (#148).
2026-07-17 16:12:54 +02:00
jmartinandCursor 18c1d1eba7 fix(#148): désactiver « Rattacher un enfant » si capacité AM pleine.
Le lien du footer est grisé avec tooltip quand enfants ≥ capacité max ; il se réactive après détachement.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 16:12:53 +02:00
jmartin f74b14c203 Merge branch 'feature/147-modale-selection-am-enfant' into develop
Modale de sélection d'AM (#147) depuis la fiche enfant.
2026-07-17 16:12:23 +02:00
jmartinandCursor f194b5f9e8 feat(#147): modale de sélection d'AM depuis la fiche enfant.
Réutilise AdminSelectListModal avec filtre Libre, cartes saturées en rouge, avertissement + lien fiche AM, et recalcul des places à chaque rattachement.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 16:11:55 +02:00
9 changed files with 387 additions and 58 deletions
@@ -24,15 +24,19 @@ export class RelaisController {
} }
@Get() @Get()
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR) @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
@ApiOperation({ summary: 'Lister tous les relais' }) @ApiOperation({
summary: 'Lister tous les relais',
description:
'Lecture ouverte aux gestionnaires (combobox fiches). CRUD write reste admin-only. Ticket #151.',
})
@ApiResponse({ status: 200, description: 'Liste des relais.' }) @ApiResponse({ status: 200, description: 'Liste des relais.' })
findAll() { findAll() {
return this.relaisService.findAll(); return this.relaisService.findAll();
} }
@Get(':id') @Get(':id')
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR) @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
@ApiOperation({ summary: 'Récupérer un relais par ID' }) @ApiOperation({ summary: 'Récupérer un relais par ID' })
@ApiResponse({ status: 200, description: 'Le relais trouvé.' }) @ApiResponse({ status: 200, description: 'Le relais trouvé.' })
findOne(@Param('id') id: string) { findOne(@Param('id') id: string) {
@@ -146,6 +146,21 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
super.dispose(); super.dispose();
} }
/// Fallback si GET /relais échoue : conserve le relais déjà connu sur l'utilisateur.
List<RelaisModel> _fallbackRelaisFromUser() {
final id = _selectedRelaisId?.trim();
if (id == null || id.isEmpty) return const [];
final nom = (widget.initialUser?.relaisNom ?? '').trim();
return [
RelaisModel(
id: id,
nom: nom.isNotEmpty ? nom : 'Relais actuel',
adresse: '',
actif: true,
),
];
}
Future<void> _loadRelais() async { Future<void> _loadRelais() async {
try { try {
final list = await RelaisService.getRelais(); final list = await RelaisService.getRelais();
@@ -162,7 +177,8 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
if (selected != null) { if (selected != null) {
filtered.add(selected); filtered.add(selected);
} else { } else {
_selectedRelaisId = null; // Garder l'id sélectionné et afficher un item de secours (nom carte).
filtered.addAll(_fallbackRelaisFromUser());
} }
} }
@@ -172,9 +188,9 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
}); });
} catch (_) { } catch (_) {
if (!mounted) return; if (!mounted) return;
// Ne pas nullifier _selectedRelaisId (#151) — la carte a déjà le bon libellé.
setState(() { setState(() {
_selectedRelaisId = null; _relais = _fallbackRelaisFromUser();
_relais = [];
_isLoadingRelais = false; _isLoadingRelais = false;
}); });
} }
+19 -1
View File
@@ -8,6 +8,7 @@ import 'package:p_tits_pas/models/pending_family.dart';
import 'package:p_tits_pas/models/dossier_unifie.dart'; import 'package:p_tits_pas/models/dossier_unifie.dart';
import 'package:p_tits_pas/services/api/api_config.dart'; import 'package:p_tits_pas/services/api/api_config.dart';
import 'package:p_tits_pas/services/api/tokenService.dart'; import 'package:p_tits_pas/services/api/tokenService.dart';
import 'package:p_tits_pas/utils/am_vigilance.dart';
class DocumentActifInfo { class DocumentActifInfo {
final String id; final String id;
@@ -688,7 +689,24 @@ class UserService {
if (response.statusCode != 200 && response.statusCode != 201) { if (response.statusCode != 200 && response.statusCode != 201) {
throw Exception(_extractErrorMessage(response.body, 'Erreur rattachement enfant')); throw Exception(_extractErrorMessage(response.body, 'Erreur rattachement enfant'));
} }
return _amModelFromBody(response.body); final am = _amModelFromBody(response.body);
// Recalcule toujours places_available (capacité enfants) après attachement.
return syncAmPlacesAvailable(am);
}
/// Aligne `places_available` sur capacité enfants rattachés.
static Future<AssistanteMaternelleModel> syncAmPlacesAvailable(
AssistanteMaternelleModel am,
) async {
final expected = amExpectedPlacesAvailable(
maxChildren: am.maxChildren,
childrenCount: am.children.length,
);
if (expected == null || am.placesAvailable == expected) return am;
return updateAmFiche(
amUserId: am.user.id,
body: {'places_available': expected},
);
} }
static Future<AssistanteMaternelleModel> detachEnfantFromAm({ static Future<AssistanteMaternelleModel> detachEnfantFromAm({
+12
View File
@@ -9,6 +9,18 @@ int? amExpectedPlacesAvailable({
return (maxChildren - childrenCount).clamp(0, maxChildren); return (maxChildren - childrenCount).clamp(0, maxChildren);
} }
/// True s'il reste au moins une place d'accueil (capacité enfants).
bool amHasFreePlace(AssistanteMaternelleModel am) {
final expected = amExpectedPlacesAvailable(
maxChildren: am.maxChildren,
childrenCount: am.children.length,
);
if (expected != null) return expected > 0;
final places = am.placesAvailable;
if (places != null) return places > 0;
return true;
}
/// True si la valeur déclarée par l'AM ne correspond pas au calcul métier. /// True si la valeur déclarée par l'AM ne correspond pas au calcul métier.
bool amHasPlacesInconsistency({ bool amHasPlacesInconsistency({
required int? maxChildren, required int? maxChildren,
@@ -20,6 +20,8 @@ class AdminAmChildrenCapacityGrid extends StatelessWidget {
final int capacity; final int capacity;
final void Function(ParentChildSummary child) onOpen; final void Function(ParentChildSummary child) onOpen;
final void Function(ParentChildSummary child) onDetach; final void Function(ParentChildSummary child) onDetach;
/// Clic sur une case libre → même flux que « Rattacher un enfant » (#149).
final VoidCallback? onAttachEmpty;
const AdminAmChildrenCapacityGrid({ const AdminAmChildrenCapacityGrid({
super.key, super.key,
@@ -27,6 +29,7 @@ class AdminAmChildrenCapacityGrid extends StatelessWidget {
required this.capacity, required this.capacity,
required this.onOpen, required this.onOpen,
required this.onDetach, required this.onDetach,
this.onAttachEmpty,
}); });
@override @override
@@ -78,7 +81,7 @@ class AdminAmChildrenCapacityGrid extends StatelessWidget {
); );
} }
if (index < maxSlots) { if (index < maxSlots) {
return const _EmptySlot(); return _EmptySlot(onTap: onAttachEmpty);
} }
return const _UnavailableSlot(); return const _UnavailableSlot();
} }
@@ -129,18 +132,52 @@ class _UnavailableSlot extends StatelessWidget {
} }
} }
class _EmptySlot extends StatelessWidget { class _EmptySlot extends StatefulWidget {
const _EmptySlot(); final VoidCallback? onTap;
const _EmptySlot({this.onTap});
@override
State<_EmptySlot> createState() => _EmptySlotState();
}
class _EmptySlotState extends State<_EmptySlot> {
bool _hovered = false;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return _SlotShell( final clickable = widget.onTap != null;
backgroundColor: Colors.grey.shade50, return MouseRegion(
borderColor: Colors.grey.shade300, onEnter: clickable ? (_) => setState(() => _hovered = true) : null,
onExit: clickable ? (_) => setState(() => _hovered = false) : null,
cursor: clickable ? SystemMouseCursors.click : MouseCursor.defer,
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(8),
child: InkWell(
onTap: widget.onTap,
borderRadius: BorderRadius.circular(8),
hoverColor: const Color(0x149CC5C0),
child: _SlotShell(
backgroundColor: _hovered
? const Color(0xFFF3F0FA)
: Colors.grey.shade50,
borderColor: _hovered
? const Color(0xFFB8A4D4)
: Colors.grey.shade300,
child: Center( child: Center(
child: Text( child: Text(
'Place libre', 'Place libre',
style: TextStyle(fontSize: 12, color: Colors.grey.shade500), style: TextStyle(
fontSize: 12,
color: _hovered
? const Color(0xFF6B3FA0)
: Colors.grey.shade500,
fontWeight: _hovered ? FontWeight.w600 : FontWeight.w400,
),
),
),
),
), ),
), ),
); );
@@ -238,6 +238,13 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
int? _capaciteMax() => _parseIntField(_capaciteCtrl); int? _capaciteMax() => _parseIntField(_capaciteCtrl);
/// True si plus aucune place d'accueil (enfants ≥ capacité max).
bool get _capacityFull {
final max = _capaciteMax();
if (max == null) return false;
return _children.length >= max;
}
int? _computedPlacesAvailable() => amExpectedPlacesAvailable( int? _computedPlacesAvailable() => amExpectedPlacesAvailable(
maxChildren: _capaciteMax(), maxChildren: _capaciteMax(),
childrenCount: _children.length, childrenCount: _children.length,
@@ -466,7 +473,7 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
} }
Future<void> _attachChild() async { Future<void> _attachChild() async {
if (!mounted) return; if (_capacityFull || !mounted) return;
final selected = await AdminSelectEnfantModal.show( final selected = await AdminSelectEnfantModal.show(
context, context,
excludeIds: _children.map((c) => c.id).toSet(), excludeIds: _children.map((c) => c.id).toSet(),
@@ -738,6 +745,7 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
capacity: capacity, capacity: capacity,
onOpen: _openChild, onOpen: _openChild,
onDetach: _detachChild, onDetach: _detachChild,
onAttachEmpty: _capacityFull ? null : _attachChild,
), ),
], ],
); );
@@ -745,6 +753,7 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
Widget _buildFooter() { Widget _buildFooter() {
final isChildrenTab = _tabCtrl.index == 2; final isChildrenTab = _tabCtrl.index == 2;
final canAttachChild = !_saving && !_capacityFull;
return Row( return Row(
children: [ children: [
TextButton( TextButton(
@@ -753,11 +762,16 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
), ),
const Spacer(), const Spacer(),
if (isChildrenTab) if (isChildrenTab)
TextButton.icon( Tooltip(
onPressed: _attachChild, message: _capacityFull
? 'Capacité maximale atteinte'
: 'Rattacher un enfant',
child: TextButton.icon(
onPressed: canAttachChild ? _attachChild : null,
icon: const Icon(Icons.link, size: 18), icon: const Icon(Icons.link, size: 18),
label: const Text('Rattacher un enfant'), label: const Text('Rattacher un enfant'),
), ),
),
if (isChildrenTab) const SizedBox(width: 12), if (isChildrenTab) const SizedBox(width: 12),
ElevatedButton( ElevatedButton(
style: ValidationModalTheme.primaryElevatedStyle, style: ValidationModalTheme.primaryElevatedStyle,
@@ -9,6 +9,7 @@ import 'package:p_tits_pas/utils/enfant_status_utils.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_am_edit_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_am_edit_modal.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_parent_edit_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_parent_edit_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_select_am_modal.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/admin/validation_modal_theme.dart'; import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
@@ -399,49 +400,25 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
} }
Future<void> _attachAm() async { Future<void> _attachAm() async {
List<AssistanteMaternelleModel> all;
try {
all = await UserService.getAssistantesMaternelles();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
);
return;
}
if (_linkedAm != null && !_isSansGarde) { if (_linkedAm != null && !_isSansGarde) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
content: Text('Détachez l\'assistante actuelle avant d\'en choisir une autre'), content: Text(
'Détachez l\'assistante actuelle avant d\'en choisir une autre',
),
), ),
); );
return; return;
} }
if (all.isEmpty) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( final selected = await AdminSelectAmModal.show(
const SnackBar(content: Text('Aucune assistante maternelle disponible')), context,
); excludeIds: {
return; if (_linkedAm != null) _linkedAm!.user.id,
} },
title: 'Choisir une assistante maternelle',
if (!mounted) return;
final selected = await showDialog<AssistanteMaternelleModel>(
context: context,
builder: (ctx) => SimpleDialog(
title: const Text('Choisir une assistante maternelle'),
children: all
.map(
(am) => SimpleDialogOption(
onPressed: () => Navigator.pop(ctx, am),
child: Text(am.user.fullName),
),
)
.toList(),
),
); );
if (selected == null || !mounted) return; if (selected == null || !mounted) return;
@@ -0,0 +1,218 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/utils/am_vigilance.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_am_edit_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_select_list_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
List<String> _amSelectSubtitleLines(AssistanteMaternelleModel am) {
final lines = <String>[];
final zone = (am.residenceCity ?? '').trim();
if (zone.isNotEmpty) lines.add('Zone : $zone');
final agrement = (am.approvalNumber ?? '').trim();
if (agrement.isNotEmpty) lines.add('Agrément : $agrement');
final max = am.maxChildren;
final free = amExpectedPlacesAvailable(
maxChildren: max,
childrenCount: am.children.length,
) ??
am.placesAvailable;
if (free != null || max != null) {
lines.add('Places libres : ${free ?? ''} / capa. ${max ?? ''}');
}
lines.add('${am.children.length} enfant(s)');
return lines;
}
/// Sélection d'une AM à rattacher (fiche enfant) — ticket #147.
/// S'appuie sur [AdminSelectListModal] (shell partagé avec #146).
class AdminSelectAmModal {
AdminSelectAmModal._();
static Future<AssistanteMaternelleModel?> show(
BuildContext context, {
Set<String> excludeIds = const {},
String title = 'Choisir une assistante maternelle',
}) {
return AdminSelectListModal.show<AssistanteMaternelleModel>(
context,
title: title,
searchHint: 'Rechercher par nom, prénom ou zone…',
emptyMessage: 'Aucune assistante maternelle disponible',
noResultsMessage: 'Aucune AM avec place libre pour cette recherche',
toggleFilter: const AdminSelectToggleFilter<AssistanteMaternelleModel>(
label: 'Libre',
initialValue: true,
whenEnabled: amHasFreePlace,
),
loadItems: () async {
final list = await UserService.getAssistantesMaternelles();
return list
.where((am) => !excludeIds.contains(am.user.id))
.toList()
..sort(
(a, b) => a.user.fullName
.toLowerCase()
.compareTo(b.user.fullName.toLowerCase()),
);
},
matchesQuery: (am, q) {
final u = am.user;
final name = u.fullName.toLowerCase();
final fn = (u.prenom ?? '').toLowerCase();
final ln = (u.nom ?? '').toLowerCase();
final zone = (am.residenceCity ?? '').toLowerCase();
final agrement = (am.approvalNumber ?? '').toLowerCase();
return name.contains(q) ||
fn.contains(q) ||
ln.contains(q) ||
zone.contains(q) ||
agrement.contains(q);
},
resolveSelect: (ctx, am, reload) =>
_resolveAmSelection(ctx, am, reload),
itemBuilder: (context, am, onSelect) {
final full = !amHasFreePlace(am);
return AdminUserCard(
title: am.user.fullName,
avatarUrl: am.user.photoUrl,
fallbackIcon: Icons.face,
subtitleLines: _amSelectSubtitleLines(am),
vigilanceTooltip: amPlacesVigilanceMessage(am),
onCardTap: onSelect,
margin: const EdgeInsets.only(bottom: 4),
contentPadding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 5,
),
backgroundColor: full ? const Color(0xFFFFEBEE) : null,
borderColor: full ? Colors.red.shade200 : null,
actions: [
IconButton(
icon: const Icon(Icons.add_link),
tooltip: 'Rattacher',
onPressed: onSelect,
),
],
);
},
);
}
static Future<AssistanteMaternelleModel?> _resolveAmSelection(
BuildContext context,
AssistanteMaternelleModel am,
Future<void> Function() reloadList,
) async {
if (amHasFreePlace(am)) return am;
final selected = await showDialog<AssistanteMaternelleModel>(
context: context,
builder: (ctx) => _AmNoPlaceWarningDialog(initialAm: am),
);
await reloadList();
return selected;
}
}
/// Avertissement AM saturée + lien vers la fiche pour ajuster les places.
class _AmNoPlaceWarningDialog extends StatefulWidget {
final AssistanteMaternelleModel initialAm;
const _AmNoPlaceWarningDialog({required this.initialAm});
@override
State<_AmNoPlaceWarningDialog> createState() =>
_AmNoPlaceWarningDialogState();
}
class _AmNoPlaceWarningDialogState extends State<_AmNoPlaceWarningDialog> {
late AssistanteMaternelleModel _am;
TapGestureRecognizer? _linkRecognizer;
@override
void initState() {
super.initState();
_am = widget.initialAm;
_linkRecognizer = TapGestureRecognizer()..onTap = _openAmFiche;
}
@override
void dispose() {
_linkRecognizer?.dispose();
super.dispose();
}
Future<void> _openAmFiche() async {
if (!mounted) return;
await showDialog<void>(
context: context,
builder: (ctx) => AdminAmEditModal(
assistante: _am,
onSaved: () async {
try {
final fresh =
await UserService.getAssistanteMaternelle(_am.user.id);
if (mounted) setState(() => _am = fresh);
} catch (_) {}
},
),
);
if (!mounted) return;
try {
final fresh = await UserService.getAssistanteMaternelle(_am.user.id);
if (!mounted) return;
setState(() => _am = fresh);
if (amHasFreePlace(fresh)) {
Navigator.of(context).pop(fresh);
}
} catch (_) {}
}
@override
Widget build(BuildContext context) {
final name = _am.user.fullName.trim().isNotEmpty
? _am.user.fullName.trim()
: 'Cette assistante maternelle';
const linkColor = ValidationModalTheme.primaryActionBackground;
return AlertDialog(
title: const Text('Plus de place disponible'),
content: Text.rich(
TextSpan(
style: const TextStyle(fontSize: 14, color: Colors.black87, height: 1.4),
children: [
TextSpan(
text: '$name n\'a plus de place libre pour accueillir '
'un enfant supplémentaire.\n\n',
),
const TextSpan(text: 'Vous pouvez '),
TextSpan(
text: 'ouvrir sa fiche',
style: TextStyle(
color: linkColor,
decoration: TextDecoration.underline,
fontWeight: FontWeight.w600,
),
recognizer: _linkRecognizer,
),
const TextSpan(
text: ' pour modifier la capacité ou les places, '
'puis la sélectionner si une place se libère.',
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Fermer'),
),
],
);
}
}
@@ -18,7 +18,7 @@ class AdminSelectToggleFilter<T> {
} }
/// Shell générique « rechercher + liste + sélection » pour les modales admin. /// 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). /// Utilisé par la sélection d'enfant (#146) et la sélection d'AM (#147).
class AdminSelectListModal<T> extends StatefulWidget { class AdminSelectListModal<T> extends StatefulWidget {
final String title; final String title;
final String searchHint; final String searchHint;
@@ -39,9 +39,17 @@ class AdminSelectListModal<T> extends StatefulWidget {
/// Nombre minimum de cartes visibles dans la zone scrollable. /// Nombre minimum de cartes visibles dans la zone scrollable.
final int minVisibleCards; final int minVisibleCards;
/// Switch optionnel à droite du champ de recherche (ex. « Sans garde »). /// Switch optionnel à droite du champ de recherche (ex. « Sans garde », « Libre »).
final AdminSelectToggleFilter<T>? toggleFilter; final AdminSelectToggleFilter<T>? toggleFilter;
/// Si fourni, appelé avant de valider la sélection.
/// Retourne l'élément à pop (éventuellement rafraîchi), ou `null` pour annuler.
final Future<T?> Function(
BuildContext context,
T item,
Future<void> Function() reload,
)? resolveSelect;
const AdminSelectListModal({ const AdminSelectListModal({
super.key, super.key,
required this.title, required this.title,
@@ -55,6 +63,7 @@ class AdminSelectListModal<T> extends StatefulWidget {
this.cardExtent = 52, this.cardExtent = 52,
this.minVisibleCards = 8, this.minVisibleCards = 8,
this.toggleFilter, this.toggleFilter,
this.resolveSelect,
}); });
static Future<T?> show<T>( static Future<T?> show<T>(
@@ -74,6 +83,11 @@ class AdminSelectListModal<T> extends StatefulWidget {
double cardExtent = 52, double cardExtent = 52,
int minVisibleCards = 8, int minVisibleCards = 8,
AdminSelectToggleFilter<T>? toggleFilter, AdminSelectToggleFilter<T>? toggleFilter,
Future<T?> Function(
BuildContext context,
T item,
Future<void> Function() reload,
)? resolveSelect,
}) { }) {
return showDialog<T>( return showDialog<T>(
context: context, context: context,
@@ -89,6 +103,7 @@ class AdminSelectListModal<T> extends StatefulWidget {
cardExtent: cardExtent, cardExtent: cardExtent,
minVisibleCards: minVisibleCards, minVisibleCards: minVisibleCards,
toggleFilter: toggleFilter, toggleFilter: toggleFilter,
resolveSelect: resolveSelect,
), ),
); );
} }
@@ -104,6 +119,7 @@ class _AdminSelectListModalState<T> extends State<AdminSelectListModal<T>> {
bool _loading = true; bool _loading = true;
String? _error; String? _error;
late bool _toggleOn; late bool _toggleOn;
bool _resolving = false;
double get _listHeight => double get _listHeight =>
widget.cardExtent * widget.minVisibleCards; widget.cardExtent * widget.minVisibleCards;
@@ -143,6 +159,23 @@ class _AdminSelectListModalState<T> extends State<AdminSelectListModal<T>> {
} }
} }
Future<void> _handleSelect(T item) async {
if (_resolving) return;
final resolve = widget.resolveSelect;
if (resolve == null) {
Navigator.of(context).pop(item);
return;
}
setState(() => _resolving = true);
try {
final chosen = await resolve(context, item, _load);
if (!mounted || chosen == null) return;
Navigator.of(context).pop(chosen);
} finally {
if (mounted) setState(() => _resolving = false);
}
}
List<T> get _filtered { List<T> get _filtered {
var list = _all; var list = _all;
final toggle = widget.toggleFilter; final toggle = widget.toggleFilter;
@@ -326,7 +359,7 @@ class _AdminSelectListModalState<T> extends State<AdminSelectListModal<T>> {
return widget.itemBuilder( return widget.itemBuilder(
context, context,
item, item,
() => Navigator.of(context).pop(item), () => _handleSelect(item),
); );
}, },
); );