release: squash merge develop → master
Quotidien / Epic C : feed bulles parent (#173), module Cartes (#194/#195), evenements_agenda (#205), couples garde AM (#169–#171), API absences (#172). Co-authored-by: Cursor <cursoragent@cursor.com>
@@ -2,7 +2,7 @@
|
||||
<application
|
||||
android:label="p_tits_pas"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
android:icon="@mipmap/launcher_icon">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
|
||||
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 741 KiB |
|
After Width: | Height: | Size: 692 KiB |
|
After Width: | Height: | Size: 785 KiB |
|
After Width: | Height: | Size: 195 KiB |
|
After Width: | Height: | Size: 231 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 289 KiB |
|
After Width: | Height: | Size: 291 KiB |
|
After Width: | Height: | Size: 365 KiB |
@@ -0,0 +1,58 @@
|
||||
class AbsenceGarde {
|
||||
final String id;
|
||||
final String idPlacement;
|
||||
final String type;
|
||||
final String dateDebut;
|
||||
final String dateFin;
|
||||
final String statut;
|
||||
final String expireAt;
|
||||
final String? creePar;
|
||||
final String? motif;
|
||||
final String? idEnfant;
|
||||
final String? prenomEnfant;
|
||||
final String? idAm;
|
||||
final String? prenomAm;
|
||||
final String? nomAm;
|
||||
final String creeLe;
|
||||
final String modifieLe;
|
||||
|
||||
AbsenceGarde({
|
||||
required this.id,
|
||||
required this.idPlacement,
|
||||
required this.type,
|
||||
required this.dateDebut,
|
||||
required this.dateFin,
|
||||
required this.statut,
|
||||
required this.expireAt,
|
||||
this.creePar,
|
||||
this.motif,
|
||||
this.idEnfant,
|
||||
this.prenomEnfant,
|
||||
this.idAm,
|
||||
this.prenomAm,
|
||||
this.nomAm,
|
||||
required this.creeLe,
|
||||
required this.modifieLe,
|
||||
});
|
||||
|
||||
factory AbsenceGarde.fromJson(Map<String, dynamic> json) {
|
||||
return AbsenceGarde(
|
||||
id: json['id'] ?? '',
|
||||
idPlacement: json['id_placement'] ?? '',
|
||||
type: json['type'] ?? '',
|
||||
dateDebut: json['date_debut'] ?? '',
|
||||
dateFin: json['date_fin'] ?? '',
|
||||
statut: json['statut'] ?? '',
|
||||
expireAt: json['expire_at'] ?? '',
|
||||
creePar: json['cree_par'],
|
||||
motif: json['motif'],
|
||||
idEnfant: json['id_enfant'],
|
||||
prenomEnfant: json['prenom_enfant'],
|
||||
idAm: json['id_am'],
|
||||
prenomAm: json['prenom_am'],
|
||||
nomAm: json['nom_am'],
|
||||
creeLe: json['cree_le'] ?? '',
|
||||
modifieLe: json['modifie_le'] ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,8 @@ enum CardColorVertical {
|
||||
lime('assets/cards/card_lime.png'),
|
||||
lavender('assets/cards/card_lavender.png'),
|
||||
green('assets/cards/card_green.png'),
|
||||
blue('assets/cards/card_blue.png');
|
||||
blue('assets/cards/card_blue.png'),
|
||||
aqua('assets/cards/card_aqua.png');
|
||||
|
||||
final String path;
|
||||
const CardColorVertical(this.path);
|
||||
@@ -18,8 +19,9 @@ enum CardColorHorizontal {
|
||||
lime('assets/cards/card_lime_h.png'),
|
||||
lavender('assets/cards/card_lavender_h.png'),
|
||||
green('assets/cards/card_green_h.png'),
|
||||
blue('assets/cards/card_blue_h.png');
|
||||
blue('assets/cards/card_blue_h.png'),
|
||||
aqua('assets/cards/card_aqua_h.png');
|
||||
|
||||
final String path;
|
||||
const CardColorHorizontal(this.path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import 'package:p_tits_pas/models/card_assets.dart';
|
||||
|
||||
/// Statut d'une instance de carte (file d'attention) — contrat #194 / #173.
|
||||
enum CarteStatut {
|
||||
ouverte('ouverte'),
|
||||
refusee('refusee'),
|
||||
traitee('traitee');
|
||||
|
||||
final String apiValue;
|
||||
const CarteStatut(this.apiValue);
|
||||
|
||||
static CarteStatut fromApi(String? raw) {
|
||||
switch (raw) {
|
||||
case 'refusee':
|
||||
return CarteStatut.refusee;
|
||||
case 'traitee':
|
||||
return CarteStatut.traitee;
|
||||
case 'ouverte':
|
||||
default:
|
||||
return CarteStatut.ouverte;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mode de réponse attendu selon le type SYSTEM.
|
||||
enum CarteResponseMode {
|
||||
none('none'),
|
||||
ack('ack'),
|
||||
acceptRefuse('accept_refuse');
|
||||
|
||||
final String apiValue;
|
||||
const CarteResponseMode(this.apiValue);
|
||||
|
||||
static CarteResponseMode fromApi(String? raw) {
|
||||
switch (raw) {
|
||||
case 'ack':
|
||||
return CarteResponseMode.ack;
|
||||
case 'accept_refuse':
|
||||
return CarteResponseMode.acceptRefuse;
|
||||
case 'none':
|
||||
default:
|
||||
return CarteResponseMode.none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bulle / carte de la file d'attention.
|
||||
class CarteBulle {
|
||||
final String id;
|
||||
final String typeCode;
|
||||
final String titre;
|
||||
final String idPlacement;
|
||||
final String? idAbsence;
|
||||
final String operation;
|
||||
final CarteStatut statut;
|
||||
final Map<String, dynamic> payload;
|
||||
final DateTime? purgeAt;
|
||||
final String? creePar;
|
||||
final bool isCreator;
|
||||
final CarteResponseMode responseMode;
|
||||
final String? lastRefuseComment;
|
||||
final DateTime? creeLe;
|
||||
final DateTime? modifieLe;
|
||||
|
||||
const CarteBulle({
|
||||
required this.id,
|
||||
required this.typeCode,
|
||||
required this.titre,
|
||||
required this.idPlacement,
|
||||
this.idAbsence,
|
||||
this.operation = 'create',
|
||||
required this.statut,
|
||||
this.payload = const {},
|
||||
this.purgeAt,
|
||||
this.creePar,
|
||||
this.isCreator = false,
|
||||
this.responseMode = CarteResponseMode.none,
|
||||
this.lastRefuseComment,
|
||||
this.creeLe,
|
||||
this.modifieLe,
|
||||
});
|
||||
|
||||
String? get dateDebut => payload['date_debut']?.toString();
|
||||
String? get dateFin => payload['date_fin']?.toString();
|
||||
String? get motif => payload['motif']?.toString();
|
||||
|
||||
/// Motif de refus : réponse API, sinon motif payload (co-parent / reload).
|
||||
String? get motifRefus {
|
||||
final fromResponse = lastRefuseComment?.trim();
|
||||
if (fromResponse != null && fromResponse.isNotEmpty) return fromResponse;
|
||||
final fromPayload = motif?.trim();
|
||||
if (fromPayload != null && fromPayload.isNotEmpty) return fromPayload;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Libellé UI (sémantique produit) — pas le titre brut API.
|
||||
String get titreAffiche {
|
||||
switch (typeCode) {
|
||||
case 'conge_am':
|
||||
return 'Demande de congés';
|
||||
case 'arret_maladie_am':
|
||||
return 'Arrêt maladie';
|
||||
case 'absence_enfant':
|
||||
case 'absence_enfant_modif':
|
||||
return 'Notification d’absence';
|
||||
case 'soin_enfant':
|
||||
return 'Soin / bobo';
|
||||
default:
|
||||
return titre;
|
||||
}
|
||||
}
|
||||
|
||||
bool get isArretMaladie => typeCode == 'arret_maladie_am';
|
||||
bool get isCongeAm => typeCode == 'conge_am';
|
||||
bool get isSoinEnfant => typeCode == 'soin_enfant';
|
||||
|
||||
/// Icône dessin (haut droite) selon le type de bulle.
|
||||
String? get iconAsset {
|
||||
switch (typeCode) {
|
||||
case 'conge_am':
|
||||
return 'assets/images/valise.png';
|
||||
case 'arret_maladie_am':
|
||||
return 'assets/images/thermo_croix.png';
|
||||
case 'absence_enfant':
|
||||
case 'absence_enfant_modif':
|
||||
return 'assets/images/sac_lapin.png';
|
||||
// soin_enfant : icône selon sous-type payload — à brancher plus tard
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Fin de période (jour inclus). Null si dates absentes / invalides.
|
||||
DateTime? get dateFinJour {
|
||||
final raw = dateFin;
|
||||
if (raw == null || raw.isEmpty) return null;
|
||||
final dt = DateTime.tryParse(raw);
|
||||
if (dt == null) return null;
|
||||
return DateTime(dt.year, dt.month, dt.day);
|
||||
}
|
||||
|
||||
/// Vrai tant que la date de fin n’est pas dépassée (jour courant inclus).
|
||||
bool get periodeEncoreActive {
|
||||
final fin = dateFinJour;
|
||||
if (fin == null) return true;
|
||||
final today = DateTime.now();
|
||||
final todayDay = DateTime(today.year, today.month, today.day);
|
||||
return !todayDay.isAfter(fin);
|
||||
}
|
||||
|
||||
/// Teinte pastel UI dérivée du `type_code` (pas de `couleur` API).
|
||||
/// Voir `docs/32_MINI-SPEC-BULLES-CARTES.md` §4.
|
||||
CardColorHorizontal get colorFamily {
|
||||
switch (typeCode) {
|
||||
case 'conge_am':
|
||||
return CardColorHorizontal.aqua;
|
||||
case 'arret_maladie_am':
|
||||
return CardColorHorizontal.blue;
|
||||
case 'absence_enfant':
|
||||
case 'absence_enfant_modif':
|
||||
return CardColorHorizontal.peach;
|
||||
case 'soin_enfant':
|
||||
return CardColorHorizontal.pink;
|
||||
default:
|
||||
return CardColorHorizontal.lavender;
|
||||
}
|
||||
}
|
||||
|
||||
/// Asset horizontal pastel (palette `assets/cards/` uniquement).
|
||||
String get backgroundAsset => colorFamily.path;
|
||||
|
||||
/// Le destinataire peut répondre (pas le créateur, carte ouverte).
|
||||
bool get peutRepondre =>
|
||||
!isCreator &&
|
||||
statut == CarteStatut.ouverte &&
|
||||
responseMode != CarteResponseMode.none;
|
||||
|
||||
factory CarteBulle.fromJson(Map<String, dynamic> json) {
|
||||
final rawPayload = json['payload'];
|
||||
return CarteBulle(
|
||||
id: (json['id'] ?? '').toString(),
|
||||
typeCode: (json['type_code'] ?? '').toString(),
|
||||
titre: (json['titre'] ?? json['type_code'] ?? 'Carte').toString(),
|
||||
idPlacement: (json['id_placement'] ?? '').toString(),
|
||||
idAbsence: json['id_absence']?.toString(),
|
||||
operation: (json['operation'] ?? 'create').toString(),
|
||||
statut: CarteStatut.fromApi(json['statut']?.toString()),
|
||||
payload: rawPayload is Map
|
||||
? Map<String, dynamic>.from(rawPayload)
|
||||
: const {},
|
||||
purgeAt: _parseDate(json['purge_at']),
|
||||
creePar: json['cree_par']?.toString(),
|
||||
isCreator: json['is_creator'] == true,
|
||||
responseMode: CarteResponseMode.fromApi(json['response_mode']?.toString()),
|
||||
lastRefuseComment: json['last_refuse_comment']?.toString(),
|
||||
creeLe: _parseDate(json['cree_le']),
|
||||
modifieLe: _parseDate(json['modifie_le']),
|
||||
);
|
||||
}
|
||||
|
||||
static DateTime? _parseDate(dynamic v) {
|
||||
if (v == null) return null;
|
||||
return DateTime.tryParse(v.toString());
|
||||
}
|
||||
}
|
||||
@@ -39,13 +39,15 @@ class CoupleMembre {
|
||||
class CoupleGarde {
|
||||
final String id;
|
||||
final CoupleMembre enfant;
|
||||
final CoupleMembre am;
|
||||
final CoupleMembre? am;
|
||||
final List<CoupleMembre> parents;
|
||||
final bool courant;
|
||||
|
||||
const CoupleGarde({
|
||||
required this.id,
|
||||
required this.enfant,
|
||||
required this.am,
|
||||
this.am,
|
||||
this.parents = const [],
|
||||
this.courant = false,
|
||||
});
|
||||
|
||||
@@ -55,9 +57,15 @@ class CoupleGarde {
|
||||
enfant: CoupleMembre.fromJson(
|
||||
Map<String, dynamic>.from(json['enfant'] ?? const {}),
|
||||
),
|
||||
am: CoupleMembre.fromJson(
|
||||
Map<String, dynamic>.from(json['am'] ?? const {}),
|
||||
),
|
||||
am: json['am'] != null
|
||||
? CoupleMembre.fromJson(Map<String, dynamic>.from(json['am']))
|
||||
: null,
|
||||
parents: json['parents'] != null
|
||||
? (json['parents'] as List)
|
||||
.whereType<Map>()
|
||||
.map((e) => CoupleMembre.fromJson(Map<String, dynamic>.from(e)))
|
||||
.toList()
|
||||
: const [],
|
||||
courant: json['courant'] == true,
|
||||
);
|
||||
}
|
||||
@@ -67,6 +75,7 @@ class CoupleGarde {
|
||||
id: id,
|
||||
enfant: enfant,
|
||||
am: am,
|
||||
parents: parents,
|
||||
courant: courant ?? this.courant,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
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/widgets/app_footer.dart';
|
||||
import 'package:p_tits_pas/widgets/dashboard/dashboard_bandeau.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';
|
||||
import 'package:p_tits_pas/screens/home/parent_screen/agenda_absences_stub.dart';
|
||||
|
||||
/// Dashboard assistante maternelle – page blanche avec bandeau générique.
|
||||
/// Contenu détaillé à venir.
|
||||
/// Dashboard assistante maternelle – coquille 3 colonnes quotidien (#169).
|
||||
/// Colonne gauche : sélecteur de couple enfant–parent(s) (#170).
|
||||
/// Métier cartes / blog / messagerie : tickets C/D/E.
|
||||
class AmDashboardScreen extends StatefulWidget {
|
||||
const AmDashboardScreen({super.key});
|
||||
|
||||
@@ -14,13 +19,19 @@ class AmDashboardScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _AmDashboardScreenState extends State<AmDashboardScreen> {
|
||||
int selectedTabIndex = 0;
|
||||
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,51 +39,150 @@ class _AmDashboardScreenState extends State<AmDashboardScreen> {
|
||||
if (mounted) setState(() => _user = user);
|
||||
}
|
||||
|
||||
Future<void> _loadCouples() async {
|
||||
setState(() {
|
||||
_couplesLoading = true;
|
||||
_couplesError = null;
|
||||
});
|
||||
try {
|
||||
final res = await CoupleGardeService.getAmCouplesGarde();
|
||||
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;
|
||||
setState(() => _selectedCoupleId = couple.id);
|
||||
try {
|
||||
final res = await CoupleGardeService.definirAmCoupleCourant(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 d'enfant : "
|
||||
'${e.toString().replaceFirst('Exception: ', '')}',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String get _displayName {
|
||||
final n = _user?.fullName.trim() ?? '';
|
||||
if (n.isNotEmpty) return n;
|
||||
final email = _user?.email.trim() ?? '';
|
||||
if (email.isNotEmpty) return email.split('@').first;
|
||||
return 'Assistante maternelle';
|
||||
}
|
||||
|
||||
void _soon(String label) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('$label — à venir')),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(60.0),
|
||||
child: DashboardBandeau(
|
||||
tabItems: const [
|
||||
DashboardTabItem(label: 'Mon tableau de bord'),
|
||||
DashboardTabItem(label: 'Paramètres'),
|
||||
],
|
||||
selectedTabIndex: selectedTabIndex,
|
||||
onTabSelected: (index) => setState(() => selectedTabIndex = index),
|
||||
userDisplayName: _user?.fullName.isNotEmpty == true
|
||||
? _user!.fullName
|
||||
: 'Assistante maternelle',
|
||||
userEmail: _user?.email,
|
||||
userRole: _user?.role,
|
||||
onProfileTap: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Modification du profil – à venir')),
|
||||
);
|
||||
},
|
||||
onSettingsTap: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Paramètres – à venir')),
|
||||
);
|
||||
},
|
||||
onLogout: () {},
|
||||
showLogoutConfirmation: true,
|
||||
),
|
||||
return QuotidienShell(
|
||||
selectedSection: _section,
|
||||
onSectionSelected: (s) => setState(() => _section = s),
|
||||
userDisplayName: _displayName,
|
||||
userEmail: _user?.email,
|
||||
onProfileTap: () => _soon('Profil'),
|
||||
onSettingsTap: () => _soon('Paramètres'),
|
||||
// L'AM n'a pas de bouton "Recherche AM" dans le bandeau
|
||||
onSearchAmTap: null,
|
||||
leftColumn: _LeftColumn(
|
||||
couples: _couples,
|
||||
selectedCoupleId: _selectedCoupleId,
|
||||
loading: _couplesLoading,
|
||||
error: _couplesError,
|
||||
onRetry: _loadCouples,
|
||||
onCoupleSelected: _selectCouple,
|
||||
),
|
||||
body: Column(
|
||||
centerColumn: const QuotidienColumnPlaceholder(
|
||||
title: 'Blog',
|
||||
subtitle:
|
||||
'Fil du quotidien (affichage par défaut)\n(à brancher — ticket #179).',
|
||||
icon: Icons.auto_stories_outlined,
|
||||
),
|
||||
rightColumn: const QuotidienColumnPlaceholder(
|
||||
title: 'Messagerie',
|
||||
subtitle:
|
||||
'Mess. Parents · Mess. RPE\n(à brancher — ticket #185).',
|
||||
icon: Icons.chat_bubble_outline,
|
||||
),
|
||||
agendaBody: AgendaAbsencesStub(placementId: _selectedCoupleId),
|
||||
contratBody: const QuotidienStubPage(
|
||||
title: 'Contrat',
|
||||
message: 'Contrat — contenu à venir (stub #187).',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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: [
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Dashboard AM – à venir',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
CoupleSelectorBandeau(
|
||||
mode: CoupleBandeauMode.assistanteMaternelle,
|
||||
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 #174).',
|
||||
icon: Icons.style_outlined,
|
||||
),
|
||||
),
|
||||
const AppFooter(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
|
||||
CardColorVertical.red,
|
||||
CardColorVertical.green,
|
||||
CardColorVertical.blue,
|
||||
CardColorVertical.aqua,
|
||||
];
|
||||
|
||||
// Garder une trace des couleurs déjà utilisées
|
||||
|
||||
@@ -3,13 +3,14 @@ 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/cartes_feed.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';
|
||||
import 'package:p_tits_pas/screens/home/parent_screen/agenda_absences_stub.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.
|
||||
/// Colonne gauche : sélecteur de couple (#167) + feed bulles (#173).
|
||||
class ParentDashboardScreen extends StatefulWidget {
|
||||
const ParentDashboardScreen({super.key});
|
||||
|
||||
@@ -62,7 +63,6 @@ class _ParentDashboardScreenState extends State<ParentDashboardScreen> {
|
||||
|
||||
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);
|
||||
@@ -115,6 +115,7 @@ class _ParentDashboardScreenState extends State<ParentDashboardScreen> {
|
||||
error: _couplesError,
|
||||
onRetry: _loadCouples,
|
||||
onCoupleSelected: _selectCouple,
|
||||
onAjouterBulle: () => _soon('Ajouter une bulle'),
|
||||
),
|
||||
centerColumn: const QuotidienColumnPlaceholder(
|
||||
title: 'Blog',
|
||||
@@ -128,10 +129,7 @@ class _ParentDashboardScreenState extends State<ParentDashboardScreen> {
|
||||
'Mess. AM · Mess. RPE\n(à brancher — ticket #184).',
|
||||
icon: Icons.chat_bubble_outline,
|
||||
),
|
||||
agendaBody: const QuotidienStubPage(
|
||||
title: 'Agenda',
|
||||
message: 'Agenda — contenu à venir (stub #187).',
|
||||
),
|
||||
agendaBody: AgendaAbsencesStub(placementId: _selectedCoupleId),
|
||||
contratBody: const QuotidienStubPage(
|
||||
title: 'Contrat',
|
||||
message: 'Contrat — contenu à venir (stub #187).',
|
||||
@@ -140,7 +138,7 @@ class _ParentDashboardScreenState extends State<ParentDashboardScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Colonne gauche : bandeau couple (#167) puis flux de cartes (#173 à venir).
|
||||
/// Colonne gauche : bandeau couple (#167) puis flux de cartes (#173).
|
||||
class _LeftColumn extends StatelessWidget {
|
||||
final List<CoupleGarde> couples;
|
||||
final String? selectedCoupleId;
|
||||
@@ -148,6 +146,7 @@ class _LeftColumn extends StatelessWidget {
|
||||
final String? error;
|
||||
final VoidCallback onRetry;
|
||||
final ValueChanged<CoupleGarde> onCoupleSelected;
|
||||
final VoidCallback onAjouterBulle;
|
||||
|
||||
const _LeftColumn({
|
||||
required this.couples,
|
||||
@@ -156,6 +155,7 @@ class _LeftColumn extends StatelessWidget {
|
||||
required this.error,
|
||||
required this.onRetry,
|
||||
required this.onCoupleSelected,
|
||||
required this.onAjouterBulle,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -174,12 +174,10 @@ class _LeftColumn extends StatelessWidget {
|
||||
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,
|
||||
Expanded(
|
||||
child: CartesFeed(
|
||||
placementId: selectedCoupleId,
|
||||
onAjouterBulle: onAjouterBulle,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:p_tits_pas/models/absence_garde.dart';
|
||||
import 'package:p_tits_pas/services/api/absences_garde_service.dart';
|
||||
import 'package:p_tits_pas/widgets/quotidien/quotidien_theme.dart';
|
||||
|
||||
class AgendaAbsencesStub extends StatefulWidget {
|
||||
final String? placementId;
|
||||
|
||||
const AgendaAbsencesStub({super.key, this.placementId});
|
||||
|
||||
@override
|
||||
State<AgendaAbsencesStub> createState() => _AgendaAbsencesStubState();
|
||||
}
|
||||
|
||||
class _AgendaAbsencesStubState extends State<AgendaAbsencesStub> {
|
||||
List<AbsenceGarde> _absences = [];
|
||||
bool _loading = true;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadAbsences();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant AgendaAbsencesStub oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.placementId != widget.placementId) {
|
||||
_loadAbsences();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadAbsences() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final absences = await AbsencesGardeService.getAbsences(
|
||||
placementId: widget.placementId,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_absences = absences;
|
||||
_loading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
color: QuotidienTheme.ivory,
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 600),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.calendar_month_outlined,
|
||||
size: 64,
|
||||
color: QuotidienTheme.peach,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
"Agenda (Stub) - Lignes d'absence",
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
color: QuotidienTheme.ink,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'ID Placement courant: ${widget.placementId ?? 'Tous'}',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: QuotidienTheme.muted),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Expanded(
|
||||
child: _buildList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildList() {
|
||||
if (_loading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (_error != null) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, color: Colors.red, size: 48),
|
||||
const SizedBox(height: 16),
|
||||
Text(_error!, style: const TextStyle(color: Colors.red)),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _loadAbsences,
|
||||
child: const Text('Réessayer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
if (_absences.isEmpty) {
|
||||
return const Center(
|
||||
child: Text('Aucune absence ou congé trouvé.',
|
||||
style: TextStyle(color: QuotidienTheme.muted)),
|
||||
);
|
||||
}
|
||||
return ListView.separated(
|
||||
itemCount: _absences.length,
|
||||
separatorBuilder: (_, __) => const Divider(),
|
||||
itemBuilder: (context, index) {
|
||||
final abs = _absences[index];
|
||||
return ListTile(
|
||||
leading: _getIcon(abs.type),
|
||||
title: Text('${abs.type} (${abs.statut})'),
|
||||
subtitle: Text(
|
||||
'Du ${abs.dateDebut} au ${abs.dateFin}\n'
|
||||
'Enfant: ${abs.prenomEnfant ?? 'N/A'}, AM: ${abs.prenomAm ?? 'N/A'}',
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.delete_outline, color: Colors.red),
|
||||
onPressed: () => _confirmDelete(abs),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Icon _getIcon(String type) {
|
||||
switch (type) {
|
||||
case 'absence_enfant':
|
||||
return const Icon(Icons.child_care, color: QuotidienTheme.coral);
|
||||
case 'conge_am':
|
||||
return const Icon(Icons.beach_access, color: QuotidienTheme.turquoise);
|
||||
case 'arret_maladie_am':
|
||||
return const Icon(Icons.medical_services, color: QuotidienTheme.coral);
|
||||
default:
|
||||
return const Icon(Icons.event);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete(AbsenceGarde abs) async {
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Supprimer ?'),
|
||||
content: Text("Supprimer l'absence ${abs.type} du ${abs.dateDebut} ?"),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: const Text('Supprimer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirm == true) {
|
||||
try {
|
||||
await AbsencesGardeService.supprimerAbsence(abs.id);
|
||||
_loadAbsences();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Erreur: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:p_tits_pas/models/absence_garde.dart';
|
||||
import 'package:p_tits_pas/services/api/api_config.dart';
|
||||
import 'package:p_tits_pas/services/api/tokenService.dart';
|
||||
|
||||
class AbsencesGardeService {
|
||||
static Future<List<AbsenceGarde>> getAbsences({String? placementId}) async {
|
||||
final token = await TokenService.getToken();
|
||||
final headers = token != null ? ApiConfig.authHeaders(token) : ApiConfig.headers;
|
||||
|
||||
final uri = Uri.parse(ApiConfig.baseUrl +
|
||||
'/evenements-agenda' +
|
||||
(placementId != null ? '?placementId=$placementId' : ''));
|
||||
|
||||
final res = await http.get(uri, headers: headers);
|
||||
if (res.statusCode == 200) {
|
||||
final json = jsonDecode(res.body);
|
||||
final List items = json['items'] ?? [];
|
||||
return items.map((e) => AbsenceGarde.fromJson(e)).toList();
|
||||
} else {
|
||||
throw Exception('Erreur de chargement des absences : ${res.statusCode}');
|
||||
}
|
||||
}
|
||||
|
||||
static Future<AbsenceGarde> creerAbsence({
|
||||
required String idPlacement,
|
||||
required String type,
|
||||
required String dateDebut,
|
||||
required String dateFin,
|
||||
String? motif,
|
||||
}) async {
|
||||
final token = await TokenService.getToken();
|
||||
final headers = token != null ? ApiConfig.authHeaders(token) : ApiConfig.headers;
|
||||
|
||||
final uri = Uri.parse('${ApiConfig.baseUrl}/evenements-agenda');
|
||||
final res = await http.post(
|
||||
uri,
|
||||
headers: headers,
|
||||
body: jsonEncode({
|
||||
'id_placement': idPlacement,
|
||||
'type': type,
|
||||
'date_debut': dateDebut,
|
||||
'date_fin': dateFin,
|
||||
if (motif != null) 'motif': motif,
|
||||
}),
|
||||
);
|
||||
if (res.statusCode == 201) {
|
||||
return AbsenceGarde.fromJson(jsonDecode(res.body));
|
||||
} else {
|
||||
throw Exception("Erreur de création d'absence : ${res.statusCode}");
|
||||
}
|
||||
}
|
||||
|
||||
static Future<AbsenceGarde> modifierAbsence(
|
||||
String id, {
|
||||
String? dateDebut,
|
||||
String? dateFin,
|
||||
String? statut,
|
||||
String? motif,
|
||||
}) async {
|
||||
final token = await TokenService.getToken();
|
||||
final headers = token != null ? ApiConfig.authHeaders(token) : ApiConfig.headers;
|
||||
|
||||
final uri = Uri.parse('${ApiConfig.baseUrl}/evenements-agenda/$id');
|
||||
final Map<String, dynamic> body = {};
|
||||
if (dateDebut != null) body['date_debut'] = dateDebut;
|
||||
if (dateFin != null) body['date_fin'] = dateFin;
|
||||
if (statut != null) body['statut'] = statut;
|
||||
if (motif != null) body['motif'] = motif;
|
||||
|
||||
final res = await http.patch(
|
||||
uri,
|
||||
headers: headers,
|
||||
body: jsonEncode(body),
|
||||
);
|
||||
if (res.statusCode == 200) {
|
||||
return AbsenceGarde.fromJson(jsonDecode(res.body));
|
||||
} else {
|
||||
throw Exception("Erreur de modification d'absence : ${res.statusCode}");
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> supprimerAbsence(String id) async {
|
||||
final token = await TokenService.getToken();
|
||||
final headers = token != null ? ApiConfig.authHeaders(token) : ApiConfig.headers;
|
||||
|
||||
final uri = Uri.parse('${ApiConfig.baseUrl}/evenements-agenda/$id');
|
||||
final res = await http.delete(uri, headers: headers);
|
||||
if (res.statusCode != 204) {
|
||||
throw Exception("Erreur de suppression d'absence : ${res.statusCode}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -67,7 +67,11 @@ class ApiConfig {
|
||||
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.
|
||||
/// Couples de garde de l'AM connectée (#170 / #171).
|
||||
static const String assistantesMaternellesCouplesGarde =
|
||||
'/assistantes-maternelles/me/couples-garde';
|
||||
static const String assistantesMaternellesCoupleGardeCourant =
|
||||
'/assistantes-maternelles/me/couples-garde/courant';
|
||||
static const String assistantesMaternellesDossier =
|
||||
'/assistantes-maternelles/dossier';
|
||||
static const String enfants = '/enfants';
|
||||
@@ -89,6 +93,10 @@ class ApiConfig {
|
||||
static const String conversations = '/conversations';
|
||||
static const String notifications = '/notifications';
|
||||
|
||||
/// File d'attention / bulles (#173 / #194).
|
||||
static const String cards = '/cards';
|
||||
static const String cardsStream = '/cards/stream';
|
||||
|
||||
// Headers
|
||||
static Map<String, String> get headers => {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -96,7 +104,8 @@ class ApiConfig {
|
||||
};
|
||||
|
||||
static Map<String, String> authHeaders(String token) => {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'Authorization': 'Bearer $token',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:p_tits_pas/models/carte_bulle.dart';
|
||||
import 'package:p_tits_pas/services/api/api_config.dart';
|
||||
import 'package:p_tits_pas/services/api/tokenService.dart';
|
||||
|
||||
/// Accès API Cartes (file d'attention) — tickets #173 / #194.
|
||||
/// - GET /cards?placementId=
|
||||
/// - POST /cards/:id/respond
|
||||
class CardsService {
|
||||
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;
|
||||
}
|
||||
|
||||
/// Feed de bulles pour l'utilisateur (filtré par couple / placement).
|
||||
static Future<List<CarteBulle>> lister({String? placementId}) async {
|
||||
final query = <String, String>{};
|
||||
if (placementId != null && placementId.isNotEmpty) {
|
||||
query['placementId'] = placementId;
|
||||
}
|
||||
final uri = Uri.parse(
|
||||
'${ApiConfig.baseUrl}${ApiConfig.cards}',
|
||||
).replace(queryParameters: query.isEmpty ? null : query);
|
||||
|
||||
final response = await http.get(uri, headers: await _headers());
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception(
|
||||
_extractError(response.body, 'Erreur chargement des cartes'),
|
||||
);
|
||||
}
|
||||
|
||||
final decoded = jsonDecode(response.body);
|
||||
final list = (decoded is Map ? decoded['items'] as List? : null) ?? const [];
|
||||
return list
|
||||
.whereType<Map>()
|
||||
.map((e) => CarteBulle.fromJson(Map<String, dynamic>.from(e)))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Répondre : accept | refuse | ack.
|
||||
static Future<CarteBulle> repondre({
|
||||
required String cardId,
|
||||
required String action,
|
||||
String? comment,
|
||||
}) async {
|
||||
final response = await http.post(
|
||||
Uri.parse(
|
||||
'${ApiConfig.baseUrl}${ApiConfig.cards}/$cardId/respond',
|
||||
),
|
||||
headers: await _headers(),
|
||||
body: jsonEncode({
|
||||
'action': action,
|
||||
if (comment != null && comment.trim().isNotEmpty)
|
||||
'comment': comment.trim(),
|
||||
}),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception(
|
||||
_extractError(response.body, 'Erreur lors de la réponse'),
|
||||
);
|
||||
}
|
||||
|
||||
final decoded = jsonDecode(response.body);
|
||||
return CarteBulle.fromJson(Map<String, dynamic>.from(decoded as Map));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:p_tits_pas/models/carte_bulle.dart';
|
||||
import 'package:p_tits_pas/services/api/api_config.dart';
|
||||
import 'package:p_tits_pas/services/api/tokenService.dart';
|
||||
|
||||
import 'cards_stream_connector_stub.dart'
|
||||
if (dart.library.html) 'cards_stream_connector_web.dart'
|
||||
if (dart.library.io) 'cards_stream_connector_io.dart';
|
||||
|
||||
/// Événement temps réel file d'attention — ticket #195.
|
||||
class CardsStreamEvent {
|
||||
final String type;
|
||||
final String? cardId;
|
||||
final CarteBulle? card;
|
||||
|
||||
const CardsStreamEvent({
|
||||
required this.type,
|
||||
this.cardId,
|
||||
this.card,
|
||||
});
|
||||
}
|
||||
|
||||
/// Client SSE `GET /cards/stream` avec reconnexion.
|
||||
class CardsStreamClient {
|
||||
final void Function(CardsStreamEvent event) onEvent;
|
||||
final VoidCallback? onReconnectSync;
|
||||
|
||||
CardsStreamClient({
|
||||
required this.onEvent,
|
||||
this.onReconnectSync,
|
||||
});
|
||||
|
||||
final CardsStreamConnector _connector = CardsStreamConnector();
|
||||
bool _started = false;
|
||||
bool _stopRequested = false;
|
||||
int _attempt = 0;
|
||||
Timer? _retryTimer;
|
||||
bool _hadConnection = false;
|
||||
|
||||
void start() {
|
||||
if (_started) return;
|
||||
_started = true;
|
||||
_stopRequested = false;
|
||||
_attempt = 0;
|
||||
unawaited(_connect());
|
||||
}
|
||||
|
||||
void stop() {
|
||||
_stopRequested = true;
|
||||
_started = false;
|
||||
_retryTimer?.cancel();
|
||||
_retryTimer = null;
|
||||
_connector.close();
|
||||
}
|
||||
|
||||
Future<void> _connect() async {
|
||||
if (_stopRequested) return;
|
||||
|
||||
final token = await TokenService.getToken();
|
||||
if (token == null || token.isEmpty) {
|
||||
_scheduleRetry();
|
||||
return;
|
||||
}
|
||||
|
||||
final base =
|
||||
'${ApiConfig.baseUrl}${ApiConfig.cardsStream}'.replaceAll(
|
||||
RegExp(r'/+$'),
|
||||
'',
|
||||
);
|
||||
|
||||
// Web (EventSource) : token en query. IO : Bearer dans le connecteur.
|
||||
final url = kIsWeb
|
||||
? Uri.parse(base).replace(
|
||||
queryParameters: {'access_token': token},
|
||||
).toString()
|
||||
: base;
|
||||
|
||||
await _connector.connect(
|
||||
url: url,
|
||||
bearerToken: kIsWeb ? null : token,
|
||||
onEvent: _handleRaw,
|
||||
onError: (_) {
|
||||
if (_stopRequested) return;
|
||||
_connector.close();
|
||||
_scheduleRetry();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _handleRaw(String eventType, String data) {
|
||||
if (_stopRequested) return;
|
||||
|
||||
// Premier événement (souvent heartbeat) = connexion OK.
|
||||
if (!_hadConnection) {
|
||||
_hadConnection = true;
|
||||
_attempt = 0;
|
||||
} else if (eventType == 'heartbeat') {
|
||||
_attempt = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventType == 'heartbeat') return;
|
||||
|
||||
try {
|
||||
final decoded = jsonDecode(data);
|
||||
if (decoded is! Map) return;
|
||||
final map = Map<String, dynamic>.from(decoded);
|
||||
final type = (map['event']?.toString().isNotEmpty == true)
|
||||
? map['event'].toString()
|
||||
: eventType;
|
||||
final cardId = map['card_id']?.toString();
|
||||
final payload = map['data'];
|
||||
|
||||
CarteBulle? card;
|
||||
if (type == 'response.added' && payload is Map) {
|
||||
final nested = payload['card'];
|
||||
if (nested is Map) {
|
||||
card = CarteBulle.fromJson(Map<String, dynamic>.from(nested));
|
||||
}
|
||||
} else if ((type == 'card.created' || type == 'card.updated') &&
|
||||
payload is Map) {
|
||||
card = CarteBulle.fromJson(Map<String, dynamic>.from(payload));
|
||||
}
|
||||
|
||||
onEvent(
|
||||
CardsStreamEvent(
|
||||
type: type,
|
||||
cardId: cardId ?? card?.id,
|
||||
card: card,
|
||||
),
|
||||
);
|
||||
} catch (_) {
|
||||
// Événement mal formé : ignorer.
|
||||
}
|
||||
}
|
||||
|
||||
void _scheduleRetry() {
|
||||
if (_stopRequested) return;
|
||||
_retryTimer?.cancel();
|
||||
final delaySec = (1 << _attempt.clamp(0, 4)).clamp(1, 30);
|
||||
_attempt++;
|
||||
_retryTimer = Timer(Duration(seconds: delaySec), () {
|
||||
if (_stopRequested) return;
|
||||
if (_hadConnection) {
|
||||
// Resync REST après coupure pour ne pas manquer d'événements.
|
||||
onReconnectSync?.call();
|
||||
}
|
||||
unawaited(_connect());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
/// Connecteur SSE via HTTP stream (IO / mobile) — ticket #195.
|
||||
class CardsStreamConnector {
|
||||
http.Client? _client;
|
||||
StreamSubscription<List<int>>? _sub;
|
||||
bool _closed = false;
|
||||
|
||||
Future<void> connect({
|
||||
required String url,
|
||||
String? bearerToken,
|
||||
required void Function(String eventType, String data) onEvent,
|
||||
required void Function(Object error) onError,
|
||||
}) async {
|
||||
close();
|
||||
_closed = false;
|
||||
final client = http.Client();
|
||||
_client = client;
|
||||
|
||||
try {
|
||||
final request = http.Request('GET', Uri.parse(url));
|
||||
request.headers['Accept'] = 'text/event-stream';
|
||||
request.headers['Cache-Control'] = 'no-cache';
|
||||
if (bearerToken != null && bearerToken.isNotEmpty) {
|
||||
request.headers['Authorization'] = 'Bearer $bearerToken';
|
||||
}
|
||||
|
||||
final response = await client.send(request);
|
||||
if (response.statusCode != 200) {
|
||||
onError(
|
||||
StateError('SSE cards HTTP ${response.statusCode}'),
|
||||
);
|
||||
close();
|
||||
return;
|
||||
}
|
||||
|
||||
String? eventType;
|
||||
final dataBuf = StringBuffer();
|
||||
final lineBuf = StringBuffer();
|
||||
|
||||
void flushEvent() {
|
||||
if (dataBuf.isEmpty) {
|
||||
eventType = null;
|
||||
return;
|
||||
}
|
||||
onEvent(eventType ?? 'message', dataBuf.toString());
|
||||
dataBuf.clear();
|
||||
eventType = null;
|
||||
}
|
||||
|
||||
void handleLine(String line) {
|
||||
if (line.isEmpty) {
|
||||
flushEvent();
|
||||
return;
|
||||
}
|
||||
if (line.startsWith(':')) return;
|
||||
if (line.startsWith('event:')) {
|
||||
eventType = line.substring(6).trim();
|
||||
return;
|
||||
}
|
||||
if (line.startsWith('data:')) {
|
||||
final payload = line.length > 5 ? line.substring(5) : '';
|
||||
final trimmed = payload.startsWith(' ')
|
||||
? payload.substring(1)
|
||||
: payload;
|
||||
if (dataBuf.isNotEmpty) dataBuf.write('\n');
|
||||
dataBuf.write(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
_sub = response.stream.listen(
|
||||
(chunk) {
|
||||
if (_closed) return;
|
||||
lineBuf.write(utf8.decode(chunk, allowMalformed: true));
|
||||
var content = lineBuf.toString();
|
||||
var idx = content.indexOf('\n');
|
||||
while (idx >= 0) {
|
||||
var line = content.substring(0, idx);
|
||||
if (line.endsWith('\r')) {
|
||||
line = line.substring(0, line.length - 1);
|
||||
}
|
||||
handleLine(line);
|
||||
content = content.substring(idx + 1);
|
||||
idx = content.indexOf('\n');
|
||||
}
|
||||
lineBuf
|
||||
..clear()
|
||||
..write(content);
|
||||
},
|
||||
onError: (Object e) {
|
||||
if (!_closed) onError(e);
|
||||
},
|
||||
onDone: () {
|
||||
if (!_closed) onError(StateError('SSE cards fermé'));
|
||||
},
|
||||
cancelOnError: true,
|
||||
);
|
||||
} catch (e) {
|
||||
if (!_closed) onError(e);
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
void close() {
|
||||
_closed = true;
|
||||
_sub?.cancel();
|
||||
_sub = null;
|
||||
_client?.close();
|
||||
_client = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import 'dart:async';
|
||||
|
||||
/// Connecteur SSE — stub (tests / plateformes sans impl).
|
||||
class CardsStreamConnector {
|
||||
Future<void> connect({
|
||||
required String url,
|
||||
String? bearerToken,
|
||||
required void Function(String eventType, String data) onEvent,
|
||||
required void Function(Object error) onError,
|
||||
}) async {}
|
||||
|
||||
void close() {}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Connecteur web uniquement (import conditionnel).
|
||||
// ignore_for_file: avoid_web_libraries_in_flutter, deprecated_member_use
|
||||
|
||||
import 'dart:html' as html;
|
||||
|
||||
/// Connecteur SSE navigateur (EventSource) — ticket #195.
|
||||
/// Auth via `?access_token=` (EventSource ne permet pas Authorization).
|
||||
class CardsStreamConnector {
|
||||
html.EventSource? _source;
|
||||
final Map<String, html.EventListener> _listeners = {};
|
||||
|
||||
Future<void> connect({
|
||||
required String url,
|
||||
String? bearerToken,
|
||||
required void Function(String eventType, String data) onEvent,
|
||||
required void Function(Object error) onError,
|
||||
}) async {
|
||||
close();
|
||||
final source = html.EventSource(url);
|
||||
_source = source;
|
||||
|
||||
const names = [
|
||||
'card.created',
|
||||
'card.updated',
|
||||
'card.deleted',
|
||||
'response.added',
|
||||
'heartbeat',
|
||||
];
|
||||
for (final name in names) {
|
||||
void listener(html.Event raw) {
|
||||
final msg = raw as html.MessageEvent;
|
||||
final data = msg.data?.toString() ?? '';
|
||||
if (data.isEmpty) return;
|
||||
onEvent(name, data);
|
||||
}
|
||||
|
||||
_listeners[name] = listener;
|
||||
source.addEventListener(name, listener);
|
||||
}
|
||||
|
||||
void onErr(html.Event _) {
|
||||
onError(StateError('SSE cards déconnecté'));
|
||||
}
|
||||
|
||||
_listeners['error'] = onErr;
|
||||
source.addEventListener('error', onErr);
|
||||
}
|
||||
|
||||
void close() {
|
||||
final source = _source;
|
||||
if (source != null) {
|
||||
_listeners.forEach((name, listener) {
|
||||
source.removeEventListener(name, listener);
|
||||
});
|
||||
source.close();
|
||||
}
|
||||
_listeners.clear();
|
||||
_source = null;
|
||||
}
|
||||
}
|
||||
@@ -383,6 +383,13 @@ class AuthService {
|
||||
}
|
||||
|
||||
/// Récupère l'utilisateur connecté depuis le cache
|
||||
static const String tokenKey = 'auth_token';
|
||||
|
||||
static Future<String?> getToken() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(tokenKey);
|
||||
}
|
||||
|
||||
static Future<AppUser?> getCurrentUser() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final userJson = prefs.getString(_currentUserKey);
|
||||
|
||||
@@ -73,4 +73,45 @@ class CoupleGardeService {
|
||||
Map<String, dynamic>.from(decoded as Map),
|
||||
);
|
||||
}
|
||||
|
||||
/// Liste les couples de garde et le couple courant de l'AM connectée.
|
||||
static Future<CouplesGardeResponse> getAmCouplesGarde() async {
|
||||
final response = await http.get(
|
||||
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.assistantesMaternellesCouplesGarde}'),
|
||||
headers: await _headers(),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception(
|
||||
_extractError(response.body, 'Erreur chargement des couples de garde (AM)'),
|
||||
);
|
||||
}
|
||||
|
||||
final decoded = jsonDecode(response.body);
|
||||
return CouplesGardeResponse.fromJson(
|
||||
Map<String, dynamic>.from(decoded as Map),
|
||||
);
|
||||
}
|
||||
|
||||
/// Persiste le couple courant (AM) et renvoie la liste à jour.
|
||||
static Future<CouplesGardeResponse> definirAmCoupleCourant(
|
||||
String coupleId,
|
||||
) async {
|
||||
final response = await http.put(
|
||||
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.assistantesMaternellesCoupleGardeCourant}'),
|
||||
headers: await _headers(),
|
||||
body: jsonEncode({'couple_id': coupleId}),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception(
|
||||
_extractError(response.body, 'Erreur sélection du couple de garde (AM)'),
|
||||
);
|
||||
}
|
||||
|
||||
final decoded = jsonDecode(response.body);
|
||||
return CouplesGardeResponse.fromJson(
|
||||
Map<String, dynamic>.from(decoded as Map),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ class RepriseMapper {
|
||||
CardColorVertical.red,
|
||||
CardColorVertical.green,
|
||||
CardColorVertical.blue,
|
||||
CardColorVertical.aqua,
|
||||
];
|
||||
|
||||
static ParentData parentFromDossier(ParentDossier p) {
|
||||
|
||||
@@ -185,6 +185,8 @@ class BaseFormScreen extends StatelessWidget {
|
||||
return CardColorVertical.pink.path;
|
||||
case CardColorHorizontal.red:
|
||||
return CardColorVertical.red.path;
|
||||
case CardColorHorizontal.aqua:
|
||||
return CardColorVertical.aqua.path;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -205,9 +205,14 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
|
||||
);
|
||||
}
|
||||
|
||||
final Color baseCardColorForShadow = widget.childData.cardColor == CardColorVertical.lavender
|
||||
? Colors.purple.shade200
|
||||
: (widget.childData.cardColor == CardColorVertical.pink ? Colors.pink.shade200 : Colors.grey.shade200);
|
||||
final Color baseCardColorForShadow =
|
||||
widget.childData.cardColor == CardColorVertical.lavender
|
||||
? Colors.purple.shade200
|
||||
: (widget.childData.cardColor == CardColorVertical.pink
|
||||
? Colors.pink.shade200
|
||||
: (widget.childData.cardColor == CardColorVertical.aqua
|
||||
? Colors.teal.shade200
|
||||
: Colors.grey.shade200));
|
||||
final double photoSide = 200.0 * (config.isMobile ? 0.8 : 1.0);
|
||||
final double editableCardWidth = config.isMobile
|
||||
? double.infinity
|
||||
@@ -340,6 +345,7 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
|
||||
|
||||
// Mapping manuel simple
|
||||
if (widget.childData.cardColor.path.contains('lavender')) horizontalCardAsset = CardColorHorizontal.lavender.path;
|
||||
else if (widget.childData.cardColor.path.contains('aqua')) horizontalCardAsset = CardColorHorizontal.aqua.path;
|
||||
else if (widget.childData.cardColor.path.contains('blue')) horizontalCardAsset = CardColorHorizontal.blue.path;
|
||||
else if (widget.childData.cardColor.path.contains('green')) horizontalCardAsset = CardColorHorizontal.green.path;
|
||||
else if (widget.childData.cardColor.path.contains('lime')) horizontalCardAsset = CardColorHorizontal.lime.path;
|
||||
|
||||
@@ -1302,6 +1302,8 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
|
||||
return CardColorVertical.pink.path;
|
||||
case CardColorHorizontal.red:
|
||||
return CardColorVertical.red.path;
|
||||
case CardColorHorizontal.aqua:
|
||||
return CardColorVertical.aqua.path;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -642,6 +642,8 @@ class _PresentationFormScreenState extends State<PresentationFormScreen> {
|
||||
return CardColorVertical.pink.path;
|
||||
case CardColorHorizontal.red:
|
||||
return CardColorVertical.red.path;
|
||||
case CardColorHorizontal.aqua:
|
||||
return CardColorVertical.aqua.path;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1196,6 +1196,8 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
|
||||
return CardColorVertical.pink.path;
|
||||
case CardColorHorizontal.red:
|
||||
return CardColorVertical.red.path;
|
||||
case CardColorHorizontal.aqua:
|
||||
return CardColorVertical.aqua.path;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Mode d’extension du fond pastel.
|
||||
enum CarteStretch {
|
||||
/// 9 zones (bulles / cartes content).
|
||||
both,
|
||||
|
||||
/// Uniquement horizontal — bouts en demi-cercle (bandeau couple).
|
||||
horizontal,
|
||||
}
|
||||
|
||||
/// Fond pastel extensible (capacité par défaut des « cartes » UI).
|
||||
///
|
||||
/// Coins / bouts à taille logique fixe ; le reste s’étire selon [stretch].
|
||||
class Carte extends StatefulWidget {
|
||||
final String assetPath;
|
||||
final CarteStretch stretch;
|
||||
final Rect centerSlice;
|
||||
final double cornerWidth;
|
||||
final double cornerHeight;
|
||||
final Widget? child;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
final BoxConstraints? constraints;
|
||||
final double? width;
|
||||
final double? height;
|
||||
|
||||
/// Carte / bulle content (`card_*_h.png`, ~1024×512).
|
||||
const Carte({
|
||||
super.key,
|
||||
required this.assetPath,
|
||||
this.child,
|
||||
this.padding,
|
||||
this.constraints,
|
||||
this.width,
|
||||
this.height,
|
||||
this.stretch = CarteStretch.both,
|
||||
this.centerSlice = const Rect.fromLTRB(160, 90, 864, 422),
|
||||
this.cornerWidth = 56,
|
||||
this.cornerHeight = 40,
|
||||
});
|
||||
|
||||
/// Bandeau couple enfant / nounou (`bandeau_*.png`, ~1024×160).
|
||||
/// Étirement horizontal seul pour garder les bouts ronds.
|
||||
const Carte.bandeau({
|
||||
super.key,
|
||||
required this.assetPath,
|
||||
this.child,
|
||||
this.padding,
|
||||
this.constraints,
|
||||
this.width,
|
||||
this.height,
|
||||
}) : stretch = CarteStretch.horizontal,
|
||||
// Demi-cercle source ≈ moitié de la hauteur (160/2 = 80).
|
||||
centerSlice = const Rect.fromLTRB(80, 0, 944, 160),
|
||||
cornerWidth = 45,
|
||||
cornerHeight = 45;
|
||||
|
||||
@override
|
||||
State<Carte> createState() => _CarteState();
|
||||
}
|
||||
|
||||
class _CarteImageCache {
|
||||
static final Map<String, Future<ui.Image>> _futures = {};
|
||||
|
||||
static Future<ui.Image> load(String path) {
|
||||
return _futures.putIfAbsent(path, () async {
|
||||
final data = await rootBundle.load(path);
|
||||
final codec = await ui.instantiateImageCodec(
|
||||
data.buffer.asUint8List(),
|
||||
);
|
||||
final frame = await codec.getNextFrame();
|
||||
return frame.image;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _CarteState extends State<Carte> {
|
||||
ui.Image? _image;
|
||||
String? _loadedPath;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant Carte oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.assetPath != widget.assetPath) {
|
||||
_load();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final path = widget.assetPath;
|
||||
final image = await _CarteImageCache.load(path);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_image = image;
|
||||
_loadedPath = path;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final img = _image;
|
||||
final ready = img != null && _loadedPath == widget.assetPath;
|
||||
|
||||
return SizedBox(
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
child: Container(
|
||||
constraints: widget.constraints,
|
||||
child: CustomPaint(
|
||||
painter: ready
|
||||
? _CartePainter(
|
||||
image: img!,
|
||||
stretch: widget.stretch,
|
||||
centerSlice: widget.centerSlice,
|
||||
cornerWidth: widget.cornerWidth,
|
||||
cornerHeight: widget.cornerHeight,
|
||||
)
|
||||
: null,
|
||||
child: Padding(
|
||||
padding: widget.padding ?? EdgeInsets.zero,
|
||||
child: widget.child,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CartePainter extends CustomPainter {
|
||||
final ui.Image image;
|
||||
final CarteStretch stretch;
|
||||
final Rect centerSlice;
|
||||
final double cornerWidth;
|
||||
final double cornerHeight;
|
||||
|
||||
_CartePainter({
|
||||
required this.image,
|
||||
required this.stretch,
|
||||
required this.centerSlice,
|
||||
required this.cornerWidth,
|
||||
required this.cornerHeight,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
if (size.width <= 0 || size.height <= 0) return;
|
||||
|
||||
final paint = Paint()..filterQuality = FilterQuality.high;
|
||||
final srcW = image.width.toDouble();
|
||||
final srcH = image.height.toDouble();
|
||||
|
||||
void draw(Rect src, Rect dst) {
|
||||
if (src.width <= 0 || src.height <= 0) return;
|
||||
if (dst.width <= 0 || dst.height <= 0) return;
|
||||
canvas.drawImageRect(image, src, dst, paint);
|
||||
}
|
||||
|
||||
if (stretch == CarteStretch.horizontal) {
|
||||
// 3 bandes pleine hauteur : bout | milieu | bout (pilule).
|
||||
// Largeur du bout = moitié de la hauteur → demi-cercle propre.
|
||||
final tip = (size.height / 2).clamp(1.0, size.width / 2);
|
||||
final srcTip = centerSlice.left.clamp(1.0, srcW / 2);
|
||||
final midW = (size.width - tip * 2).clamp(0.0, size.width);
|
||||
|
||||
draw(
|
||||
Rect.fromLTWH(0, 0, srcTip, srcH),
|
||||
Rect.fromLTWH(0, 0, tip, size.height),
|
||||
);
|
||||
draw(
|
||||
Rect.fromLTWH(srcTip, 0, (srcW - srcTip * 2).clamp(1.0, srcW), srcH),
|
||||
Rect.fromLTWH(tip, 0, midW, size.height),
|
||||
);
|
||||
draw(
|
||||
Rect.fromLTWH(srcW - srcTip, 0, srcTip, srcH),
|
||||
Rect.fromLTWH(tip + midW, 0, tip, size.height),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 9 zones (bulles).
|
||||
final srcLeft = centerSlice.left.clamp(0.0, srcW);
|
||||
final srcTop = centerSlice.top.clamp(0.0, srcH);
|
||||
final srcRight = centerSlice.right.clamp(0.0, srcW);
|
||||
final srcBottom = centerSlice.bottom.clamp(0.0, srcH);
|
||||
|
||||
final srcLeftW = srcLeft;
|
||||
final srcTopH = srcTop;
|
||||
final srcRightW = srcW - srcRight;
|
||||
final srcBottomH = srcH - srcBottom;
|
||||
final srcCenterW = (srcRight - srcLeft).clamp(1.0, srcW);
|
||||
final srcCenterH = (srcBottom - srcTop).clamp(1.0, srcH);
|
||||
|
||||
final dstLeftW = cornerWidth.clamp(1.0, size.width / 2);
|
||||
final dstRightW = cornerWidth.clamp(1.0, size.width / 2);
|
||||
final dstTopH = cornerHeight.clamp(1.0, size.height / 2);
|
||||
final dstBottomH = cornerHeight.clamp(1.0, size.height / 2);
|
||||
final dstCenterW =
|
||||
(size.width - dstLeftW - dstRightW).clamp(0.0, size.width);
|
||||
final dstCenterH =
|
||||
(size.height - dstTopH - dstBottomH).clamp(0.0, size.height);
|
||||
|
||||
// Haut
|
||||
draw(Rect.fromLTWH(0, 0, srcLeftW, srcTopH),
|
||||
Rect.fromLTWH(0, 0, dstLeftW, dstTopH));
|
||||
draw(Rect.fromLTWH(srcLeft, 0, srcCenterW, srcTopH),
|
||||
Rect.fromLTWH(dstLeftW, 0, dstCenterW, dstTopH));
|
||||
draw(Rect.fromLTWH(srcRight, 0, srcRightW, srcTopH),
|
||||
Rect.fromLTWH(dstLeftW + dstCenterW, 0, dstRightW, dstTopH));
|
||||
|
||||
// Milieu
|
||||
draw(Rect.fromLTWH(0, srcTop, srcLeftW, srcCenterH),
|
||||
Rect.fromLTWH(0, dstTopH, dstLeftW, dstCenterH));
|
||||
draw(Rect.fromLTWH(srcLeft, srcTop, srcCenterW, srcCenterH),
|
||||
Rect.fromLTWH(dstLeftW, dstTopH, dstCenterW, dstCenterH));
|
||||
draw(Rect.fromLTWH(srcRight, srcTop, srcRightW, srcCenterH),
|
||||
Rect.fromLTWH(dstLeftW + dstCenterW, dstTopH, dstRightW, dstCenterH));
|
||||
|
||||
// Bas
|
||||
draw(Rect.fromLTWH(0, srcBottom, srcLeftW, srcBottomH),
|
||||
Rect.fromLTWH(0, dstTopH + dstCenterH, dstLeftW, dstBottomH));
|
||||
draw(Rect.fromLTWH(srcLeft, srcBottom, srcCenterW, srcBottomH),
|
||||
Rect.fromLTWH(dstLeftW, dstTopH + dstCenterH, dstCenterW, dstBottomH));
|
||||
draw(
|
||||
Rect.fromLTWH(srcRight, srcBottom, srcRightW, srcBottomH),
|
||||
Rect.fromLTWH(
|
||||
dstLeftW + dstCenterW,
|
||||
dstTopH + dstCenterH,
|
||||
dstRightW,
|
||||
dstBottomH,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _CartePainter oldDelegate) {
|
||||
return oldDelegate.image != image ||
|
||||
oldDelegate.stretch != stretch ||
|
||||
oldDelegate.centerSlice != centerSlice ||
|
||||
oldDelegate.cornerWidth != cornerWidth ||
|
||||
oldDelegate.cornerHeight != cornerHeight;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ 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/carte.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
|
||||
@@ -37,7 +38,7 @@ class CoupleSelectorBandeau extends StatelessWidget {
|
||||
|
||||
CoupleGarde? get _selected {
|
||||
if (couples.isEmpty) return null;
|
||||
if (selectedCoupleId != null) {
|
||||
if (selectedCoupleId != null) {
|
||||
for (final c in couples) {
|
||||
if (c.id == selectedCoupleId) return c;
|
||||
}
|
||||
@@ -133,16 +134,51 @@ class _CoupleCard extends StatelessWidget {
|
||||
required this.colorIndex,
|
||||
});
|
||||
|
||||
/// Libellé parents côté AM : « Sophie » ou « Thomas et Claire ».
|
||||
static String _parentsLabel(List<CoupleMembre> parents) {
|
||||
if (parents.isEmpty) return 'Parents';
|
||||
String prenomOf(CoupleMembre p) {
|
||||
final prenom = (p.prenom ?? '').trim();
|
||||
if (prenom.isNotEmpty) return prenom;
|
||||
return p.displayName(fallback: 'Parent');
|
||||
}
|
||||
if (parents.length == 1) return prenomOf(parents.first);
|
||||
return '${prenomOf(parents[0])} et ${prenomOf(parents[1])}';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 90,
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: AssetImage(QuotidienTheme.bandeauColors[colorIndex % QuotidienTheme.bandeauColors.length]),
|
||||
fit: BoxFit.fill,
|
||||
// Parent mode: enfant | AM (photo + prénom)
|
||||
// AM mode: enfant | parent(s) — texte seul, pas de vignette parent
|
||||
final isParentMode = mode == CoupleBandeauMode.parent;
|
||||
|
||||
Widget amOrParentsWidget;
|
||||
|
||||
if (isParentMode) {
|
||||
amOrParentsWidget = _MembreTile(
|
||||
photoUrl: couple.am?.photoUrl,
|
||||
name: (couple.am?.prenom != null && couple.am!.prenom!.isNotEmpty)
|
||||
? couple.am!.prenom!
|
||||
: (couple.am?.displayName(fallback: 'Nounou') ?? 'Nounou'),
|
||||
fallbackIcon: Icons.volunteer_activism,
|
||||
);
|
||||
} else {
|
||||
amOrParentsWidget = Text(
|
||||
_parentsLabel(couple.parents),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: GoogleFonts.merienda(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: QuotidienTheme.ink,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Carte.bandeau(
|
||||
assetPath: QuotidienTheme
|
||||
.bandeauColors[colorIndex % QuotidienTheme.bandeauColors.length],
|
||||
height: 90,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Row(
|
||||
children: [
|
||||
@@ -151,7 +187,8 @@ class _CoupleCard extends StatelessWidget {
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: _MembreTile(
|
||||
photoUrl: couple.enfant.photoUrl,
|
||||
name: (couple.enfant.prenom != null && couple.enfant.prenom!.isNotEmpty)
|
||||
name: (couple.enfant.prenom != null &&
|
||||
couple.enfant.prenom!.isNotEmpty)
|
||||
? couple.enfant.prenom!
|
||||
: couple.enfant.displayName(fallback: 'Enfant'),
|
||||
fallbackIcon: Icons.child_care,
|
||||
@@ -171,29 +208,17 @@ class _CoupleCard extends StatelessWidget {
|
||||
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,
|
||||
),
|
||||
child: amOrParentsWidget,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 28,
|
||||
child: showChevron
|
||||
? const Icon(
|
||||
Icons.keyboard_arrow_down,
|
||||
color: QuotidienTheme.ink,
|
||||
)
|
||||
: null,
|
||||
child: showChevron
|
||||
? const Icon(
|
||||
Icons.keyboard_arrow_down,
|
||||
color: QuotidienTheme.ink,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -279,24 +304,22 @@ class _CoupleBandeauSkeleton extends StatelessWidget {
|
||||
|
||||
@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,
|
||||
return ColorFiltered(
|
||||
colorFilter: ColorFilter.mode(
|
||||
Colors.white.withValues(alpha: 0.5),
|
||||
BlendMode.lighten,
|
||||
),
|
||||
child: Carte.bandeau(
|
||||
assetPath: QuotidienTheme.bandeauLime,
|
||||
height: 90,
|
||||
child: const Center(
|
||||
child: SizedBox(
|
||||
width: 26,
|
||||
height: 26,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: const SizedBox(
|
||||
width: 26,
|
||||
height: 26,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -306,14 +329,9 @@ class _CoupleBandeauEmpty extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
return Carte.bandeau(
|
||||
assetPath: QuotidienTheme.bandeauLime,
|
||||
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: [
|
||||
@@ -342,38 +360,35 @@ class _CoupleBandeauError extends StatelessWidget {
|
||||
|
||||
@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,
|
||||
),
|
||||
),
|
||||
return ColorFiltered(
|
||||
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,
|
||||
child: Carte.bandeau(
|
||||
assetPath: QuotidienTheme.bandeauLime,
|
||||
height: 90,
|
||||
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'),
|
||||
),
|
||||
],
|
||||
if (onRetry != null)
|
||||
TextButton(
|
||||
onPressed: onRetry,
|
||||
child: const Text('Réessayer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -183,15 +183,16 @@ class _UserMenu extends StatelessWidget {
|
||||
title: Text('Profil'),
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'search_am',
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Icon(Icons.search, size: 20),
|
||||
title: Text('Recherche AM'),
|
||||
if (onSearchAmTap != null)
|
||||
const PopupMenuItem(
|
||||
value: 'search_am',
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Icon(Icons.search, size: 20),
|
||||
title: Text('Recherche AM'),
|
||||
),
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'settings',
|
||||
child: ListTile(
|
||||
|
||||
@@ -6,6 +6,7 @@ abstract final class QuotidienTheme {
|
||||
static const Color ink = Color(0xFF2F2F2F);
|
||||
static const Color ivory = Color(0xFFFFFEF9);
|
||||
static const Color turquoise = Color(0xFF8AD0C8);
|
||||
static const Color peach = Color(0xFFFFCCB6);
|
||||
static const Color lavender = Color(0xFFC6A3D8);
|
||||
static const Color coral = Color(0xFFF4A28C);
|
||||
static const Color softGreenPill = Color(0xFFB8D9A8);
|
||||
@@ -48,6 +49,7 @@ abstract final class QuotidienTheme {
|
||||
'assets/images/bg_turquoise_pill.png';
|
||||
static const String pillLavenderAsset =
|
||||
'assets/images/bg_lavender_pill.png';
|
||||
static const String pillCorailAsset = 'assets/images/bg_corail_pill.png';
|
||||
|
||||
/// Trait crayon gris (2400×28, transparent) : séparateurs bandeau / corps /
|
||||
/// footer. Version verticale (28×2400) entre les colonnes.
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
archive:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: archive
|
||||
sha256: "6c5bcd986e06b94e3c40244af471750840a3d2341d1f9763a1100a14add517b4"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.3.0"
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: args
|
||||
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.0"
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -25,6 +41,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
checked_yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: checked_yaml
|
||||
sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.3"
|
||||
cli_util:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cli_util
|
||||
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.4.2"
|
||||
clock:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -150,6 +182,14 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_launcher_icons:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_launcher_icons
|
||||
sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.13.1"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@@ -213,6 +253,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.2"
|
||||
image:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image
|
||||
sha256: a1e7f4951e538a568e14b856702afc9ae1d2f4b202daced8d22c1b9cd211ce89
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.10.1"
|
||||
image_picker:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -293,6 +341,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.7"
|
||||
json_annotation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: json_annotation
|
||||
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.9.0"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -461,6 +517,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
posix:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: posix
|
||||
sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.5.2"
|
||||
provider:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -722,6 +786,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: yaml
|
||||
sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.4"
|
||||
sdks:
|
||||
dart: ">=3.7.0-0 <4.0.0"
|
||||
flutter: ">=3.19.0"
|
||||
|
||||
@@ -30,13 +30,29 @@ dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^2.0.0
|
||||
flutter_launcher_icons: ^0.13.1
|
||||
|
||||
flutter_launcher_icons:
|
||||
android: "launcher_icon"
|
||||
ios: false
|
||||
image_path: "assets/images/icon.png"
|
||||
web:
|
||||
generate: true
|
||||
image_path: "assets/images/icon.png"
|
||||
background_color: "#ffffff"
|
||||
theme_color: "#ffffff"
|
||||
windows:
|
||||
generate: true
|
||||
image_path: "assets/images/icon.png"
|
||||
icon_size: 256
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
|
||||
assets:
|
||||
- assets/images/ # Déclarer le dossier entier
|
||||
- assets/cards/ # Nouveau dossier de cartes
|
||||
- assets/images/
|
||||
- assets/images/cartes/
|
||||
- assets/cards/
|
||||
- assets/documents/
|
||||
|
||||
fonts:
|
||||
|
||||
|
Before Width: | Height: | Size: 917 B After Width: | Height: | Size: 633 B |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 165 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 165 KiB |
@@ -3,19 +3,19 @@
|
||||
"short_name": "P'titsPas",
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"background_color": "#FFFEF9",
|
||||
"theme_color": "#8AD0C8",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#ffffff",
|
||||
"description": "P'titsPas - Grandir pas à pas, sereinement",
|
||||
"orientation": "portrait-primary",
|
||||
"prefer_related_applications": false,
|
||||
"icons": [
|
||||
{
|
||||
"src": "assets/images/icon.png",
|
||||
"src": "icons/Icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "assets/images/icon.png",
|
||||
"src": "icons/Icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
@@ -32,4 +32,4 @@
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 47 KiB |