Files
petitspas/frontend/lib/widgets/dashboard/am_children_capacity_grid.dart
jmartinandCursor 01fb514bde refactor(#155): option C — widgets partagés sans préfixe Admin.
Déplace les composants Admin* du dashboard partagé vers
widgets/dashboard/ (ChildDetailModal, AmEditModal, UserCard, Select*, …).
Conserve AdminManagementWidget et screens/administrateurs. Rename mécanique,
aucun changement de comportement.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 23:13:53 +02:00

339 lines
9.8 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:p_tits_pas/models/parent_child_summary.dart';
import 'package:p_tits_pas/services/api/api_config.dart';
import 'package:p_tits_pas/utils/date_display_utils.dart';
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
/// Grille 2×2 des places d'accueil AM (max 4, limitée à [capacity]).
class AmChildrenCapacityGrid extends StatelessWidget {
static const int _gridSlots = 4;
static const double _slotHeight = 44;
static const double _gridPadding = 10;
static const double _gridGap = 8;
static const double _borderWidth = 1;
static const double fixedHeight = _borderWidth * 2 +
_gridPadding * 2 +
_slotHeight * 2 +
_gridGap;
final List<ParentChildSummary> children;
final int capacity;
final void Function(ParentChildSummary child) onOpen;
final void Function(ParentChildSummary child) onDetach;
/// Clic sur une case libre → même flux que « Rattacher un enfant » (#149).
final VoidCallback? onAttachEmpty;
const AmChildrenCapacityGrid({
super.key,
required this.children,
required this.capacity,
required this.onOpen,
required this.onDetach,
this.onAttachEmpty,
});
@override
Widget build(BuildContext context) {
final maxSlots = capacity.clamp(0, _gridSlots);
return Container(
height: fixedHeight,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade300, width: _borderWidth),
),
padding: const EdgeInsets.all(_gridPadding),
child: Column(
children: [
_buildRow(0, 1, maxSlots),
const SizedBox(height: _gridGap),
_buildRow(2, 3, maxSlots),
],
),
);
}
Widget _buildRow(int leftIndex, int rightIndex, int maxSlots) {
return Row(
children: [
Expanded(child: _buildSlot(leftIndex, maxSlots)),
const SizedBox(width: _gridGap),
Expanded(child: _buildSlot(rightIndex, maxSlots)),
],
);
}
Widget _buildSlot(int index, int maxSlots) {
return SizedBox(
height: _slotHeight,
child: _buildSlotContent(index, maxSlots),
);
}
Widget _buildSlotContent(int index, int maxSlots) {
if (index < children.length) {
return _OccupiedSlot(
child: children[index],
overCapacity: index >= maxSlots,
onOpen: () => onOpen(children[index]),
onDetach: () => onDetach(children[index]),
);
}
if (index < maxSlots) {
return _EmptySlot(onTap: onAttachEmpty);
}
return const _UnavailableSlot();
}
}
class _SlotShell extends StatelessWidget {
final Color backgroundColor;
final Color borderColor;
final Widget child;
const _SlotShell({
required this.backgroundColor,
required this.borderColor,
required this.child,
});
@override
Widget build(BuildContext context) {
return SizedBox.expand(
child: DecoratedBox(
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: borderColor),
),
child: child,
),
);
}
}
class _UnavailableSlot extends StatelessWidget {
const _UnavailableSlot();
@override
Widget build(BuildContext context) {
return _SlotShell(
backgroundColor: Colors.grey.shade100,
borderColor: Colors.grey.shade200,
child: Center(
child: Icon(
Icons.block_outlined,
size: 20,
color: Colors.grey.shade400,
),
),
);
}
}
class _EmptySlot extends StatefulWidget {
final VoidCallback? onTap;
const _EmptySlot({this.onTap});
@override
State<_EmptySlot> createState() => _EmptySlotState();
}
class _EmptySlotState extends State<_EmptySlot> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final clickable = widget.onTap != null;
return MouseRegion(
onEnter: clickable ? (_) => setState(() => _hovered = true) : null,
onExit: clickable ? (_) => setState(() => _hovered = false) : null,
cursor: clickable ? SystemMouseCursors.click : MouseCursor.defer,
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(8),
child: InkWell(
onTap: widget.onTap,
borderRadius: BorderRadius.circular(8),
hoverColor: const Color(0x149CC5C0),
child: _SlotShell(
backgroundColor: _hovered
? const Color(0xFFF3F0FA)
: Colors.grey.shade50,
borderColor: _hovered
? const Color(0xFFB8A4D4)
: Colors.grey.shade300,
child: Center(
child: Text(
'Place libre',
style: TextStyle(
fontSize: 12,
color: _hovered
? const Color(0xFF6B3FA0)
: Colors.grey.shade500,
fontWeight: _hovered ? FontWeight.w600 : FontWeight.w400,
),
),
),
),
),
),
);
}
}
class _OccupiedSlot extends StatefulWidget {
final ParentChildSummary child;
final bool overCapacity;
final VoidCallback onOpen;
final VoidCallback onDetach;
const _OccupiedSlot({
required this.child,
required this.overCapacity,
required this.onOpen,
required this.onDetach,
});
@override
State<_OccupiedSlot> createState() => _OccupiedSlotState();
}
class _OccupiedSlotState extends State<_OccupiedSlot> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final age = formatChildAgeLabel(
birthDate: widget.child.birthDate,
dueDate: widget.child.dueDate,
status: widget.child.status,
);
final avatarUrl = ApiConfig.absoluteMediaUrl(widget.child.photoUrl ?? '');
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: _SlotShell(
backgroundColor: widget.overCapacity
? Colors.red.shade50
: const Color(0xFFF8F5FC),
borderColor: widget.overCapacity
? Colors.red.shade300
: const Color(0xFFD8CCE8),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
_buildAvatar(avatarUrl),
const SizedBox(width: 8),
Expanded(
child: Row(
children: [
Flexible(
fit: FlexFit.loose,
child: Text(
widget.child.fullName,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (age.isNotEmpty) ...[
const SizedBox(width: 8),
Expanded(
child: Text(
age,
style: const TextStyle(
fontSize: 11,
color: Colors.black54,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
],
),
),
AnimatedOpacity(
duration: const Duration(milliseconds: 120),
opacity: _hovered ? 1 : 0,
child: IgnorePointer(
ignoring: !_hovered,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Voir / modifier',
icon: const Icon(Icons.visibility_outlined, size: 20),
padding: const EdgeInsets.all(4),
constraints: const BoxConstraints(
minWidth: 32,
minHeight: 32,
),
onPressed: widget.onOpen,
),
IconButton(
tooltip: 'Détacher',
icon: Icon(
Icons.link_off,
size: 20,
color: Colors.orange.shade800,
),
padding: const EdgeInsets.all(4),
constraints: const BoxConstraints(
minWidth: 32,
minHeight: 32,
),
onPressed: widget.onDetach,
),
],
),
),
),
],
),
),
),
);
}
Widget _buildAvatar(String url) {
const size = 28.0;
const bg = Color(0xFFEDE5FA);
const iconColor = Color(0xFF6B3FA0);
if (url.isEmpty) {
return CircleAvatar(
radius: 14,
backgroundColor: bg,
child: const Icon(Icons.child_care_outlined, size: 15, color: iconColor),
);
}
return ClipOval(
child: SizedBox(
width: size,
height: size,
child: AuthNetworkImage(
url: url,
width: size,
height: size,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => ColoredBox(
color: bg,
child: const Icon(Icons.child_care_outlined, size: 15, color: iconColor),
),
),
),
);
}
}