Suppression complète grossesse multiple / est_multiple (BDD, API, front). Rename option C : widgets partagés et panels staff sous widgets/dashboard/, AdminManagementWidget seul restant dans widgets/admin/.
339 lines
9.8 KiB
Dart
339 lines
9.8 KiB
Dart
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),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|