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>
This commit is contained in:
@@ -0,0 +1,338 @@
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user