feat(front): selecteur couple enfant-nounou (#167)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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 |
@@ -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: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/models/user.dart';
|
||||||
import 'package:p_tits_pas/services/auth_service.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_shell.dart';
|
||||||
import 'package:p_tits_pas/widgets/quotidien/quotidien_theme.dart';
|
import 'package:p_tits_pas/widgets/quotidien/quotidien_theme.dart';
|
||||||
|
|
||||||
/// Tableau de bord parent — coquille 3 colonnes quotidien (#166).
|
/// 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.
|
/// Métier cartes / blog / messagerie : tickets C/D/E.
|
||||||
class ParentDashboardScreen extends StatefulWidget {
|
class ParentDashboardScreen extends StatefulWidget {
|
||||||
const ParentDashboardScreen({super.key});
|
const ParentDashboardScreen({super.key});
|
||||||
@@ -17,10 +21,16 @@ class _ParentDashboardScreenState extends State<ParentDashboardScreen> {
|
|||||||
QuotidienNavSection _section = QuotidienNavSection.liaison;
|
QuotidienNavSection _section = QuotidienNavSection.liaison;
|
||||||
AppUser? _user;
|
AppUser? _user;
|
||||||
|
|
||||||
|
List<CoupleGarde> _couples = const [];
|
||||||
|
String? _selectedCoupleId;
|
||||||
|
bool _couplesLoading = true;
|
||||||
|
String? _couplesError;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_loadUser();
|
_loadUser();
|
||||||
|
_loadCouples();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadUser() async {
|
Future<void> _loadUser() async {
|
||||||
@@ -28,6 +38,52 @@ class _ParentDashboardScreenState extends State<ParentDashboardScreen> {
|
|||||||
if (mounted) setState(() => _user = user);
|
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 {
|
String get _displayName {
|
||||||
final n = _user?.fullName.trim() ?? '';
|
final n = _user?.fullName.trim() ?? '';
|
||||||
if (n.isNotEmpty) return n;
|
if (n.isNotEmpty) return n;
|
||||||
@@ -52,11 +108,13 @@ class _ParentDashboardScreenState extends State<ParentDashboardScreen> {
|
|||||||
onProfileTap: () => _soon('Profil'),
|
onProfileTap: () => _soon('Profil'),
|
||||||
onSearchAmTap: () => _soon('Recherche AM'),
|
onSearchAmTap: () => _soon('Recherche AM'),
|
||||||
onSettingsTap: () => _soon('Paramètres'),
|
onSettingsTap: () => _soon('Paramètres'),
|
||||||
leftColumn: const QuotidienColumnPlaceholder(
|
leftColumn: _LeftColumn(
|
||||||
title: 'Cartes',
|
couples: _couples,
|
||||||
subtitle:
|
selectedCoupleId: _selectedCoupleId,
|
||||||
'Couple enfant–nounou et flux de cartes\n(à brancher — tickets #167 / #173).',
|
loading: _couplesLoading,
|
||||||
icon: Icons.style_outlined,
|
error: _couplesError,
|
||||||
|
onRetry: _loadCouples,
|
||||||
|
onCoupleSelected: _selectCouple,
|
||||||
),
|
),
|
||||||
centerColumn: const QuotidienColumnPlaceholder(
|
centerColumn: const QuotidienColumnPlaceholder(
|
||||||
title: 'Blog',
|
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';
|
static const String parents = '/parents';
|
||||||
/// Création dossier famille actif par le staff (#129) — body type register parent.
|
/// Création dossier famille actif par le staff (#129) — body type register parent.
|
||||||
static const String parentsDossier = '/parents/dossier';
|
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';
|
static const String assistantesMaternelles = '/assistantes-maternelles';
|
||||||
/// Création dossier AM actif par le staff (#156) — body type register AM.
|
/// Création dossier AM actif par le staff (#156) — body type register AM.
|
||||||
static const String assistantesMaternellesDossier =
|
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.
|
/// couleur charte par section ; l'inactive est la même, plus transparente.
|
||||||
static const double pillInactiveOpacity = 0.45;
|
static const double pillInactiveOpacity = 0.45;
|
||||||
static const String pillIvoryAsset = 'assets/images/bg_ivoire_pill.png';
|
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 pillYellowAsset = 'assets/images/bg_yellow_pill.png';
|
||||||
static const String pillPeachAsset = 'assets/images/bg_peach_pill.png';
|
static const String pillPeachAsset = 'assets/images/bg_peach_pill.png';
|
||||||
static const String pillTurquoiseAsset =
|
static const String pillTurquoiseAsset =
|
||||||
|
|||||||
Reference in New Issue
Block a user