diff --git a/frontend/assets/images/bandeau_blue.png b/frontend/assets/images/bandeau_blue.png new file mode 100644 index 0000000..69a0d39 Binary files /dev/null and b/frontend/assets/images/bandeau_blue.png differ diff --git a/frontend/assets/images/bandeau_lavender.png b/frontend/assets/images/bandeau_lavender.png new file mode 100644 index 0000000..6cb7cb4 Binary files /dev/null and b/frontend/assets/images/bandeau_lavender.png differ diff --git a/frontend/assets/images/bandeau_lime.png b/frontend/assets/images/bandeau_lime.png new file mode 100644 index 0000000..da69b94 Binary files /dev/null and b/frontend/assets/images/bandeau_lime.png differ diff --git a/frontend/assets/images/bandeau_peach.png b/frontend/assets/images/bandeau_peach.png new file mode 100644 index 0000000..a79be96 Binary files /dev/null and b/frontend/assets/images/bandeau_peach.png differ diff --git a/frontend/assets/images/bandeau_yellow.png b/frontend/assets/images/bandeau_yellow.png new file mode 100644 index 0000000..949e9d8 Binary files /dev/null and b/frontend/assets/images/bandeau_yellow.png differ diff --git a/frontend/assets/images/bg_blue_pill.png b/frontend/assets/images/bg_blue_pill.png new file mode 100644 index 0000000..f45ba86 Binary files /dev/null and b/frontend/assets/images/bg_blue_pill.png differ diff --git a/frontend/lib/models/couple_garde.dart b/frontend/lib/models/couple_garde.dart new file mode 100644 index 0000000..1d1f1ab --- /dev/null +++ b/frontend/lib/models/couple_garde.dart @@ -0,0 +1,113 @@ +/// Modèles du couple de garde (enfant ↔ AM) — ticket #167. +/// Contrat backend #168 : GET /parents/me/couples-garde. + +/// Identité minimale d'une personne du couple (enfant, AM ou parent). +class CoupleMembre { + final String id; + final String? prenom; + final String? nom; + final String? photoUrl; + + const CoupleMembre({ + required this.id, + this.prenom, + this.nom, + this.photoUrl, + }); + + /// Nom d'affichage : prénom seul si dispo, sinon « Prénom Nom », sinon repli. + String displayName({String fallback = ''}) { + final p = (prenom ?? '').trim(); + final n = (nom ?? '').trim(); + if (p.isNotEmpty && n.isNotEmpty) return '$p $n'; + if (p.isNotEmpty) return p; + if (n.isNotEmpty) return n; + return fallback; + } + + factory CoupleMembre.fromJson(Map json) { + return CoupleMembre( + id: (json['id'] ?? '').toString(), + prenom: json['prenom']?.toString(), + nom: json['nom']?.toString(), + photoUrl: json['photo_url']?.toString(), + ); + } +} + +/// Un couple de garde = placement actif enfant ↔ AM. +class CoupleGarde { + final String id; + final CoupleMembre enfant; + final CoupleMembre am; + final bool courant; + + const CoupleGarde({ + required this.id, + required this.enfant, + required this.am, + this.courant = false, + }); + + factory CoupleGarde.fromJson(Map json) { + return CoupleGarde( + id: (json['id'] ?? '').toString(), + enfant: CoupleMembre.fromJson( + Map.from(json['enfant'] ?? const {}), + ), + am: CoupleMembre.fromJson( + Map.from(json['am'] ?? const {}), + ), + courant: json['courant'] == true, + ); + } + + CoupleGarde copyWith({bool? courant}) { + return CoupleGarde( + id: id, + enfant: enfant, + am: am, + courant: courant ?? this.courant, + ); + } +} + +/// Réponse de l'API couples de garde (liste + id du couple courant). +class CouplesGardeResponse { + final List couples; + final String? coupleCourantId; + + const CouplesGardeResponse({ + required this.couples, + this.coupleCourantId, + }); + + bool get isEmpty => couples.isEmpty; + bool get isUnique => couples.length == 1; + + /// Couple courant : celui marqué `courant`, sinon celui de [coupleCourantId], + /// sinon le premier (repli implicite côté client, prévu par le back). + CoupleGarde? get coupleCourant { + if (couples.isEmpty) return null; + for (final c in couples) { + if (c.courant) return c; + } + if (coupleCourantId != null) { + for (final c in couples) { + if (c.id == coupleCourantId) return c; + } + } + return couples.first; + } + + factory CouplesGardeResponse.fromJson(Map json) { + final list = (json['couples'] as List?) ?? const []; + return CouplesGardeResponse( + couples: list + .whereType() + .map((e) => CoupleGarde.fromJson(Map.from(e))) + .toList(), + coupleCourantId: json['couple_courant_id']?.toString(), + ); + } +} diff --git a/frontend/lib/screens/home/parent_screen/ParentDashboardScreen.dart b/frontend/lib/screens/home/parent_screen/ParentDashboardScreen.dart index 543689c..6a35615 100644 --- a/frontend/lib/screens/home/parent_screen/ParentDashboardScreen.dart +++ b/frontend/lib/screens/home/parent_screen/ParentDashboardScreen.dart @@ -1,10 +1,14 @@ import 'package:flutter/material.dart'; +import 'package:p_tits_pas/models/couple_garde.dart'; import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/services/auth_service.dart'; +import 'package:p_tits_pas/services/couple_garde_service.dart'; +import 'package:p_tits_pas/widgets/quotidien/couple_selector_bandeau.dart'; import 'package:p_tits_pas/widgets/quotidien/quotidien_shell.dart'; import 'package:p_tits_pas/widgets/quotidien/quotidien_theme.dart'; /// Tableau de bord parent — coquille 3 colonnes quotidien (#166). +/// Colonne gauche : sélecteur de couple enfant–nounou (#167). /// Métier cartes / blog / messagerie : tickets C/D/E. class ParentDashboardScreen extends StatefulWidget { const ParentDashboardScreen({super.key}); @@ -17,10 +21,16 @@ class _ParentDashboardScreenState extends State { QuotidienNavSection _section = QuotidienNavSection.liaison; AppUser? _user; + List _couples = const []; + String? _selectedCoupleId; + bool _couplesLoading = true; + String? _couplesError; + @override void initState() { super.initState(); _loadUser(); + _loadCouples(); } Future _loadUser() async { @@ -28,6 +38,52 @@ class _ParentDashboardScreenState extends State { if (mounted) setState(() => _user = user); } + Future _loadCouples() async { + setState(() { + _couplesLoading = true; + _couplesError = null; + }); + try { + final res = await CoupleGardeService.getCouplesGarde(); + if (!mounted) return; + setState(() { + _couples = res.couples; + _selectedCoupleId = res.coupleCourant?.id; + _couplesLoading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _couplesError = e.toString().replaceFirst('Exception: ', ''); + _couplesLoading = false; + }); + } + } + + Future _selectCouple(CoupleGarde couple) async { + if (couple.id == _selectedCoupleId) return; + // Optimiste : on bascule tout de suite, l'API persiste ensuite. + setState(() => _selectedCoupleId = couple.id); + try { + final res = await CoupleGardeService.definirCoupleCourant(couple.id); + if (!mounted) return; + setState(() { + _couples = res.couples; + _selectedCoupleId = res.coupleCourant?.id ?? couple.id; + }); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Impossible de changer de garde : ' + '${e.toString().replaceFirst('Exception: ', '')}', + ), + ), + ); + } + } + String get _displayName { final n = _user?.fullName.trim() ?? ''; if (n.isNotEmpty) return n; @@ -52,11 +108,13 @@ class _ParentDashboardScreenState extends State { onProfileTap: () => _soon('Profil'), onSearchAmTap: () => _soon('Recherche AM'), onSettingsTap: () => _soon('Paramètres'), - leftColumn: const QuotidienColumnPlaceholder( - title: 'Cartes', - subtitle: - 'Couple enfant–nounou et flux de cartes\n(à brancher — tickets #167 / #173).', - icon: Icons.style_outlined, + leftColumn: _LeftColumn( + couples: _couples, + selectedCoupleId: _selectedCoupleId, + loading: _couplesLoading, + error: _couplesError, + onRetry: _loadCouples, + onCoupleSelected: _selectCouple, ), centerColumn: const QuotidienColumnPlaceholder( title: 'Blog', @@ -81,3 +139,51 @@ class _ParentDashboardScreenState extends State { ); } } + +/// Colonne gauche : bandeau couple (#167) puis flux de cartes (#173 à venir). +class _LeftColumn extends StatelessWidget { + final List couples; + final String? selectedCoupleId; + final bool loading; + final String? error; + final VoidCallback onRetry; + final ValueChanged onCoupleSelected; + + const _LeftColumn({ + required this.couples, + required this.selectedCoupleId, + required this.loading, + required this.error, + required this.onRetry, + required this.onCoupleSelected, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + CoupleSelectorBandeau( + couples: couples, + selectedCoupleId: selectedCoupleId, + loading: loading, + errorMessage: error, + onRetry: onRetry, + onCoupleSelected: onCoupleSelected, + ), + const SizedBox(height: 14), + const Expanded( + child: QuotidienColumnPlaceholder( + title: 'Cartes', + subtitle: + 'Absences, congés AM, sorties à valider\n(à brancher — ticket #173).', + icon: Icons.style_outlined, + ), + ), + ], + ), + ); + } +} diff --git a/frontend/lib/services/api/api_config.dart b/frontend/lib/services/api/api_config.dart index 72e7667..d7ec64f 100644 --- a/frontend/lib/services/api/api_config.dart +++ b/frontend/lib/services/api/api_config.dart @@ -62,6 +62,10 @@ class ApiConfig { static const String parents = '/parents'; /// Création dossier famille actif par le staff (#129) — body type register parent. static const String parentsDossier = '/parents/dossier'; + /// Couples de garde du parent connecté (#167 / #168). + static const String parentsCouplesGarde = '/parents/me/couples-garde'; + static const String parentsCoupleGardeCourant = + '/parents/me/couples-garde/courant'; static const String assistantesMaternelles = '/assistantes-maternelles'; /// Création dossier AM actif par le staff (#156) — body type register AM. static const String assistantesMaternellesDossier = diff --git a/frontend/lib/services/couple_garde_service.dart b/frontend/lib/services/couple_garde_service.dart new file mode 100644 index 0000000..83afcdf --- /dev/null +++ b/frontend/lib/services/couple_garde_service.dart @@ -0,0 +1,76 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:p_tits_pas/models/couple_garde.dart'; +import 'package:p_tits_pas/services/api/api_config.dart'; +import 'package:p_tits_pas/services/api/tokenService.dart'; + +/// Accès API aux couples de garde du parent connecté — tickets #167 / #168. +/// - GET /parents/me/couples-garde +/// - PUT /parents/me/couples-garde/courant { couple_id } +class CoupleGardeService { + static Future> _headers() async { + final token = await TokenService.getToken(); + return token != null + ? ApiConfig.authHeaders(token) + : Map.from(ApiConfig.headers); + } + + static String _extractError(String body, String fallback) { + try { + final decoded = jsonDecode(body); + if (decoded is Map) { + final message = decoded['message']; + if (message is String && message.trim().isNotEmpty) { + return message; + } + if (message is Map && message['message'] is String) { + return message['message'] as String; + } + } + } catch (_) {} + return fallback; + } + + /// Liste les couples de garde et le couple courant du parent connecté. + static Future getCouplesGarde() async { + final response = await http.get( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.parentsCouplesGarde}'), + headers: await _headers(), + ); + + if (response.statusCode != 200) { + throw Exception( + _extractError(response.body, 'Erreur chargement des couples de garde'), + ); + } + + final decoded = jsonDecode(response.body); + return CouplesGardeResponse.fromJson( + Map.from(decoded as Map), + ); + } + + /// Persiste le couple courant (préférence utilisateur) et renvoie la liste + /// à jour. + static Future definirCoupleCourant( + String coupleId, + ) async { + final response = await http.put( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.parentsCoupleGardeCourant}'), + headers: await _headers(), + body: jsonEncode({'couple_id': coupleId}), + ); + + if (response.statusCode != 200) { + throw Exception( + _extractError(response.body, 'Erreur sélection du couple de garde'), + ); + } + + final decoded = jsonDecode(response.body); + return CouplesGardeResponse.fromJson( + Map.from(decoded as Map), + ); + } +} diff --git a/frontend/lib/widgets/quotidien/couple_selector_bandeau.dart b/frontend/lib/widgets/quotidien/couple_selector_bandeau.dart new file mode 100644 index 0000000..572c237 --- /dev/null +++ b/frontend/lib/widgets/quotidien/couple_selector_bandeau.dart @@ -0,0 +1,371 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:p_tits_pas/models/couple_garde.dart'; +import 'package:p_tits_pas/services/api/api_config.dart'; +import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; +import 'package:p_tits_pas/widgets/quotidien/quotidien_theme.dart'; + +/// Rôle qui consulte le bandeau : côté parent (enfant | nounou) ou côté AM +/// (enfant | parent(s)). Le composant est le même, seule la lecture change. +/// Ticket #167 (parent) ; #170 réutilise en mode AM. +enum CoupleBandeauMode { parent, assistanteMaternelle } + +/// Bandeau « couple de garde » en haut de la colonne gauche du TdB. +/// +/// - Plusieurs couples → contrôle unique avec chevron (dropdown de bascule). +/// - Un seul couple → affichage informatif (pas de chevron, pas de menu). +/// - Aucun couple → état vide discret. +class CoupleSelectorBandeau extends StatelessWidget { + final CoupleBandeauMode mode; + final List couples; + final String? selectedCoupleId; + final ValueChanged? onCoupleSelected; + final bool loading; + final String? errorMessage; + final VoidCallback? onRetry; + + const CoupleSelectorBandeau({ + super.key, + this.mode = CoupleBandeauMode.parent, + required this.couples, + this.selectedCoupleId, + this.onCoupleSelected, + this.loading = false, + this.errorMessage, + this.onRetry, + }); + + CoupleGarde? get _selected { + if (couples.isEmpty) return null; + if (selectedCoupleId != null) { + for (final c in couples) { + if (c.id == selectedCoupleId) return c; + } + } + for (final c in couples) { + if (c.courant) return c; + } + return couples.first; + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder(builder: (context, constraints) { + if (loading) return const _CoupleBandeauSkeleton(); + if (errorMessage != null) { + return _CoupleBandeauError(message: errorMessage!, onRetry: onRetry); + } + if (couples.isEmpty) return const _CoupleBandeauEmpty(); + + final selected = _selected!; + final multi = couples.length > 1; + + final card = _CoupleCard( + mode: mode, + couple: selected, + showChevron: multi, + ); + + if (!multi) return card; + + return Theme( + data: Theme.of(context).copyWith( + hoverColor: Colors.transparent, + splashColor: Colors.transparent, + highlightColor: Colors.transparent, + focusColor: Colors.transparent, + ), + child: PopupMenuButton( + tooltip: 'Changer de garde', + // L'offset doit être suffisant pour descendre sous la carte (qui fait 90px). + offset: const Offset(0, 95), + color: Colors.transparent, // Le fond devient invisible + elevation: 0, // Pas d'ombre carrée + constraints: BoxConstraints.tightFor(width: constraints.maxWidth), + padding: EdgeInsets.zero, + onSelected: (id) { + final chosen = couples.firstWhere((c) => c.id == id); + onCoupleSelected?.call(chosen); + }, + itemBuilder: (context) => [ + for (final c in couples) + if (c.id != selected.id) + PopupMenuItem( + value: c.id, + padding: EdgeInsets.zero, + height: 100, // Hauteur de la carte (90) + un peu de marge (10) + child: Padding( + padding: const EdgeInsets.only(bottom: 10), + child: _CoupleCard( + mode: mode, + couple: c, + showChevron: false, + selected: false, + isMenuItem: true, + ), + ), + ), + ], + child: card, + ), + ); + }); + } +} + +/// Carte principale : enfant à gauche, séparateur, AM/parents à droite. +class _CoupleCard extends StatelessWidget { + final CoupleBandeauMode mode; + final CoupleGarde couple; + final bool showChevron; + final bool selected; + final bool isMenuItem; + + const _CoupleCard({ + required this.mode, + required this.couple, + required this.showChevron, + this.selected = true, + this.isMenuItem = false, + }); + + @override + Widget build(BuildContext context) { + // Dans le menu, les non-sélectionnés sont un peu translucides + final opacity = (!selected && isMenuItem) ? 0.65 : 1.0; + + return Container( + height: 90, + decoration: BoxDecoration( + image: DecorationImage( + image: AssetImage(QuotidienTheme.bandeauAssetForCouple(couple.id)), + fit: BoxFit.fill, + opacity: opacity, + ), + ), + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Row( + children: [ + Expanded( + child: Opacity( + opacity: opacity, + child: _MembreTile( + photoUrl: couple.enfant.photoUrl, + name: couple.enfant.displayName(fallback: 'Enfant'), + fallbackIcon: Icons.child_care, + ), + ), + ), + Container( + width: 1, + height: 44, + margin: const EdgeInsets.symmetric(horizontal: 12), + color: QuotidienTheme.ink.withValues(alpha: 0.15 * opacity), + ), + Expanded( + child: Opacity( + opacity: opacity, + child: _MembreTile( + photoUrl: couple.am.photoUrl, + name: couple.am.displayName( + fallback: mode == CoupleBandeauMode.parent + ? 'Nounou' + : 'Parent', + ), + fallbackIcon: mode == CoupleBandeauMode.parent + ? Icons.volunteer_activism + : Icons.person_outline, + ), + ), + ), + if (showChevron) + Padding( + padding: const EdgeInsets.only(left: 4), + child: Icon( + Icons.keyboard_arrow_down, + color: QuotidienTheme.ink.withValues(alpha: opacity), + ), + ), + ], + ), + ); + } +} + +/// Photo ronde + nom (une moitié du couple). +class _MembreTile extends StatelessWidget { + final String? photoUrl; + final String name; + final IconData fallbackIcon; + + const _MembreTile({ + required this.photoUrl, + required this.name, + required this.fallbackIcon, + }); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + _Avatar(photoUrl: photoUrl, fallbackIcon: fallbackIcon, size: 48), + const SizedBox(width: 10), + Expanded( + child: Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.merienda( + fontSize: 15, + fontWeight: FontWeight.bold, + color: QuotidienTheme.ink, + ), + ), + ), + ], + ); + } +} + +class _Avatar extends StatelessWidget { + final String? photoUrl; + final IconData fallbackIcon; + final double size; + + const _Avatar({ + required this.photoUrl, + required this.fallbackIcon, + required this.size, + }); + + @override + Widget build(BuildContext context) { + final url = ApiConfig.absoluteMediaUrl(photoUrl); + final placeholder = Container( + width: size, + height: size, + color: QuotidienTheme.lavender.withValues(alpha: 0.35), + child: Icon(fallbackIcon, size: size * 0.5, color: QuotidienTheme.ink), + ); + return ClipOval( + child: SizedBox( + width: size, + height: size, + child: url.isEmpty + ? placeholder + : AuthNetworkImage( + url: url, + width: size, + height: size, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => placeholder, + ), + ), + ); + } +} + +class _CoupleBandeauSkeleton extends StatelessWidget { + const _CoupleBandeauSkeleton(); + + @override + Widget build(BuildContext context) { + return Container( + height: 90, + decoration: BoxDecoration( + image: DecorationImage( + image: const AssetImage(QuotidienTheme.bandeauLime), // Un asset existant + fit: BoxFit.fill, + colorFilter: ColorFilter.mode( + Colors.white.withValues(alpha: 0.5), + BlendMode.lighten, + ), + ), + ), + alignment: Alignment.center, + child: const SizedBox( + width: 26, + height: 26, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ); + } +} + +class _CoupleBandeauEmpty extends StatelessWidget { + const _CoupleBandeauEmpty(); + + @override + Widget build(BuildContext context) { + return Container( + height: 90, + decoration: const BoxDecoration( + image: DecorationImage( + image: AssetImage(QuotidienTheme.bandeauLime), // Un asset existant par défaut + fit: BoxFit.fill, + ), + ), + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Row( + children: [ + const Icon(Icons.info_outline, color: QuotidienTheme.muted), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Aucune garde active pour le moment.', + style: GoogleFonts.merriweather( + fontSize: 14, + color: QuotidienTheme.muted, + ), + ), + ), + ], + ), + ); + } +} + +class _CoupleBandeauError extends StatelessWidget { + final String message; + final VoidCallback? onRetry; + + const _CoupleBandeauError({required this.message, this.onRetry}); + + @override + Widget build(BuildContext context) { + return Container( + height: 90, + decoration: BoxDecoration( + image: DecorationImage( + image: const AssetImage(QuotidienTheme.bandeauLime), // Un asset existant + fit: BoxFit.fill, + colorFilter: ColorFilter.mode( + QuotidienTheme.coral.withValues(alpha: 0.3), + BlendMode.srcATop, + ), + ), + ), + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Row( + children: [ + const Icon(Icons.error_outline, color: QuotidienTheme.ink), + const SizedBox(width: 12), + Expanded( + child: Text( + message, + style: GoogleFonts.merriweather( + fontSize: 13, + color: QuotidienTheme.ink, + ), + ), + ), + if (onRetry != null) + TextButton( + onPressed: onRetry, + child: const Text('Réessayer'), + ), + ], + ), + ); + } +} diff --git a/frontend/lib/widgets/quotidien/quotidien_theme.dart b/frontend/lib/widgets/quotidien/quotidien_theme.dart index 2eb3a5f..188b5a2 100644 --- a/frontend/lib/widgets/quotidien/quotidien_theme.dart +++ b/frontend/lib/widgets/quotidien/quotidien_theme.dart @@ -19,6 +19,29 @@ abstract final class QuotidienTheme { /// couleur charte par section ; l'inactive est la même, plus transparente. static const double pillInactiveOpacity = 0.45; static const String pillIvoryAsset = 'assets/images/bg_ivoire_pill.png'; + static const String pillBlueAsset = 'assets/images/bg_blue_pill.png'; + + // Bandeaux pour les couples (ratio 10:1, dessinés au crayon, couleur dynamique) + static const String bandeauLime = 'assets/images/bandeau_lime.png'; + static const String bandeauBlue = 'assets/images/bandeau_blue.png'; + static const String bandeauPeach = 'assets/images/bandeau_peach.png'; + static const String bandeauYellow = 'assets/images/bandeau_yellow.png'; + static const String bandeauLavender = 'assets/images/bandeau_lavender.png'; + + static const List bandeauColors = [ + bandeauLime, + bandeauBlue, + bandeauPeach, + bandeauYellow, + bandeauLavender, + ]; + + /// Retourne un asset bandeau fixe pour un couple donné (basé sur son ID) + static String bandeauAssetForCouple(String coupleId) { + if (coupleId.isEmpty) return bandeauLime; + final hash = coupleId.hashCode.abs(); + return bandeauColors[hash % bandeauColors.length]; + } static const String pillYellowAsset = 'assets/images/bg_yellow_pill.png'; static const String pillPeachAsset = 'assets/images/bg_peach_pill.png'; static const String pillTurquoiseAsset =