244 lines
8.7 KiB
Dart
244 lines
8.7 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:manager_api_new/api.dart';
|
||
import 'package:manager_app/constants.dart';
|
||
import 'package:myinfomate_layout/myinfomate_layout.dart';
|
||
|
||
const double _kGap = 8;
|
||
|
||
/// Aperçu live de la grille bento (même placement dense que web/mobile en prod).
|
||
/// Glisser le coin d'une card change ses spans (colonnes × lignes) ; le résultat
|
||
/// snappe sur des cellules entières. [onSpanChanged] doit rester synchronisé avec
|
||
/// le même état que le sélecteur de forme de la liste.
|
||
/// Glisser la poignée (coin haut-gauche) d'une card sur une autre échange leur
|
||
/// position dans la liste — le dense packing recalcule le reste automatiquement.
|
||
class BentoPreviewGrid extends StatelessWidget {
|
||
final List<AppConfigurationLinkDTO> links;
|
||
final int columns;
|
||
final void Function(AppConfigurationLinkDTO link, int colSpan, int rowSpan) onSpanChanged;
|
||
final void Function(String fromId, String toId) onSwap;
|
||
|
||
const BentoPreviewGrid({
|
||
super.key,
|
||
required this.links,
|
||
required this.columns,
|
||
required this.onSpanChanged,
|
||
required this.onSwap,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return LayoutBuilder(builder: (context, constraints) {
|
||
final width = constraints.maxWidth;
|
||
// Seuls les blocs actifs apparaissent réellement dans l'app — sinon le
|
||
// switch "actif/inactif" de la liste n'aurait aucun effet sur l'aperçu.
|
||
final activeLinks = links.where((l) => l.isActive == true).toList();
|
||
if (width <= 0 || activeLinks.isEmpty) return const SizedBox();
|
||
|
||
final cols = columns < 1 ? 1 : columns;
|
||
final maxSpan = cols < 2 ? 1 : 2;
|
||
final cellWidth = (width - (cols - 1) * _kGap) / cols;
|
||
final cellHeight = cellWidth * 0.78;
|
||
|
||
// Trié par `order` plutôt que par l'ordre du tableau reçu : la liste de
|
||
// gauche (ReorderableCustomList) travaille sur sa propre copie interne et
|
||
// ne réordonne que les DTO (leur champ `order`), jamais le tableau `links`
|
||
// lui-même — sans ce tri, l'aperçu ignorerait le reorder de la liste.
|
||
final orderedLinks = [...activeLinks]..sort((a, b) => (a.order ?? 0).compareTo(b.order ?? 0));
|
||
|
||
final result = bentoLayout(
|
||
orderedLinks
|
||
.map((l) => BentoItem(
|
||
id: l.id!,
|
||
colSpan: (l.gridColSpan ?? 1).clamp(1, maxSpan),
|
||
rowSpan: (l.gridRowSpan ?? 1).clamp(1, 2),
|
||
))
|
||
.toList(),
|
||
cols,
|
||
);
|
||
|
||
final totalHeight =
|
||
result.rowCount * cellHeight + (result.rowCount - 1) * _kGap;
|
||
final byId = {for (final l in activeLinks) l.id!: l};
|
||
|
||
return SizedBox(
|
||
width: width,
|
||
height: totalHeight < 0 ? 0 : totalHeight,
|
||
child: Stack(
|
||
children: result.placements.map((p) {
|
||
final link = byId[p.id]!;
|
||
return Positioned(
|
||
left: p.col * (cellWidth + _kGap),
|
||
top: p.row * (cellHeight + _kGap),
|
||
width: p.colSpan * cellWidth + (p.colSpan - 1) * _kGap,
|
||
height: p.rowSpan * cellHeight + (p.rowSpan - 1) * _kGap,
|
||
child: DragTarget<String>(
|
||
onWillAcceptWithDetails: (details) => details.data != link.id,
|
||
onAcceptWithDetails: (details) => onSwap(details.data, link.id!),
|
||
builder: (context, candidateData, rejectedData) {
|
||
return _BentoCard(
|
||
link: link,
|
||
cellWidth: cellWidth,
|
||
cellHeight: cellHeight,
|
||
maxSpan: maxSpan,
|
||
isDropTarget: candidateData.isNotEmpty,
|
||
onSpanChanged: (c, r) => onSpanChanged(link, c, r),
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}).toList(),
|
||
),
|
||
);
|
||
});
|
||
}
|
||
}
|
||
|
||
class _BentoCard extends StatefulWidget {
|
||
final AppConfigurationLinkDTO link;
|
||
final double cellWidth;
|
||
final double cellHeight;
|
||
final int maxSpan;
|
||
final bool isDropTarget;
|
||
final void Function(int colSpan, int rowSpan) onSpanChanged;
|
||
|
||
const _BentoCard({
|
||
required this.link,
|
||
required this.cellWidth,
|
||
required this.cellHeight,
|
||
required this.maxSpan,
|
||
required this.isDropTarget,
|
||
required this.onSpanChanged,
|
||
});
|
||
|
||
@override
|
||
State<_BentoCard> createState() => _BentoCardState();
|
||
}
|
||
|
||
class _BentoCardState extends State<_BentoCard> {
|
||
double _accDx = 0;
|
||
double _accDy = 0;
|
||
int _startCol = 1;
|
||
int _startRow = 1;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final link = widget.link;
|
||
final hasImage = link.configuration?.imageSource != null;
|
||
|
||
return Container(
|
||
decoration: BoxDecoration(
|
||
color: hasImage ? null : kPrimaryColor.withValues(alpha: 0.55),
|
||
borderRadius: BorderRadius.circular(10),
|
||
image: hasImage
|
||
? DecorationImage(image: NetworkImage(link.configuration!.imageSource!), fit: BoxFit.cover)
|
||
: null,
|
||
border: widget.isDropTarget ? Border.all(color: kPrimaryColor, width: 3) : null,
|
||
),
|
||
child: Stack(
|
||
children: [
|
||
Positioned.fill(
|
||
child: DecoratedBox(
|
||
decoration: BoxDecoration(
|
||
borderRadius: BorderRadius.circular(10),
|
||
gradient: LinearGradient(
|
||
begin: Alignment.topCenter,
|
||
end: Alignment.bottomCenter,
|
||
colors: [Colors.transparent, Colors.black.withValues(alpha: 0.7)],
|
||
stops: const [0.4, 1.0],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
Positioned(
|
||
bottom: 6,
|
||
left: 8,
|
||
right: 8,
|
||
child: Text(
|
||
link.configuration?.label ?? '',
|
||
maxLines: 2,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: const TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.w600),
|
||
),
|
||
),
|
||
// Poignée de déplacement — glisser sur une autre card échange leur position.
|
||
// Coin opposé à la poignée de resize pour éviter tout conflit de geste.
|
||
Positioned(
|
||
left: 0,
|
||
top: 0,
|
||
child: Draggable<String>(
|
||
data: link.id,
|
||
feedback: Material(
|
||
color: Colors.transparent,
|
||
child: Opacity(
|
||
opacity: 0.85,
|
||
child: SizedBox(
|
||
width: widget.cellWidth,
|
||
height: widget.cellHeight,
|
||
child: _dragHandle(),
|
||
),
|
||
),
|
||
),
|
||
child: _dragHandle(),
|
||
),
|
||
),
|
||
// Poignée de coin — drag 2D : largeur = colonnes, hauteur = lignes.
|
||
Positioned(
|
||
right: 0,
|
||
bottom: 0,
|
||
child: MouseRegion(
|
||
cursor: SystemMouseCursors.resizeDownRight,
|
||
child: GestureDetector(
|
||
onPanStart: (_) {
|
||
_accDx = 0;
|
||
_accDy = 0;
|
||
_startCol = (link.gridColSpan ?? 1).clamp(1, widget.maxSpan);
|
||
_startRow = (link.gridRowSpan ?? 1).clamp(1, 2);
|
||
},
|
||
onPanUpdate: (details) {
|
||
_accDx += details.delta.dx;
|
||
_accDy += details.delta.dy;
|
||
final newCol = (_startCol + (_accDx / (widget.cellWidth + _kGap)).round())
|
||
.clamp(1, widget.maxSpan);
|
||
final newRow = (_startRow + (_accDy / (widget.cellHeight + _kGap)).round())
|
||
.clamp(1, 2);
|
||
if (newCol != (link.gridColSpan ?? 1) || newRow != (link.gridRowSpan ?? 1)) {
|
||
widget.onSpanChanged(newCol, newRow);
|
||
}
|
||
},
|
||
child: Container(
|
||
width: 20,
|
||
height: 20,
|
||
margin: const EdgeInsets.all(3),
|
||
decoration: BoxDecoration(
|
||
color: kWhite,
|
||
borderRadius: BorderRadius.circular(5),
|
||
border: Border.all(color: kPrimaryColor, width: 2),
|
||
),
|
||
child: Icon(Icons.open_in_full, size: 11, color: kPrimaryColor),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _dragHandle() {
|
||
return MouseRegion(
|
||
cursor: SystemMouseCursors.grab,
|
||
child: Container(
|
||
width: 20,
|
||
height: 20,
|
||
margin: const EdgeInsets.all(3),
|
||
decoration: BoxDecoration(
|
||
color: kWhite,
|
||
borderRadius: BorderRadius.circular(5),
|
||
border: Border.all(color: kPrimaryColor, width: 2),
|
||
),
|
||
child: Icon(Icons.drag_indicator, size: 11, color: kPrimaryColor),
|
||
),
|
||
);
|
||
}
|
||
}
|