diff --git a/bash.exe.stackdump b/bash.exe.stackdump new file mode 100644 index 0000000..f901366 --- /dev/null +++ b/bash.exe.stackdump @@ -0,0 +1,28 @@ +Stack trace: +Frame Function Args +0007FFFFAC10 00021005FEBA (000210285F48, 00021026AB6E, 000000000000, 0007FFFF9B10) msys-2.0.dll+0x1FEBA +0007FFFFAC10 0002100467F9 (000000000000, 000000000000, 000000000000, 0007FFFFAEE8) msys-2.0.dll+0x67F9 +0007FFFFAC10 000210046832 (000210285FF9, 0007FFFFAAC8, 000000000000, 000000000000) msys-2.0.dll+0x6832 +0007FFFFAC10 000210068F86 (000000000000, 000000000000, 000000000000, 000000000000) msys-2.0.dll+0x28F86 +0007FFFFAC10 0002100690B4 (0007FFFFAC20, 000000000000, 000000000000, 000000000000) msys-2.0.dll+0x290B4 +0007FFFFAEF0 00021006A49D (0007FFFFAC20, 000000000000, 000000000000, 000000000000) msys-2.0.dll+0x2A49D +End of stack trace +Loaded modules: +000100400000 bash.exe +7FFE2CD00000 ntdll.dll +7FFE2C1F0000 KERNEL32.DLL +7FFE29C90000 KERNELBASE.dll +7FFE2B8C0000 USER32.dll +7FFE2A090000 win32u.dll +7FFE2C410000 GDI32.dll +7FFE29730000 gdi32full.dll +7FFE29860000 msvcp_win.dll +7FFE2A930000 ucrtbase.dll +000210040000 msys-2.0.dll +7FFE2C350000 advapi32.dll +7FFE2B810000 msvcrt.dll +7FFE2CC10000 sechost.dll +7FFE2AB30000 RPCRT4.dll +7FFE28D10000 CRYPTBASE.DLL +7FFE29B50000 bcryptPrimitives.dll +7FFE2CAD0000 IMM32.DLL 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/assets/images/bg_lime_bandeau.png b/frontend/assets/images/bg_lime_bandeau.png new file mode 100644 index 0000000..86f859d Binary files /dev/null and b/frontend/assets/images/bg_lime_bandeau.png differ diff --git a/frontend/flutter_01.png b/frontend/flutter_01.png new file mode 100644 index 0000000..2d68927 Binary files /dev/null and b/frontend/flutter_01.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..b34aa00 --- /dev/null +++ b/frontend/lib/widgets/quotidien/couple_selector_bandeau.dart @@ -0,0 +1,380 @@ +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, + colorIndex: couples.indexOf(selected), + ); + + 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, + colorIndex: couples.indexOf(c), + ), + ), + ), + ], + 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; + final int colorIndex; + + const _CoupleCard({ + required this.mode, + required this.couple, + required this.showChevron, + this.selected = true, + this.isMenuItem = false, + required this.colorIndex, + }); + + @override + Widget build(BuildContext context) { + return Container( + height: 90, + decoration: BoxDecoration( + image: DecorationImage( + image: AssetImage(QuotidienTheme.bandeauColors[colorIndex % QuotidienTheme.bandeauColors.length]), + fit: BoxFit.fill, + ), + ), + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Row( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.only(right: 12), + child: _MembreTile( + photoUrl: couple.enfant.photoUrl, + name: (couple.enfant.prenom != null && couple.enfant.prenom!.isNotEmpty) + ? couple.enfant.prenom! + : couple.enfant.displayName(fallback: 'Enfant'), + fallbackIcon: Icons.child_care, + ), + ), + ), + Container( + width: 8, + height: 44, + decoration: const BoxDecoration( + image: DecorationImage( + image: AssetImage(QuotidienTheme.pencilLineVerticalAsset), + fit: BoxFit.contain, + ), + ), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.only(left: 12), + child: _MembreTile( + photoUrl: couple.am.photoUrl, + name: (couple.am.prenom != null && couple.am.prenom!.isNotEmpty) + ? couple.am.prenom! + : couple.am.displayName( + fallback: mode == CoupleBandeauMode.parent + ? 'Nounou' + : 'Parent', + ), + fallbackIcon: mode == CoupleBandeauMode.parent + ? Icons.volunteer_activism + : Icons.person_outline, + ), + ), + ), + SizedBox( + width: 28, + child: showChevron + ? const Icon( + Icons.keyboard_arrow_down, + color: QuotidienTheme.ink, + ) + : null, + ), + ], + ), + ); + } +} + +/// 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: 64), + const SizedBox(width: 12), + Expanded( + child: Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.merienda( + fontSize: 22, + 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 = diff --git a/scripts/gitea-list-open-issues-by-milestone.js b/scripts/gitea-list-open-issues-by-milestone.js new file mode 100644 index 0000000..85a56ac --- /dev/null +++ b/scripts/gitea-list-open-issues-by-milestone.js @@ -0,0 +1,144 @@ +/** + * Liste les issues Gitea ouvertes pour un milestone donné (ex. 0.1.0). + * Usage : node scripts/gitea-list-open-issues-by-milestone.js [milestone] + * Token : .gitea-token (racine), GITEA_TOKEN, ou docs/27_BRIEFING-FRONTEND.md + */ +const https = require('https'); +const fs = require('fs'); +const path = require('path'); + +const repoRoot = path.join(__dirname, '..'); +const REPO = 'jmartin/petitspas'; +const milestoneWanted = (process.argv[2] || '0.1.0').trim(); + +let token = process.env.GITEA_TOKEN; +if (!token) { + try { + const tokenFile = path.join(repoRoot, '.gitea-token'); + if (fs.existsSync(tokenFile)) token = fs.readFileSync(tokenFile, 'utf8').trim(); + } catch (_) {} +} +if (!token) { + try { + const briefing = fs.readFileSync( + path.join(repoRoot, 'docs/27_BRIEFING-FRONTEND.md'), + 'utf8', + ); + const m = briefing.match(/Token:\s*(giteabu_[a-f0-9]+)/); + if (m) token = m[1].trim(); + } catch (_) {} +} +if (!token) { + console.error('Token non trouvé : .gitea-token ou GITEA_TOKEN'); + process.exit(1); +} + +function getJson(apiPath) { + return new Promise((resolve, reject) => { + const opts = { + hostname: 'git.ptits-pas.fr', + path: `/api/v1/repos/${REPO}${apiPath}`, + method: 'GET', + headers: { + Authorization: 'token ' + token, + Accept: 'application/json', + }, + }; + const req = https.request(opts, (res) => { + let d = ''; + res.on('data', (c) => (d += c)); + res.on('end', () => { + if (res.statusCode !== 200) { + reject(new Error(`HTTP ${res.statusCode}: ${d.slice(0, 500)}`)); + return; + } + try { + resolve(JSON.parse(d)); + } catch (e) { + reject(e); + } + }); + }); + req.on('error', reject); + req.end(); + }); +} + +async function fetchAllOpenIssues() { + const out = []; + let page = 1; + const limit = 50; + for (;;) { + const qs = new URLSearchParams({ + state: 'open', + type: 'all', + page: String(page), + limit: String(limit), + }); + const batch = await getJson(`/issues?${qs}`); + if (!Array.isArray(batch) || batch.length === 0) break; + out.push(...batch); + if (batch.length < limit) break; + page += 1; + if (page > 40) break; + } + return out; +} + +function milestoneMatches(m, wanted) { + if (!m) return false; + const t = (m.title || '').trim(); + return t === wanted || t === `v${wanted}`; +} + +async function main() { + let milestones; + try { + milestones = await getJson('/milestones?state=all'); + } catch (e) { + milestones = []; + console.warn('Milestones non lisibles:', e.message); + } + + const known = Array.isArray(milestones) + ? milestones.map((m) => m.title).filter(Boolean) + : []; + if (known.length) { + console.log('Milestones connus sur le dépôt :', known.join(', ')); + } + + const issues = await fetchAllOpenIssues(); + const filtered = issues.filter((i) => milestoneMatches(i.milestone, milestoneWanted)); + + console.log(''); + console.log(`## Issues ouvertes — milestone « ${milestoneWanted} » (${filtered.length})`); + console.log(''); + if (filtered.length === 0) { + console.log( + 'Aucune issue ouverte avec ce milestone. Vérifier sur Gitea que les tickets ' + + '0.1.0 portent bien le milestone, ou élargir la requête.', + ); + console.log(''); + console.log(`(Total issues ouvertes sans filtre milestone : ${issues.length})`); + const withM = issues.filter((i) => i.milestone); + if (withM.length) { + console.log(''); + console.log('Issues ouvertes qui ont *un* milestone :'); + for (const i of withM) { + console.log(`- #${i.number} [${i.milestone.title}] ${i.title}`); + } + } + process.exit(0); + } + + for (const i of filtered.sort((a, b) => a.number - b.number)) { + const labels = (i.labels || []).map((l) => l.name).join(', '); + console.log(`- **#${i.number}** — ${i.title}`); + if (labels) console.log(` - Labels : ${labels}`); + } +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/tests/scripts/register-parent-bernard-test.mjs b/tests/scripts/register-parent-bernard-test.mjs new file mode 100644 index 0000000..c99b012 --- /dev/null +++ b/tests/scripts/register-parent-bernard-test.mjs @@ -0,0 +1,103 @@ +/** + * POST /api/v1/auth/register/parent — foyer mono-parent (1 parent + 1 enfant). + * Inspiré de register-parent-lecomte-test.mjs. + * Email : sophie.bernard@example.com + * + * Usage : node tests/scripts/register-parent-bernard-test.mjs [BASE_URL] + */ + +import fs from 'fs'; +import path from 'path'; +import https from 'https'; +import http from 'http'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const photosDir = path.join(__dirname, '..', 'ressources', 'photos'); + +function toDataUri(filePath) { + const buf = fs.readFileSync(filePath); + return `data:image/png;base64,${buf.toString('base64')}`; +} + +const presentationDossier = + "Je suis Sophie BERNARD, mère isolée de Jules. J'ai la garde complète de mon fils. " + + "Je recherche une assistante maternelle bienveillante à Bezons. " + + "Merci pour l'étude de notre dossier."; + +const body = { + email: 'sophie.bernard@example.com', + prenom: 'Sophie', + nom: 'BERNARD', + telephone: '0611223344', + adresse: '12 Rue des Lilas', + code_postal: '95870', + ville: 'Bezons', + // Pas de co-parent (mono-parent). + enfants: [ + { + prenom: 'Jules', + nom: 'BERNARD', + date_naissance: '2024-06-10', + genre: 'H', + // Réutilise une photo de test existante. + photo_base64: toDataUri(path.join(photosDir, 'lecomte-maxime.png')), + photo_filename: 'jules_bernard.png', + }, + ], + presentation_dossier: presentationDossier, + acceptation_cgu: true, + acceptation_privacy: true, +}; + +const json = JSON.stringify(body); +const baseArg = process.argv[2] || 'https://app.ptits-pas.fr'; +const base = new URL(baseArg.endsWith('/') ? baseArg.slice(0, -1) : baseArg); +const url = new URL('/api/v1/auth/register/parent', `${base.protocol}//${base.host}`); + +const opts = { + hostname: url.hostname, + port: url.port || (url.protocol === 'https:' ? 443 : 80), + path: url.pathname, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'Content-Length': Buffer.byteLength(json, 'utf8'), + }, +}; + +const lib = url.protocol === 'https:' ? https : http; + +console.error(`POST ${url.href} (payload ~${Math.round(json.length / 1024)} Ko)`); + +const req = lib.request(opts, (res) => { + let data = ''; + res.on('data', (c) => { + data += c; + }); + res.on('end', () => { + console.log('HTTP', res.statusCode); + try { + const j = JSON.parse(data); + console.log(JSON.stringify(j, null, 2)); + } catch { + console.log(data.slice(0, 4000)); + } + if (res.statusCode < 200 || res.statusCode >= 300) process.exit(1); + }); +}); + +req.on('error', (e) => { + console.error('Erreur réseau:', e.message); + process.exit(1); +}); + +req.setTimeout(120000, () => { + req.destroy(); + console.error('Timeout 120s'); + process.exit(1); +}); + +req.write(json); +req.end();