Merge branch 'feature/167-selecteur-couple-enfant-nounou' into develop (#167)

This commit is contained in:
2026-09-24 00:12:49 +02:00
12 changed files with 691 additions and 5 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

+113
View File
@@ -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<String, dynamic> 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<String, dynamic> json) {
return CoupleGarde(
id: (json['id'] ?? '').toString(),
enfant: CoupleMembre.fromJson(
Map<String, dynamic>.from(json['enfant'] ?? const {}),
),
am: CoupleMembre.fromJson(
Map<String, dynamic>.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<CoupleGarde> 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<String, dynamic> json) {
final list = (json['couples'] as List?) ?? const [];
return CouplesGardeResponse(
couples: list
.whereType<Map>()
.map((e) => CoupleGarde.fromJson(Map<String, dynamic>.from(e)))
.toList(),
coupleCourantId: json['couple_courant_id']?.toString(),
);
}
}
@@ -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 enfantnounou (#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<ParentDashboardScreen> {
QuotidienNavSection _section = QuotidienNavSection.liaison;
AppUser? _user;
List<CoupleGarde> _couples = const [];
String? _selectedCoupleId;
bool _couplesLoading = true;
String? _couplesError;
@override
void initState() {
super.initState();
_loadUser();
_loadCouples();
}
Future<void> _loadUser() async {
@@ -28,6 +38,52 @@ class _ParentDashboardScreenState extends State<ParentDashboardScreen> {
if (mounted) setState(() => _user = user);
}
Future<void> _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<void> _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<ParentDashboardScreen> {
onProfileTap: () => _soon('Profil'),
onSearchAmTap: () => _soon('Recherche AM'),
onSettingsTap: () => _soon('Paramètres'),
leftColumn: const QuotidienColumnPlaceholder(
title: 'Cartes',
subtitle:
'Couple enfantnounou 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<ParentDashboardScreen> {
);
}
}
/// Colonne gauche : bandeau couple (#167) puis flux de cartes (#173 à venir).
class _LeftColumn extends StatelessWidget {
final List<CoupleGarde> couples;
final String? selectedCoupleId;
final bool loading;
final String? error;
final VoidCallback onRetry;
final ValueChanged<CoupleGarde> 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,
),
),
],
),
);
}
}
@@ -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 =
@@ -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<Map<String, String>> _headers() async {
final token = await TokenService.getToken();
return token != null
? ApiConfig.authHeaders(token)
: Map<String, String>.from(ApiConfig.headers);
}
static String _extractError(String body, String fallback) {
try {
final decoded = jsonDecode(body);
if (decoded is Map<String, dynamic>) {
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<CouplesGardeResponse> 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<String, dynamic>.from(decoded as Map),
);
}
/// Persiste le couple courant (préférence utilisateur) et renvoie la liste
/// à jour.
static Future<CouplesGardeResponse> 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<String, dynamic>.from(decoded as Map),
);
}
}
@@ -0,0 +1,364 @@
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<CoupleGarde> couples;
final String? selectedCoupleId;
final ValueChanged<CoupleGarde>? 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 PopupMenuButton<String>(
tooltip: 'Changer de garde',
offset: const Offset(0, 4), // Rapproché du bandeau principal
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)
// On ne réaffiche pas le couple actuellement sélectionné dans le menu déroulant
// puisqu'il est déjà visible en tant que "bouton" principal.
if (c.id != selected.id)
PopupMenuItem<String>(
value: c.id,
padding: EdgeInsets.zero,
// Marge verticale pour que les pastilles ne soient pas collées
child: Padding(
padding: const EdgeInsets.only(bottom: 8),
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'),
),
],
),
);
}
}
@@ -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<String> 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 =