Déplace les panels/wizards/validation/common partagés hors de widgets/admin/ ; ne conserve que AdminManagementWidget (variante A). Co-authored-by: Cursor <cursoragent@cursor.com>
368 lines
11 KiB
Dart
368 lines
11 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:p_tits_pas/widgets/dashboard/validation_modal_theme.dart';
|
|
|
|
/// Filtre optionnel (switch) sur la même ligne que la barre de recherche.
|
|
class SelectToggleFilter<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 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<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 », « Libre »).
|
|
final SelectToggleFilter<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 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<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,
|
|
SelectToggleFilter<T>? toggleFilter,
|
|
Future<T?> Function(
|
|
BuildContext context,
|
|
T item,
|
|
Future<void> Function() reload,
|
|
)? resolveSelect,
|
|
}) {
|
|
return showDialog<T>(
|
|
context: context,
|
|
builder: (ctx) => SelectListModal<T>(
|
|
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<SelectListModal<T>> createState() =>
|
|
_SelectListModalState<T>();
|
|
}
|
|
|
|
class _SelectListModalState<T> extends State<SelectListModal<T>> {
|
|
final _searchCtrl = TextEditingController();
|
|
List<T> _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<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: ', '');
|
|
});
|
|
}
|
|
}
|
|
|
|
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 {
|
|
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),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|