Regroupe la configuration de carte en deux blocs (Carte / Points d'interet), ajoute le filtre par categorie et l'editeur de point geographique, et passe les ecrans Users et les jauges de quota par AppLocalizations (FR/EN/NL). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
405 lines
14 KiB
Dart
405 lines
14 KiB
Dart
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<SectionDTO> 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<SectionGrid> createState() => _SectionGridState();
|
||
}
|
||
|
||
class _SectionGridState extends State<SectionGrid> {
|
||
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<int>(
|
||
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<int>(
|
||
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;
|
||
}
|