manager-app/lib/Screens/Applications/bento_preview_grid.dart
2026-07-14 17:20:20 +02:00

244 lines
8.7 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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),
),
);
}
}