manager-app/lib/Components/collection_editor.dart

402 lines
13 KiB
Dart

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<T> {
const RemoteCollection({
required this.create,
required this.delete,
required this.reorder,
});
final Future<T?> Function() create;
final Future<bool> 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<bool> Function(List<T> items) reorder;
}
class CollectionEditor<T> 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<T> 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<List<T>> 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<T>? 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<CollectionEditor<T>> createState() => _CollectionEditorState<T>();
}
class _CollectionEditorState<T> extends State<CollectionEditor<T>> {
int _selected = 0;
bool _isBusy = false;
Future<void> _onReorder(int oldIndex, int newIndex) async {
if (newIndex > oldIndex) newIndex -= 1;
final previous = List<T>.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<void> _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<void> _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),
],
),
);
}
}