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 SelectToggleFilter { 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 SelectToggleFilter({ 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 la sélection d'AM (#147). class SelectListModal extends StatefulWidget { final String title; final String searchHint; final Future> 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 », « Libre »). final SelectToggleFilter? toggleFilter; /// Si fourni, appelé avant de valider la sélection. /// Retourne l'élément à pop (éventuellement rafraîchi), ou `null` pour annuler. final Future Function( BuildContext context, T item, Future Function() reload, )? resolveSelect; const SelectListModal({ 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, this.resolveSelect, }); static Future show( BuildContext context, { required String title, required Future> 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, SelectToggleFilter? toggleFilter, Future Function( BuildContext context, T item, Future Function() reload, )? resolveSelect, }) { return showDialog( context: context, builder: (ctx) => SelectListModal( title: title, loadItems: loadItems, matchesQuery: matchesQuery, itemBuilder: itemBuilder, searchHint: searchHint, emptyMessage: emptyMessage, noResultsMessage: noResultsMessage, modalWidth: modalWidth, cardExtent: cardExtent, minVisibleCards: minVisibleCards, toggleFilter: toggleFilter, resolveSelect: resolveSelect, ), ); } @override State> createState() => _SelectListModalState(); } class _SelectListModalState extends State> { final _searchCtrl = TextEditingController(); List _all = []; bool _loading = true; String? _error; late bool _toggleOn; bool _resolving = false; 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 _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: ', ''); }); } } Future _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 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, () => _handleSelect(item), ); }, ); } }