Merge develop into master for #167
Co-authored-by: Cursor <cursoragent@cursor.com>
@@ -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
|
||||
|
After Width: | Height: | Size: 216 KiB |
|
After Width: | Height: | Size: 249 KiB |
|
After Width: | Height: | Size: 277 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 290 KiB |
|
After Width: | Height: | Size: 239 KiB |
|
After Width: | Height: | Size: 261 KiB |
|
After Width: | Height: | Size: 276 KiB |
@@ -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 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<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 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<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,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<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,
|
||||
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<String>(
|
||||
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<String>(
|
||||
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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||