import 'package:flutter/material.dart'; import 'package:manager_app/Components/fetch_section_icon.dart'; import 'package:manager_app/constants.dart'; import 'package:manager_app/l10n/app_localizations.dart'; import 'package:manager_api_new/api.dart'; /// Tuile d'une section dans la grille d'une configuration. /// /// Elle faisait 150 × 150 dans une liste horizontale : carrée, trop grande, et /// tout au centre. Format 16/9, badge d'ordre en haut à gauche, poignée de /// réordonnancement en haut à droite, nom et type en dessous. class SectionCard extends StatelessWidget { const SectionCard({ Key? key, required this.section, required this.order, required this.onTap, this.dragHandle, this.isDropTarget = false, this.onToggleVisibility, }) : super(key: key); final SectionDTO section; final int order; final VoidCallback onTap; final Widget? dragHandle; final bool isDropTarget; /// Null pour un utilisateur qui ne peut pas éditer : la tuile perd le bouton. final VoidCallback? onToggleVisibility; @override Widget build(BuildContext context) { final l = AppLocalizations.of(context)!; final hasImage = section.imageSource != null && section.imageSource!.isNotEmpty; final isHidden = section.isActive == false; return Material( color: Colors.transparent, child: InkWell( borderRadius: BorderRadius.circular(kRadiusCard), onTap: onTap, child: Opacity( opacity: isHidden ? 0.5 : 1, child: Container( decoration: BoxDecoration( color: kSurface, border: Border.all( color: isDropTarget ? kPrimaryColor : kLine, width: isDropTarget ? 1.5 : 1, ), borderRadius: BorderRadius.circular(kRadiusCard), ), clipBehavior: Clip.antiAlias, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ AspectRatio( aspectRatio: 16 / 9, child: Stack( fit: StackFit.expand, children: [ if (hasImage) Image.network(section.imageSource!, fit: BoxFit.cover) else ColoredBox( color: kGround, child: Center( child: Icon(getSectionIcon(section.type), size: 26, color: kLine), ), ), Positioned( top: kSpace2, left: kSpace2, child: Container( padding: const EdgeInsets.symmetric( horizontal: kSpace2, vertical: 1), decoration: BoxDecoration( color: kPrimaryColor, borderRadius: BorderRadius.circular(kRadiusInput), ), child: Text("$order", style: const TextStyle( color: kWhite, fontSize: 11, fontWeight: FontWeight.w600)), ), ), if (isHidden) Positioned( bottom: kSpace2, left: kSpace2, child: Container( padding: const EdgeInsets.symmetric( horizontal: kSpace2, vertical: 1), decoration: BoxDecoration( color: kSurface, border: Border.all(color: kLine), borderRadius: BorderRadius.circular(kRadiusInput), ), child: Text(l.sectionHiddenBadge, style: const TextStyle( fontSize: 10, color: kInk3)), ), ), if (onToggleVisibility != null) Positioned( bottom: 2, right: 2, child: _OverlayIconButton( icon: isHidden ? Icons.visibility_off : Icons.visibility, tooltip: isHidden ? l.sectionShowAction : l.sectionHideAction, onPressed: onToggleVisibility!, ), ), if (dragHandle != null) Positioned(top: 2, right: 2, child: dragHandle!), ], ), ), Padding( padding: const EdgeInsets.fromLTRB(kSpace3, kSpace3, kSpace3, kSpace4), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( section.label ?? "", maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 12.5, fontWeight: FontWeight.w500, height: 1.25, color: kInk), ), const SizedBox(height: 3), Row( children: [ Icon(getSectionIcon(section.type), size: 12, color: kInk3), const SizedBox(width: kSpace1), Expanded( child: Text( getSectionTypeName(l, section.type), maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 10.5, color: kInk3), ), ), ], ), ], ), ), ], ), ), ), ), ); } } /// Bouton posé sur l'image de la tuile : l'ombre porte le contraste, parce que /// l'image derrière peut être de n'importe quelle couleur. class _OverlayIconButton extends StatelessWidget { const _OverlayIconButton({ required this.icon, required this.tooltip, required this.onPressed, }); final IconData icon; final String tooltip; final VoidCallback onPressed; @override Widget build(BuildContext context) { return Tooltip( message: tooltip, child: Material( color: Colors.transparent, child: InkWell( borderRadius: BorderRadius.circular(kRadiusInput), onTap: onPressed, child: Padding( padding: const EdgeInsets.all(kSpace1), child: Icon(icon, size: 17, color: kWhite, shadows: const [Shadow(color: kBlack, blurRadius: 3)]), ), ), ), ); } } /// La grille de sections, réordonnable, terminée par la tuile « Ajouter ». /// /// Partagée par les sections d'une configuration et par les sous-sections d'un /// Menu — ce sont les mêmes objets, avec le même geste. `ReorderableListView` /// ne sait pas faire de grille, d'où le `Draggable` sur la poignée et le /// `DragTarget` sur chaque tuile. class SectionGrid extends StatefulWidget { const SectionGrid({ Key? key, required this.sections, required this.onReorder, required this.onTap, required this.onAdd, this.onToggleVisibility, }) : super(key: key); final List sections; final void Function(int oldIndex, int newIndex) onReorder; final void Function(SectionDTO section, int index) onTap; final VoidCallback onAdd; final void Function(SectionDTO section)? onToggleVisibility; @override State createState() => _SectionGridState(); } class _SectionGridState extends State { int? dropTargetIndex; @override Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { const spacing = kSpace3; const minWidth = 146.0; final columns = ((constraints.maxWidth + spacing) / (minWidth + spacing)) .floor() .clamp(1, widget.sections.length + 1); final width = (constraints.maxWidth - spacing * (columns - 1)) / columns; return Wrap( spacing: spacing, runSpacing: spacing, children: [ for (var index = 0; index < widget.sections.length; index++) SizedBox( width: width, child: DragTarget( onWillAcceptWithDetails: (details) { if (details.data == index) return false; setState(() => dropTargetIndex = index); return true; }, onLeave: (_) => setState(() => dropTargetIndex = null), onAcceptWithDetails: (details) { setState(() => dropTargetIndex = null); widget.onReorder(details.data, index); }, builder: (context, candidate, rejected) => SectionCard( section: widget.sections[index], order: index + 1, isDropTarget: dropTargetIndex == index, dragHandle: SectionDragHandle(index: index, width: width), onTap: () => widget.onTap(widget.sections[index], index), onToggleVisibility: widget.onToggleVisibility == null ? null : () => widget.onToggleVisibility!(widget.sections[index]), ), ), ), SizedBox( width: width, child: AddSectionCard(onTap: widget.onAdd), ), ], ); }, ); } } class SectionDragHandle extends StatelessWidget { const SectionDragHandle({Key? key, required this.index, required this.width}) : super(key: key); final int index; final double width; @override Widget build(BuildContext context) { return Draggable( data: index, feedback: Container( width: width, height: width * 9 / 16, decoration: BoxDecoration( color: kPrimaryColor.withValues(alpha: 0.85), borderRadius: BorderRadius.circular(kRadiusCard), ), child: const Icon(Icons.drag_indicator, color: kWhite), ), childWhenDragging: const SizedBox.shrink(), child: const MouseRegion( cursor: SystemMouseCursors.grab, child: Padding( padding: EdgeInsets.all(kSpace1), child: Icon(Icons.drag_indicator, size: 17, color: kWhite, shadows: [Shadow(color: kBlack, blurRadius: 3)]), ), ), ); } } /// La tuile « Ajouter » prend la place du bouton vert flottant : elle est dans /// la grille, à la fin, là où le regard finit de lire les sections. class AddSectionCard extends StatelessWidget { const AddSectionCard({Key? key, required this.onTap}) : super(key: key); final VoidCallback onTap; @override Widget build(BuildContext context) { final l = AppLocalizations.of(context)!; return Material( color: Colors.transparent, child: InkWell( borderRadius: BorderRadius.circular(kRadiusCard), onTap: onTap, child: DottedBorderBox( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Icon(Icons.add, size: 22, color: kPrimaryColor), const SizedBox(height: 3), Text( l.addSectionTile, textAlign: TextAlign.center, style: const TextStyle( fontSize: 11.5, fontWeight: FontWeight.w500, color: kPrimaryColor), ), ], ), ), ), ); } } /// Cadre en pointillés — Flutter n'a pas de `border-style: dashed`, donc c'est /// dessiné à la main. class DottedBorderBox extends StatelessWidget { const DottedBorderBox({Key? key, required this.child}) : super(key: key); final Widget child; @override Widget build(BuildContext context) { return CustomPaint( painter: _DashedBorderPainter(), child: Padding( padding: const EdgeInsets.symmetric(horizontal: kSpace3, vertical: kSpace5), child: child, ), ); } } class _DashedBorderPainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { final paint = Paint() ..color = kBrandSoft ..style = PaintingStyle.stroke ..strokeWidth = 1; final path = Path() ..addRRect(RRect.fromRectAndRadius( Offset.zero & size, const Radius.circular(kRadiusCard))); const dash = 4.0; const gap = 3.0; for (final metric in path.computeMetrics()) { double distance = 0; while (distance < metric.length) { canvas.drawPath( metric.extractPath(distance, distance + dash), paint); distance += dash + gap; } } } @override bool shouldRepaint(covariant CustomPainter oldDelegate) => false; }