import 'package:flutter/material.dart'; import 'package:manager_app/constants.dart'; import 'package:manager_app/l10n/app_localizations.dart'; /// Le gabarit B : une liste ordonnée à gauche, l'élément sélectionné édité en /// place à droite. /// /// Huit configurations faisaient rigoureusement la même chose — une liste /// horizontale à hauteur figée, un bouton vert flottant et une modale par /// élément, chacune avec son propre `MediaQuery.size * 0.x`. Cette brique les /// remplace, et avec elles les dialogues `showNewOrUpdate*`. /// /// La liste est mutée en place puis `onChanged` est appelé : c'est au parent de /// renuméroter les `order` et de persister, comme il le faisait déjà. /// Les trois opérations d'une collection qui vit côté serveur. /// /// Quiz, Agenda et Menu écrivent un appel par élément dès la validation, là où /// Slider, Article et PDF gardent tout dans le DTO de la section jusqu'au /// « Enregistrer ». Les callbacks affichent leur propre message d'erreur — le /// codebase le fait déjà partout — et rendent `null`/`false` pour dire à /// l'éditeur de remettre la liste dans son état d'avant. class RemoteCollection { const RemoteCollection({ required this.create, required this.delete, required this.reorder, }); final Future Function() create; final Future Function(T item) delete; /// Reçoit la liste déjà renumérotée. ⚠️ Elle est renumérotée *entière* : les /// écrans distants n'écrivaient que l'`order` de l'élément déplacé, ce qui /// laissait des rangs en double. final Future Function(List items) reorder; } class CollectionEditor extends StatefulWidget { const CollectionEditor({ Key? key, required this.items, required this.itemLabel, required this.detailBuilder, required this.createItem, required this.onChanged, required this.addLabel, this.detailTitle, this.emptyLabel, this.remote, this.setOrder, }) : super(key: key); final List items; final String Function(T item, int index) itemLabel; final Widget Function(T item, int index) detailBuilder; /// Ignoré quand `remote` est fourni : c'est le serveur qui crée l'élément. final T Function() createItem; final ValueChanged> onChanged; final String addLabel; final String Function(int index)? detailTitle; final String? emptyLabel; /// Collection distante : l'éditeur appelle l'API au lieu de muter la liste. final RemoteCollection? remote; /// Écrit le rang d'un élément. Requis avec `remote`, pour que la /// renumérotation porte sur toute la liste. final void Function(T item, int order)? setOrder; @override State> createState() => _CollectionEditorState(); } class _CollectionEditorState extends State> { int _selected = 0; bool _isBusy = false; Future _onReorder(int oldIndex, int newIndex) async { if (newIndex > oldIndex) newIndex -= 1; final previous = List.from(widget.items); final selectedItem = _selected < widget.items.length ? widget.items[_selected] : null; setState(() { final item = widget.items.removeAt(oldIndex); widget.items.insert(newIndex, item); if (selectedItem != null) { _selected = widget.items.indexOf(selectedItem); } }); if (widget.remote == null) { widget.onChanged(widget.items); return; } for (var i = 0; i < widget.items.length; i++) { widget.setOrder!(widget.items[i], i); } setState(() => _isBusy = true); final ok = await widget.remote!.reorder(widget.items); if (!mounted) return; setState(() { _isBusy = false; if (!ok) { widget.items ..clear() ..addAll(previous); for (var i = 0; i < widget.items.length; i++) { widget.setOrder!(widget.items[i], i); } if (selectedItem != null) { _selected = widget.items.indexOf(selectedItem); } } }); if (ok) widget.onChanged(widget.items); } Future _add() async { if (widget.remote == null) { setState(() { widget.items.add(widget.createItem()); _selected = widget.items.length - 1; widget.onChanged(widget.items); }); return; } setState(() => _isBusy = true); final created = await widget.remote!.create(); if (!mounted) return; setState(() { _isBusy = false; if (created != null) { widget.setOrder!(created, widget.items.length); widget.items.add(created); _selected = widget.items.length - 1; } }); if (created != null) widget.onChanged(widget.items); } Future _delete(int index) async { final item = widget.items[index]; if (widget.remote != null) { setState(() => _isBusy = true); final ok = await widget.remote!.delete(item); if (!mounted) return; setState(() => _isBusy = false); if (!ok) return; } setState(() { widget.items.remove(item); if (widget.remote != null) { for (var i = 0; i < widget.items.length; i++) { widget.setOrder!(widget.items[i], i); } } if (_selected >= widget.items.length) { _selected = widget.items.isEmpty ? 0 : widget.items.length - 1; } widget.onChanged(widget.items); }); } @override Widget build(BuildContext context) { return Stack( children: [ Container( decoration: BoxDecoration( border: Border.all(color: kLine), borderRadius: BorderRadius.circular(kRadiusCard), ), clipBehavior: Clip.antiAlias, child: LayoutBuilder( builder: (context, constraints) { // Collection vide : un seul bloc centré. Un rail de 208 px ne // contenant que le bouton « Ajouter », à côté d'un panneau // « Aucune donnée » vide, posait ce bouton dans un coin sans // raison. if (widget.items.isEmpty) return _buildEmpty(); if (constraints.maxWidth < 780) { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _buildList(bordered: false), _buildDetail(), ], ); } // ⚠️ Pas d'`IntrinsicHeight` : le panneau de détail peut contenir // une carte, et `FlutterMap` ne sait pas répondre à une mesure // intrinsèque — la ligne plantait au layout. return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox(width: 208, child: _buildList(bordered: true)), Expanded(child: _buildDetail()), ], ); }, ), ), // Un appel est en cours : on empêche un second geste avant sa réponse. if (_isBusy) Positioned.fill( child: ColoredBox( color: kSurface.withValues(alpha: 0.55), child: const Center( child: SizedBox( width: 22, height: 22, child: CircularProgressIndicator(strokeWidth: 2), ), ), ), ), ], ); } Widget _buildEmpty() { final l = AppLocalizations.of(context)!; return Container( color: kSurface, padding: const EdgeInsets.symmetric(vertical: kSpace8, horizontal: kSpace6), alignment: Alignment.center, child: Column( mainAxisSize: MainAxisSize.min, children: [ Text(widget.emptyLabel ?? l.noData, style: kTextHint), const SizedBox(height: kSpace5), SizedBox(width: 220, child: _buildAddRow()), ], ), ); } Widget _buildList({required bool bordered}) { return Container( padding: const EdgeInsets.all(kSpace3), decoration: BoxDecoration( color: kGround, border: bordered ? const Border(right: BorderSide(color: kLine)) : const Border(bottom: BorderSide(color: kLine)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ ReorderableListView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), buildDefaultDragHandles: false, itemCount: widget.items.length, onReorder: _onReorder, itemBuilder: (context, index) => _buildRow(index), ), _buildAddRow(), ], ), ); } Widget _buildRow(int index) { final isSelected = index == _selected; return Padding( key: ValueKey(identityHashCode(widget.items[index])), padding: const EdgeInsets.only(bottom: kSpace2), child: Material( color: Colors.transparent, child: InkWell( borderRadius: BorderRadius.circular(kRadiusInput), onTap: () => setState(() => _selected = index), child: Container( padding: const EdgeInsets.symmetric( horizontal: kSpace3, vertical: kSpace2), decoration: BoxDecoration( color: isSelected ? kSurface : Colors.transparent, border: Border.all( color: isSelected ? kPrimaryColor : Colors.transparent), borderRadius: BorderRadius.circular(kRadiusInput), ), child: Row( children: [ // ⚠️ Pas de `MouseRegion` ici : ajouter un curseur dans un // élément de `ReorderableListView` en `shrinkWrap` déclenche // l'assertion `!_debugDuringDeviceUpdate` du suivi de souris à // l'ouverture de l'écran. ReorderableDragStartListener( index: index, child: const Icon(Icons.drag_indicator, size: 15, color: kLine), ), const SizedBox(width: kSpace2), Expanded( child: Text( widget.itemLabel(widget.items[index], index), maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 12.5, fontWeight: isSelected ? FontWeight.w500 : FontWeight.w400, color: kInk, ), ), ), const SizedBox(width: kSpace2), Text("${index + 1}", style: kTextHint), ], ), ), ), ), ); } Widget _buildAddRow() { return Material( color: Colors.transparent, child: InkWell( borderRadius: BorderRadius.circular(kRadiusInput), onTap: _add, child: Container( padding: const EdgeInsets.symmetric(vertical: kSpace3), decoration: BoxDecoration( border: Border.all(color: kBrandSoft), borderRadius: BorderRadius.circular(kRadiusInput), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ const Icon(Icons.add, size: 15, color: kPrimaryColor), const SizedBox(width: kSpace2), Flexible( child: Text(widget.addLabel, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 12.5, fontWeight: FontWeight.w500, color: kPrimaryColor)), ), ], ), ), ), ); } Widget _buildDetail() { final l = AppLocalizations.of(context)!; if (widget.items.isEmpty || _selected >= widget.items.length) { return Container( color: kSurface, padding: const EdgeInsets.all(kSpace8), alignment: Alignment.center, child: Text(widget.emptyLabel ?? l.noData, style: kTextHint), ); } return Container( color: kSurface, padding: const EdgeInsets.fromLTRB(kSpace5, kSpace4, kSpace5, kSpace5), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( children: [ Expanded( child: Text( widget.detailTitle?.call(_selected) ?? widget.itemLabel(widget.items[_selected], _selected), style: kOverline, ), ), IconButton( icon: const Icon(Icons.delete_outline, size: 18, color: kError), tooltip: l.delete, onPressed: () => _delete(_selected), ), ], ), const SizedBox(height: kSpace4), widget.detailBuilder(widget.items[_selected], _selected), ], ), ); } }