402 lines
13 KiB
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),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|