feat(front): coquille quotidien parent 3 colonnes + bandeau pastel (#166)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-23 23:40:16 +02:00
co-authored by Cursor
parent aeda636983
commit 2daf6c9cda
19 changed files with 742 additions and 652 deletions
@@ -0,0 +1,282 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:p_tits_pas/services/auth_service.dart';
import 'package:p_tits_pas/widgets/image_button.dart';
import 'package:p_tits_pas/widgets/quotidien/quotidien_theme.dart';
/// Bandeau quotidien pastel : logo · pastilles Cahier de liaison / Agenda /
/// Contrat · menu user.
/// Réutilisable parent (#166) et AM (#169).
class QuotidienBandeau extends StatelessWidget {
final QuotidienNavSection selectedSection;
final ValueChanged<QuotidienNavSection> onSectionSelected;
final String userDisplayName;
final String? userEmail;
final VoidCallback? onProfileTap;
final VoidCallback? onSearchAmTap;
final VoidCallback? onSettingsTap;
final VoidCallback? onLogout;
const QuotidienBandeau({
super.key,
required this.selectedSection,
required this.onSectionSelected,
required this.userDisplayName,
this.userEmail,
this.onProfileTap,
this.onSearchAmTap,
this.onSettingsTap,
this.onLogout,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: Row(
children: [
Image.asset(
QuotidienTheme.logoAsset,
height: 44,
fit: BoxFit.contain,
),
const Spacer(),
_NavPills(
selected: selectedSection,
onSelected: onSectionSelected,
),
const Spacer(),
_UserMenu(
displayName: userDisplayName,
email: userEmail,
onProfileTap: onProfileTap,
onSearchAmTap: onSearchAmTap,
onSettingsTap: onSettingsTap,
onLogout: onLogout,
),
],
),
);
}
}
class _NavPills extends StatelessWidget {
final QuotidienNavSection selected;
final ValueChanged<QuotidienNavSection> onSelected;
const _NavPills({
required this.selected,
required this.onSelected,
});
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
_pill(
label: 'Cahier de liaison',
section: QuotidienNavSection.liaison,
),
const SizedBox(width: 10),
_pill(
label: 'Agenda',
section: QuotidienNavSection.agenda,
),
const SizedBox(width: 10),
_pill(
label: 'Contrat',
section: QuotidienNavSection.contrat,
),
],
);
}
Widget _pill({
required String label,
required QuotidienNavSection section,
}) {
final active = selected == section;
// Même famille que l’inscription : fonds « dessinés » (pas Material).
// Les deux pastilles ont la même silhouette (ratio ~4:1) : la largeur
// est dérivée de la hauteur pour ne jamais déformer le PNG.
// Chaque section garde sa couleur ; l'inactive est juste plus claire.
return ImageButton(
bg: QuotidienTheme.pillAssetFor(section),
bgOpacity: active ? 1.0 : QuotidienTheme.pillInactiveOpacity,
width: _pillHeight * QuotidienTheme.pillAspectRatio,
height: _pillHeight,
text: label,
textColor: QuotidienTheme.ink,
fontSize: 14,
onPressed: () => onSelected(section),
);
}
static const double _pillHeight = 46;
}
class _UserMenu extends StatelessWidget {
final String displayName;
final String? email;
final VoidCallback? onProfileTap;
final VoidCallback? onSearchAmTap;
final VoidCallback? onSettingsTap;
final VoidCallback? onLogout;
const _UserMenu({
required this.displayName,
this.email,
this.onProfileTap,
this.onSearchAmTap,
this.onSettingsTap,
this.onLogout,
});
@override
Widget build(BuildContext context) {
final shortName = displayName.trim().isEmpty ? 'Compte' : displayName.trim();
const double pillHeight = 46;
const double pillWidth = pillHeight * QuotidienTheme.pillAspectRatio;
return PopupMenuButton<String>(
tooltip: 'Menu utilisateur',
offset: const Offset(0, pillHeight + 4),
color: QuotidienTheme.ivory,
elevation: 3,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: QuotidienTheme.lavender.withOpacity(0.6)),
),
onSelected: (value) async {
switch (value) {
case 'profile':
onProfileTap?.call();
break;
case 'search_am':
onSearchAmTap?.call();
break;
case 'settings':
onSettingsTap?.call();
break;
case 'logout':
await _confirmLogout(context);
break;
}
},
itemBuilder: (context) => [
if (email != null && email!.trim().isNotEmpty)
PopupMenuItem(
enabled: false,
child: Text(
email!,
style: TextStyle(color: QuotidienTheme.muted, fontSize: 12),
),
),
const PopupMenuDivider(),
const PopupMenuItem(
value: 'profile',
child: ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: Icon(Icons.person_outline, size: 20),
title: Text('Profil'),
),
),
const PopupMenuItem(
value: 'search_am',
child: ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: Icon(Icons.search, size: 20),
title: Text('Recherche AM'),
),
),
const PopupMenuItem(
value: 'settings',
child: ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: Icon(Icons.settings_outlined, size: 20),
title: Text('Paramètres'),
),
),
const PopupMenuDivider(),
const PopupMenuItem(
value: 'logout',
child: ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: Icon(Icons.logout, size: 20),
title: Text('Déconnexion'),
),
),
],
// Même pastille « dessinée » que la nav, teinte violet pastel charte.
child: Container(
width: pillWidth,
height: pillHeight,
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: const BoxDecoration(
image: DecorationImage(
image: AssetImage(QuotidienTheme.pillLavenderAsset),
fit: BoxFit.fill,
),
),
child: Row(
children: [
const Icon(
Icons.person_outline,
size: 20,
color: QuotidienTheme.ink,
),
const SizedBox(width: 6),
Expanded(
child: Text(
shortName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.merienda(
fontSize: 13,
fontWeight: FontWeight.bold,
color: QuotidienTheme.ink,
),
),
),
const Icon(
Icons.keyboard_arrow_down,
size: 18,
color: QuotidienTheme.ink,
),
],
),
),
);
}
Future<void> _confirmLogout(BuildContext context) async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Déconnexion'),
content: const Text('Voulez-vous vraiment vous déconnecter ?'),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Annuler'),
),
TextButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Déconnexion'),
),
],
),
);
if (ok != true) return;
onLogout?.call();
await AuthService.logout();
if (context.mounted) {
context.go('/login');
}
}
}