diff --git a/backend/src/entities/card_responses.entity.ts b/backend/src/entities/card_responses.entity.ts index f278502..9047f4f 100644 --- a/backend/src/entities/card_responses.entity.ts +++ b/backend/src/entities/card_responses.entity.ts @@ -5,6 +5,7 @@ import { JoinColumn, ManyToOne, PrimaryGeneratedColumn, + RelationId, } from 'typeorm'; import { CardInstance } from './card_instances.entity'; import { Users } from './users.entity'; @@ -20,20 +21,21 @@ export class CardResponse { @PrimaryGeneratedColumn('uuid') id: string; - @Column({ name: 'id_card', type: 'uuid' }) - id_card: string; - + /** FK carte — une seule source de vérité via @JoinColumn (pas de @Column doublon). */ @ManyToOne(() => CardInstance, (c) => c.responses, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'id_card', referencedColumnName: 'id' }) card: CardInstance; - @Column({ name: 'id_utilisateur', type: 'uuid' }) - id_utilisateur: string; + @RelationId((r: CardResponse) => r.card) + id_card: string; @ManyToOne(() => Users, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'id_utilisateur', referencedColumnName: 'id' }) user: Users; + @RelationId((r: CardResponse) => r.user) + id_utilisateur: string; + @Column({ type: 'enum', enum: CardResponseActionType, diff --git a/backend/src/modules/cards/cards.service.spec.ts b/backend/src/modules/cards/cards.service.spec.ts index 31260a2..cfdb925 100644 --- a/backend/src/modules/cards/cards.service.spec.ts +++ b/backend/src/modules/cards/cards.service.spec.ts @@ -24,6 +24,7 @@ describe('CardsService (#194)', () => { const cardsRepo = { create: jest.fn((x) => x), save: jest.fn(async (x) => ({ ...x, id: x.id ?? 'card-1', cree_le: new Date(), modifie_le: new Date() })), + update: jest.fn(async () => ({ affected: 1 })), findOne: jest.fn(), delete: jest.fn(), createQueryBuilder: jest.fn(), @@ -37,7 +38,8 @@ describe('CardsService (#194)', () => { }; const responsesRepo = { create: jest.fn((x) => x), - save: jest.fn(), + save: jest.fn(async (x) => x), + insert: jest.fn(), }; const amChildrenRepo = { findOne: jest.fn() }; const parentsChildrenRepo = { find: jest.fn(), findOne: jest.fn() }; @@ -80,7 +82,6 @@ describe('CardsService (#194)', () => { audience_resolver: 'couple_parents', response_mode: CardResponseModeType.ACCEPT_REFUSE, retention_days: 14, - couleur: 'lavender', }); amChildrenRepo.findOne.mockResolvedValue({ id: 'pl-1', @@ -111,7 +112,6 @@ describe('CardsService (#194)', () => { modifie_le: new Date(), type: { titre: 'Congé AM', - couleur: 'lavender', response_mode: CardResponseModeType.ACCEPT_REFUSE, retention_days: 14, }, @@ -157,4 +157,128 @@ describe('CardsService (#194)', () => { }), ).rejects.toBeInstanceOf(ForbiddenException); }); + + it('parent crée absence_enfant → audience AM + co-parent', async () => { + typesRepo.findOne.mockResolvedValue({ + code: 'absence_enfant', + system: true, + titre: 'Absence enfant', + emitter_roles: [RoleType.PARENT], + recipient_roles: [RoleType.ASSISTANTE_MATERNELLE], + audience_resolver: 'couple_am', + response_mode: CardResponseModeType.NONE, + retention_days: 7, + }); + amChildrenRepo.findOne.mockResolvedValue({ + id: 'pl-1', + amId: 'am-1', + enfantId: 'e-1', + date_fin: null, + }); + parentsChildrenRepo.findOne.mockResolvedValue({ + parentId: 'p-1', + enfantId: 'e-1', + }); + parentsChildrenRepo.find.mockResolvedValue([ + { parentId: 'p-1', enfantId: 'e-1' }, + { parentId: 'p-2', enfantId: 'e-1' }, + ]); + evenementsService.creer.mockResolvedValue({ + id: 'abs-1', + type: TypeEvenementAgendaType.ABSENCE_ENFANT, + statut: StatutEvenementAgendaType.ACCEPTE, + }); + cardsRepo.findOne.mockResolvedValue({ + id: 'card-abs', + type_code: 'absence_enfant', + id_placement: 'pl-1', + id_evenement: 'abs-1', + cree_par: 'p-1', + operation: CardOperationType.CREATE, + statut: CardInstanceStatutType.OUVERTE, + payload: { date_debut: '2026-10-28', date_fin: '2026-10-29' }, + purge_at: new Date(), + cree_le: new Date(), + modifie_le: new Date(), + type: { + titre: 'Absence enfant', + response_mode: CardResponseModeType.NONE, + retention_days: 7, + }, + responses: [], + audience: [ + { id_utilisateur: 'p-1' }, + { id_utilisateur: 'am-1' }, + { id_utilisateur: 'p-2' }, + ], + }); + + await service.creer('p-1', RoleType.PARENT, { + type_code: 'absence_enfant', + id_placement: 'pl-1', + date_debut: '2026-10-28', + date_fin: '2026-10-29', + }); + + const saved = audienceRepo.save.mock.calls[0][0] as Array<{ + id_utilisateur: string; + is_creator: boolean; + }>; + const ids = saved.map((m) => m.id_utilisateur).sort(); + expect(ids).toEqual(['am-1', 'p-1', 'p-2']); + expect(saved.find((m) => m.id_utilisateur === 'p-2')?.is_creator).toBe(false); + }); + + it('parent accepte conge_am → response via relation + carte traitee', async () => { + cardsRepo.findOne.mockResolvedValue({ + id: 'card-1', + type_code: 'conge_am', + id_placement: 'pl-1', + id_evenement: 'ev-1', + cree_par: 'am-1', + operation: CardOperationType.CREATE, + statut: CardInstanceStatutType.OUVERTE, + payload: { date_debut: '2026-10-27', date_fin: '2026-10-31' }, + purge_at: new Date(), + cree_le: new Date(), + modifie_le: new Date(), + type: { + titre: 'Congé AM', + response_mode: CardResponseModeType.ACCEPT_REFUSE, + retention_days: 14, + }, + responses: [], + audience: [ + { id_utilisateur: 'am-1' }, + { id_utilisateur: 'p-1' }, + { id_utilisateur: 'p-2' }, + ], + }); + audienceRepo.findOne.mockResolvedValue({ id_card: 'card-1', id_utilisateur: 'p-1' }); + evenementsService.maj.mockResolvedValue({}); + + const res = await service.repondre('p-1', RoleType.PARENT, 'card-1', { + action: 'accept' as never, + }); + + expect(responsesRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + card: { id: 'card-1' }, + user: { id: 'p-1' }, + action: 'accept', + }), + ); + expect(responsesRepo.save).toHaveBeenCalled(); + expect(evenementsService.maj).toHaveBeenCalledWith( + 'p-1', + RoleType.PARENT, + 'ev-1', + expect.objectContaining({ statut: StatutEvenementAgendaType.ACCEPTE }), + ); + expect(cardsRepo.update).toHaveBeenCalledWith( + { id: 'card-1' }, + expect.objectContaining({ statut: CardInstanceStatutType.TRAITEE }), + ); + expect(res.statut).toBe(CardInstanceStatutType.TRAITEE); + }); }); diff --git a/backend/src/modules/cards/cards.service.ts b/backend/src/modules/cards/cards.service.ts index 7630e74..b0402a2 100644 --- a/backend/src/modules/cards/cards.service.ts +++ b/backend/src/modules/cards/cards.service.ts @@ -53,9 +53,32 @@ export class CardsService { private readonly realtime: CardsRealtimeService, ) {} - async listerTypes(role: RoleType): Promise { + async listerTypes(role: RoleType): Promise< + Pick< + CardType, + | 'code' + | 'system' + | 'titre' + | 'emitter_roles' + | 'recipient_roles' + | 'audience_resolver' + | 'response_mode' + | 'retention_days' + >[] + > { const all = await this.typesRepo.find({ where: { system: true } }); - return all.filter((t) => t.emitter_roles.includes(role)); + return all + .filter((t) => t.emitter_roles.includes(role)) + .map((t) => ({ + code: t.code, + system: t.system, + titre: t.titre, + emitter_roles: t.emitter_roles, + recipient_roles: t.recipient_roles, + audience_resolver: t.audience_resolver, + response_mode: t.response_mode, + retention_days: t.retention_days, + })); } async lister( @@ -204,12 +227,14 @@ export class CardsService { throw new BadRequestException('Motivation obligatoire en cas de refus'); } - await this.responsesRepo.insert({ - id_card: card.id, - id_utilisateur: userId, - action: dto.action, - comment: dto.comment?.trim() || undefined, - }); + await this.responsesRepo.save( + this.responsesRepo.create({ + card: { id: card.id }, + user: { id: userId }, + action: dto.action, + comment: dto.comment?.trim() || undefined, + }), + ); if (card.id_evenement) { if (dto.action === CardResponseActionType.ACCEPT || dto.action === CardResponseActionType.ACK) { @@ -229,7 +254,12 @@ export class CardsService { } card.purge_at = this.purgeAt(card.type.retention_days, card.statut); - await this.cardsRepo.save(card); + // Ne pas cardsRepo.save(card) avec relations chargées : TypeORM peut + // resynchroniser les FK enfants (UPDATE … SET id_card=null). + await this.cardsRepo.update( + { id: card.id }, + { statut: card.statut, purge_at: card.purge_at }, + ); const full = await this.loadCard(cardId); const dtoOut = this.toDto(full, userId); this.emitAudience(full, 'response.added', { @@ -281,7 +311,12 @@ export class CardsService { }; card.statut = CardInstanceStatutType.OUVERTE; card.purge_at = this.purgeAt(card.type.retention_days, card.statut); - await this.cardsRepo.save(card); + await this.cardsRepo.manager.query( + `UPDATE card_instances + SET statut = $1, purge_at = $2, payload = $3::jsonb, modifie_le = now() + WHERE id = $4`, + [card.statut, card.purge_at, JSON.stringify(card.payload), card.id], + ); const full = await this.loadCard(cardId); const dtoOut = this.toDto(full, userId); this.emitAudience(full, 'card.updated', dtoOut); @@ -372,12 +407,25 @@ export class CardsService { ]; if (type.audience_resolver === 'couple_am') { + // Destinataire métier = AM + miroir foyer : tous les co-parents de l’enfant members.push({ id_card: card.id, id_utilisateur: placement.amId, role_snapshot: RoleType.ASSISTANTE_MATERNELLE, is_creator: false, }); + const liensAm = await this.parentsChildrenRepo.find({ + where: { enfantId: placement.enfantId }, + }); + for (const l of liensAm) { + if (l.parentId === creatorId) continue; + members.push({ + id_card: card.id, + id_utilisateur: l.parentId, + role_snapshot: RoleType.PARENT, + is_creator: false, + }); + } } else if (type.audience_resolver === 'couple_parents') { const liens = await this.parentsChildrenRepo.find({ where: { enfantId: placement.enfantId }, @@ -469,7 +517,6 @@ export class CardsService { id: card.id, type_code: card.type_code, titre: card.type?.titre ?? card.type_code, - couleur: card.type?.couleur ?? null, id_placement: card.id_placement, id_evenement: card.id_evenement ?? null, operation: card.operation, diff --git a/backend/src/modules/cards/dto/cards.dto.ts b/backend/src/modules/cards/dto/cards.dto.ts index 01474e0..d086dd5 100644 --- a/backend/src/modules/cards/dto/cards.dto.ts +++ b/backend/src/modules/cards/dto/cards.dto.ts @@ -91,9 +91,6 @@ export class CarteDto { @ApiProperty() titre: string; - @ApiPropertyOptional() - couleur?: string | null; - @ApiProperty() id_placement: string; diff --git a/docs/24_DECISIONS-PROJET.md b/docs/24_DECISIONS-PROJET.md index b1451d5..35397b8 100644 --- a/docs/24_DECISIONS-PROJET.md +++ b/docs/24_DECISIONS-PROJET.md @@ -572,14 +572,28 @@ POST /repos/jmartin/petitspas/pulls/{index}/merge ### 32. Module Absences + Cartes (séparation back / collecte) -**Décision** : ✅ **Back `evenements_agenda` = vérité métier** ; **Cartes = file d’attention / collecte** (plugin isolé). Types SYSTEM (absence, congé, arrêt) indéboulonnables ; types OPTIONNELS (sondages…) plus tard. Annulation = DELETE. `expire_at` dès V1. +**Décision** : ✅ **Back `evenements_agenda` = vérité métier / agenda** ; **Cartes = file d’attention** (plugin isolé). Types SYSTEM (absence, congé, arrêt) indéboulonnables ; types OPTIONNELS (sondages, soins enfant, école, mairie, sorties…) plus tard. Annulation = DELETE. `expire_at` dès V1. **Justification** : - Premier vrai module d’interaction multi-acteurs -- Évite de stocker l’historique congés dans des bulles éphémères -- Portabilité / généricité des cartes sans coupler le métier garde +- Évite de stocker l’historique dans des bulles éphémères +- Portabilité / généricité des cartes sans coupler le métier +- **Différence avec le blog** : les cartes du fil = **événements d’agenda** (période + placement + historique) ; le blog = récit narratif du jour -**Flux V1** : BDD absences → API liste/CRUD → module Cartes SYSTEM → front bulles (séparé). +**Modèle données** : +- Table **`evenements_agenda`** (ex-`absences_garde`, rename [#205](https://git.ptits-pas.fr/jmartin/petitspas/issues/205)) — nom volontairement ouvert + +**UI** : la **couleur** des bulles est un mapping **front** (`type_code` → teinte). L’API n’expose pas `couleur`. + +**Flux V1** : BDD agenda → API liste/CRUD → module Cartes SYSTEM → front bulles (séparé). + +**V1 cartes (réponses)** : +- `absence_enfant` → info (`none`) — pas d’ack AM ; audience **AM + tous les parents du foyer** (co-parent inclus) +- `arret_maladie_am` → ack parent (« Bien reçu ») +- `conge_am` → accept / refuse (+ motivation refus) +- **Modifs d’absence** → via **agenda / calendrier**, pas via bulle `absence_enfant_modif` (reporté au ticket calendrier) + +**Réf. détail** : `docs/32_MINI-SPEC-BULLES-CARTES.md` --- diff --git a/docs/31_MINI-SPEC-QUOTIDIEN-PARENT-AM.md b/docs/31_MINI-SPEC-QUOTIDIEN-PARENT-AM.md index 8f2e521..4bc19d0 100644 --- a/docs/31_MINI-SPEC-QUOTIDIEN-PARENT-AM.md +++ b/docs/31_MINI-SPEC-QUOTIDIEN-PARENT-AM.md @@ -103,7 +103,7 @@ Avec données peuplées, sur au moins 2 supports : - Parent : `frontend/lib/screens/home/parent_screen/ParentDashboardScreen.dart` + `dashbord_parent/` - AM : `frontend/lib/screens/am/am_dashboard_screen.dart` (placeholder) -- Cartes couleurs : `frontend/assets/cards/` (7 teintes max) +- Cartes couleurs : `frontend/assets/cards/` (8 teintes max) ## 9. Ordre de build suggéré diff --git a/docs/32_MINI-SPEC-BULLES-CARTES.md b/docs/32_MINI-SPEC-BULLES-CARTES.md new file mode 100644 index 0000000..d7f9583 --- /dev/null +++ b/docs/32_MINI-SPEC-BULLES-CARTES.md @@ -0,0 +1,195 @@ +# Mini-spec · Bulles / file d’attention (#173) + +*Front · octobre 2026 — alignement product / API.* + +--- + +## 1. Contexte / objectif + +Les bulles (cartes) sont la **file d’attention** du quotidien parent / AM : décisions et infos proches (congés, arrêt maladie AM, absences, soins / bobos enfant…). + +**Différence fondamentale avec le blog :** +les cartes de ce fil sont des **événements d’agenda** (période, placement, historique métier loggé). +Le **blog** = récit / mémoire narrative du jour (photos, texte) — pas une ligne d’agenda. + +Les cartes restent une **mémoire courte** de file (`purge_at` sur la bulle) ; la **source de vérité agenda** reste le back métier. + +Table agenda : **`evenements_agenda`** (ex-`absences_garde`, [#205](https://git.ptits-pas.fr/jmartin/petitspas/issues/205) fait) — conteneur ouvert (absences, congés, soins, école, mairie, sorties…). Les bulles notifient ; l’agenda historise. + +Voir aussi `31_MINI-SPEC-QUOTIDIEN-PARENT-AM.md`. + +Cette note formalise : +1. Ce qui est en place côté front (#173). +2. Le contrat API (sans couleur). +3. La **palette UI** (front only, dérivée du `type_code`). +4. La nouvelle famille **soins / bobo enfant** (rose) — AM → parents. +5. Idées futures. + +--- + +## 2. Contrat API + +Endpoint principal : `GET /api/v1/cards` (filtre couple / placement). + +| Champ | Rôle UI | +|-------|---------| +| `type_code` | Layout + libellé + **couleur / icône (mapping front)** | +| `titre` | Fallback si `type_code` inconnu | +| `statut` | `ouverte` · `refusee` · `traitee` | +| `response_mode` | `none` · `ack` · `accept_refuse` | +| `is_creator` | Masque les boutons réponse pour le créateur | +| `payload.date_debut` / `date_fin` | Période (agenda) | +| `payload.motif` | Texte libre / détail | +| `last_refuse_comment` | Si `refusee` | +| `id_evenement` | Lien métier agenda (`evenements_agenda`, si applicable) — ex-`id_absence` (#205) | +| `purge_at` | Péremption de la bulle dans la file | + +> **Front** : parser `id_evenement` (fallback éventuel `id_absence` uniquement pour anciennes fixtures / caches). Ne plus documenter ni dépendre de `id_absence` comme contrat. + +**Pas de champ `couleur` dans l’API.** +La teinte pastel est **100 % front** : table de mapping `type_code` → asset (`card_*_h.png`). Le back ne gère pas la charte graphique. + +**Règle front :** le destinataire peut répondre si `!is_creator && statut == ouverte && response_mode != none`. + +--- + +## 3. Évolutions front déjà livrées (#173) + +### 3.1 Feed + +- Widget partagé `CartesFeed` (parent + AM). +- Fond via widget `Carte` (9-slice horizontal) selon mapping local. +- Actions via **modales** accepter / refuser / ack. +- Libellés UI : + - `conge_am` → « Demande de congés » + - `arret_maladie_am` → « Arrêt maladie » + - `absence_enfant` → « Notification d’absence » + +### 3.2 Carte **congés** (`conge_am`) + +| Élément | Comportement | +|---------|----------------| +| Fond | **Aqua** (mapping front) | +| Encre | Pétrole `#315F63` / dates `#4A7276` | +| Icône | Valise | +| Période | « Du … au … » + calendrier | +| Actions | Pilules ✓ / ✕ si `accept_refuse` | +| Lien | « Ouvrir dans l’agenda ↗ » | +| Opacité | Atténuée si `traitee` (sauf règle arrêt ci-dessous) | + +### 3.3 Arrêt maladie AM (`arret_maladie_am`) + +- Reste **visible / opaque** tant que `date_fin` (jour inclus) n’est pas passée, même si `traitee`. +- Fond **bleu** (mapping front). + +### 3.4 Assets + +Teintes UI : `red` · `pink` · `peach` · `lime` · `lavender` · `green` · `blue` · `aqua`. +Clé = **choix front**, pas une valeur API. + +--- + +## 4. Palette UI (front only — dérivée du `type_code`) + +| Couleur UI | Famille | Types | +|------------|---------|--------| +| **`aqua`** | Congés AM | `conge_am` | +| **`blue`** | Arrêt maladie AM | `arret_maladie_am` | +| **`peach`** | Absence enfant / info pratique | `absence_enfant` | +| **`pink`** | Soins / bobo / problème **enfant** (AM → parents) | `soin_enfant` *(nouveau — voir §5)* | +| **`lavender`** | Admin / divers | futurs types admin | + +**Hors scope V1 cartes** : `absence_enfant_modif` (ack AM sur modif). +Les **modifications d’absence** se font via l’**agenda / calendrier**, pas via une bulle dédiée. On rebranchera une notif éventuelle quand le calendrier sera implémenté. + +**Principes** + +- Couleur = famille visuelle (lisible sans tout lire). +- Icône précise l’événement (surtout sur rose : thermomètre, pansement, flacon…). +- Rose = doux / rassurant, **pas** une alerte médicale forte. +- Congés ≠ lavande (trop « admin »). + +--- + +## 5. Nouvelle bulle rose — soins / bobo enfant (AM → parents) + +### Intention produit + +La nounou signale un **événement concernant l’enfant** (maladie légère, bobo, crème, température, souci du jour…) **vers les parents**. + +Pourquoi **pas seulement la messagerie** ? +- C’est **loggé** dans la file d’attention. +- C’est un **événement d’agenda** (période / jour, placement, historique). +- Les parents peuvent **acquitter** (`ack`) sans noyer le chat. + +### Proposition V1 (à brancher ultérieurement) + +| | | +|--|--| +| `type_code` | `soin_enfant` (nom exact à figer) | +| Émetteur | AM | +| Destinataires | parents du couple / placement | +| `response_mode` | `ack` | +| Couleur UI | `pink` | +| Back métier | ligne agenda liée (même esprit que `absences_garde` : période + placement + motif) ; la bulle = collecte / notification | +| Payload | `date_debut` / `date_fin` (+ motif / sous-type optionnel : temperature, bobo, medicament…) | + +**Hors scope immédiat #173** : implémentation complète ; cette section fige l’intention pour le contrat types + UI. + +--- + +## 6. Idées futures (hors scope) + +| Famille | Couleur UI | Exemples | `response_mode` | +|---------|------------|----------|-----------------| +| Soins (détail) | `pink` | `soin_temperature`, `soin_medicament`, `soin_bobo` — ou sous-types dans payload | `ack` | +| Modif absence | `peach` | `absence_enfant_modif` — **via calendrier** d’abord ; bulle ack AM éventuelle ensuite | `ack` | +| Sortie | `peach` / `lavender` | `sortie_a_valider` | `accept_refuse` | +| Admin | `lavender` | `info_admin`, `document_a_fournir` | `ack` / `none` | + +Variante soins : un seul fond rose ; l’icône / sous-type change. + +--- + +## 7. Comportements métier à ne pas casser + +- Créateur : pas de boutons réponse ; éventuel « En attente de réponse ». +- Refus : afficher `last_refuse_comment`. +- Congé : `accept_refuse` côté destinataire. +- Absence enfant : **info seule** (`response_mode: none`) — pas de veto ni ack AM en V1. +- Absence enfant : audience = **AM + tous les parents du foyer** (créateur + co-parent), pas seulement le déclarant. +- Modif / édition d’absence : **agenda**, pas les cartes (V1). +- File courte : `purge_at` ; l’**agenda** conserve l’historique métier. + +--- + +## 8. Fichiers front de référence + +- `frontend/lib/models/carte_bulle.dart` — mapping type → libellé / icône / fond +- `frontend/lib/widgets/quotidien/cartes_feed.dart` +- `frontend/lib/models/card_assets.dart` +- Assets : `frontend/assets/cards/`, `valise.png`, `btn_*_pilule.png` + +--- + +## 9. Recettes + +- [ ] API cartes **sans** champ `couleur` (ni feed ni `/types`). +- [ ] Contrat lien agenda : champ API `id_evenement` (plus `id_absence`). +- [ ] Front mappe `conge_am` → aqua, `arret_maladie_am` → blue, `absence_enfant` → peach. +- [ ] Accept / refuse congé OK. +- [ ] Arrêt : ack parent « Bien reçu » ; traité + période en cours → reste visible. +- [ ] Absence enfant : pas de bouton (info). +- [ ] Filtre couple / placement. +- [ ] (Plus tard) `soin_enfant` rose AM → parents + ligne agenda. +- [ ] (Plus tard / calendrier) modifs d’absence + éventuel `absence_enfant_modif`. + +--- + +## 10. Références + +- Ticket front : **#173** +- Module cartes : **#194** +- Quotidien : `docs/31_MINI-SPEC-QUOTIDIEN-PARENT-AM.md` +- Décision projet : `docs/24_DECISIONS-PROJET.md` §32 +- Maquette congés : `ressources/bulle_congés.jpg` diff --git a/frontend/assets/cards/card_aqua.png b/frontend/assets/cards/card_aqua.png new file mode 100644 index 0000000..47acd6a Binary files /dev/null and b/frontend/assets/cards/card_aqua.png differ diff --git a/frontend/assets/cards/card_aqua_h.png b/frontend/assets/cards/card_aqua_h.png new file mode 100644 index 0000000..d57837d Binary files /dev/null and b/frontend/assets/cards/card_aqua_h.png differ diff --git a/frontend/assets/cards/extensible.png b/frontend/assets/cards/extensible.png new file mode 100644 index 0000000..056541f Binary files /dev/null and b/frontend/assets/cards/extensible.png differ diff --git a/frontend/assets/images/cartes/btn_coche_pilule.png b/frontend/assets/images/cartes/btn_coche_pilule.png new file mode 100644 index 0000000..781c67c Binary files /dev/null and b/frontend/assets/images/cartes/btn_coche_pilule.png differ diff --git a/frontend/assets/images/cartes/btn_croix_pilule.png b/frontend/assets/images/cartes/btn_croix_pilule.png new file mode 100644 index 0000000..23de94b Binary files /dev/null and b/frontend/assets/images/cartes/btn_croix_pilule.png differ diff --git a/frontend/assets/images/cartes/icon_calendrier_crayon.png b/frontend/assets/images/cartes/icon_calendrier_crayon.png new file mode 100644 index 0000000..64802c5 Binary files /dev/null and b/frontend/assets/images/cartes/icon_calendrier_crayon.png differ diff --git a/frontend/assets/images/sac_lapin.png b/frontend/assets/images/sac_lapin.png new file mode 100644 index 0000000..89476a4 Binary files /dev/null and b/frontend/assets/images/sac_lapin.png differ diff --git a/frontend/assets/images/thermo_croix.png b/frontend/assets/images/thermo_croix.png new file mode 100644 index 0000000..3b9b760 Binary files /dev/null and b/frontend/assets/images/thermo_croix.png differ diff --git a/frontend/assets/images/valise.png b/frontend/assets/images/valise.png new file mode 100644 index 0000000..afe4bd6 Binary files /dev/null and b/frontend/assets/images/valise.png differ diff --git a/frontend/lib/models/card_assets.dart b/frontend/lib/models/card_assets.dart index c86b663..926d696 100644 --- a/frontend/lib/models/card_assets.dart +++ b/frontend/lib/models/card_assets.dart @@ -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); -} \ No newline at end of file +} diff --git a/frontend/lib/models/carte_bulle.dart b/frontend/lib/models/carte_bulle.dart new file mode 100644 index 0000000..fbc3b94 --- /dev/null +++ b/frontend/lib/models/carte_bulle.dart @@ -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 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 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.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()); + } +} diff --git a/frontend/lib/screens/auth/parent_register_step3_screen.dart b/frontend/lib/screens/auth/parent_register_step3_screen.dart index 7e99b0d..34e427e 100644 --- a/frontend/lib/screens/auth/parent_register_step3_screen.dart +++ b/frontend/lib/screens/auth/parent_register_step3_screen.dart @@ -41,6 +41,7 @@ class _ParentRegisterStep3ScreenState extends State { CardColorVertical.red, CardColorVertical.green, CardColorVertical.blue, + CardColorVertical.aqua, ]; // Garder une trace des couleurs déjà utilisées diff --git a/frontend/lib/screens/home/parent_screen/ParentDashboardScreen.dart b/frontend/lib/screens/home/parent_screen/ParentDashboardScreen.dart index e2d2e82..568d77f 100644 --- a/frontend/lib/screens/home/parent_screen/ParentDashboardScreen.dart +++ b/frontend/lib/screens/home/parent_screen/ParentDashboardScreen.dart @@ -3,14 +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}); @@ -63,7 +63,6 @@ class _ParentDashboardScreenState extends State { Future _selectCouple(CoupleGarde couple) async { if (couple.id == _selectedCoupleId) return; - // Optimiste : on bascule tout de suite, l'API persiste ensuite. setState(() => _selectedCoupleId = couple.id); try { final res = await CoupleGardeService.definirCoupleCourant(couple.id); @@ -116,6 +115,7 @@ class _ParentDashboardScreenState extends State { error: _couplesError, onRetry: _loadCouples, onCoupleSelected: _selectCouple, + onAjouterBulle: () => _soon('Ajouter une bulle'), ), centerColumn: const QuotidienColumnPlaceholder( title: 'Blog', @@ -138,7 +138,7 @@ class _ParentDashboardScreenState extends State { } } -/// 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 couples; final String? selectedCoupleId; @@ -146,6 +146,7 @@ class _LeftColumn extends StatelessWidget { final String? error; final VoidCallback onRetry; final ValueChanged onCoupleSelected; + final VoidCallback onAjouterBulle; const _LeftColumn({ required this.couples, @@ -154,6 +155,7 @@ class _LeftColumn extends StatelessWidget { required this.error, required this.onRetry, required this.onCoupleSelected, + required this.onAjouterBulle, }); @override @@ -172,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, ), ), ], diff --git a/frontend/lib/services/api/api_config.dart b/frontend/lib/services/api/api_config.dart index 4c67371..717af7f 100644 --- a/frontend/lib/services/api/api_config.dart +++ b/frontend/lib/services/api/api_config.dart @@ -93,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 get headers => { 'Content-Type': 'application/json', diff --git a/frontend/lib/services/api/cards_service.dart b/frontend/lib/services/api/cards_service.dart new file mode 100644 index 0000000..14f64f9 --- /dev/null +++ b/frontend/lib/services/api/cards_service.dart @@ -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> _headers() async { + final token = await TokenService.getToken(); + return token != null + ? ApiConfig.authHeaders(token) + : Map.from(ApiConfig.headers); + } + + static String _extractError(String body, String fallback) { + try { + final decoded = jsonDecode(body); + if (decoded is Map) { + final message = decoded['message']; + if (message is String && message.trim().isNotEmpty) { + return message; + } + if (message is Map && message['message'] is String) { + return message['message'] as String; + } + } + } catch (_) {} + return fallback; + } + + /// Feed de bulles pour l'utilisateur (filtré par couple / placement). + static Future> lister({String? placementId}) async { + final query = {}; + 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((e) => CarteBulle.fromJson(Map.from(e))) + .toList(); + } + + /// Répondre : accept | refuse | ack. + static Future 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.from(decoded as Map)); + } +} diff --git a/frontend/lib/services/api/cards_stream_client.dart b/frontend/lib/services/api/cards_stream_client.dart new file mode 100644 index 0000000..35cf690 --- /dev/null +++ b/frontend/lib/services/api/cards_stream_client.dart @@ -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 _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.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.from(nested)); + } + } else if ((type == 'card.created' || type == 'card.updated') && + payload is Map) { + card = CarteBulle.fromJson(Map.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()); + }); + } +} diff --git a/frontend/lib/services/api/cards_stream_connector_io.dart b/frontend/lib/services/api/cards_stream_connector_io.dart new file mode 100644 index 0000000..fca64f6 --- /dev/null +++ b/frontend/lib/services/api/cards_stream_connector_io.dart @@ -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>? _sub; + bool _closed = false; + + Future 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; + } +} diff --git a/frontend/lib/services/api/cards_stream_connector_stub.dart b/frontend/lib/services/api/cards_stream_connector_stub.dart new file mode 100644 index 0000000..f50d0f8 --- /dev/null +++ b/frontend/lib/services/api/cards_stream_connector_stub.dart @@ -0,0 +1,13 @@ +import 'dart:async'; + +/// Connecteur SSE — stub (tests / plateformes sans impl). +class CardsStreamConnector { + Future connect({ + required String url, + String? bearerToken, + required void Function(String eventType, String data) onEvent, + required void Function(Object error) onError, + }) async {} + + void close() {} +} diff --git a/frontend/lib/services/api/cards_stream_connector_web.dart b/frontend/lib/services/api/cards_stream_connector_web.dart new file mode 100644 index 0000000..9e5e6b7 --- /dev/null +++ b/frontend/lib/services/api/cards_stream_connector_web.dart @@ -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 _listeners = {}; + + Future 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; + } +} diff --git a/frontend/lib/utils/reprise_mapper.dart b/frontend/lib/utils/reprise_mapper.dart index 7a9730e..8b830fa 100644 --- a/frontend/lib/utils/reprise_mapper.dart +++ b/frontend/lib/utils/reprise_mapper.dart @@ -17,6 +17,7 @@ class RepriseMapper { CardColorVertical.red, CardColorVertical.green, CardColorVertical.blue, + CardColorVertical.aqua, ]; static ParentData parentFromDossier(ParentDossier p) { diff --git a/frontend/lib/widgets/base_form_screen.dart b/frontend/lib/widgets/base_form_screen.dart index 608bffd..9b7cde4 100644 --- a/frontend/lib/widgets/base_form_screen.dart +++ b/frontend/lib/widgets/base_form_screen.dart @@ -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; } } diff --git a/frontend/lib/widgets/child_card_widget.dart b/frontend/lib/widgets/child_card_widget.dart index 4ff744f..14128f5 100644 --- a/frontend/lib/widgets/child_card_widget.dart +++ b/frontend/lib/widgets/child_card_widget.dart @@ -205,9 +205,14 @@ class _ChildCardWidgetState extends State { ); } - 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 { // 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; diff --git a/frontend/lib/widgets/personal_info_form_screen.dart b/frontend/lib/widgets/personal_info_form_screen.dart index 94c9888..3a73267 100644 --- a/frontend/lib/widgets/personal_info_form_screen.dart +++ b/frontend/lib/widgets/personal_info_form_screen.dart @@ -1302,6 +1302,8 @@ class _PersonalInfoFormScreenState extends State { return CardColorVertical.pink.path; case CardColorHorizontal.red: return CardColorVertical.red.path; + case CardColorHorizontal.aqua: + return CardColorVertical.aqua.path; } } } diff --git a/frontend/lib/widgets/presentation_form_screen.dart b/frontend/lib/widgets/presentation_form_screen.dart index fd2d310..fb019a6 100644 --- a/frontend/lib/widgets/presentation_form_screen.dart +++ b/frontend/lib/widgets/presentation_form_screen.dart @@ -642,6 +642,8 @@ class _PresentationFormScreenState extends State { return CardColorVertical.pink.path; case CardColorHorizontal.red: return CardColorVertical.red.path; + case CardColorHorizontal.aqua: + return CardColorVertical.aqua.path; } } } diff --git a/frontend/lib/widgets/professional_info_form_screen.dart b/frontend/lib/widgets/professional_info_form_screen.dart index 9a21bd0..33a282e 100644 --- a/frontend/lib/widgets/professional_info_form_screen.dart +++ b/frontend/lib/widgets/professional_info_form_screen.dart @@ -1196,6 +1196,8 @@ class _ProfessionalInfoFormScreenState extends State return CardColorVertical.pink.path; case CardColorHorizontal.red: return CardColorVertical.red.path; + case CardColorHorizontal.aqua: + return CardColorVertical.aqua.path; } } } diff --git a/frontend/lib/widgets/quotidien/carte.dart b/frontend/lib/widgets/quotidien/carte.dart new file mode 100644 index 0000000..bd3289e --- /dev/null +++ b/frontend/lib/widgets/quotidien/carte.dart @@ -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 createState() => _CarteState(); +} + +class _CarteImageCache { + static final Map> _futures = {}; + + static Future 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 { + 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 _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; + } +} diff --git a/frontend/lib/widgets/quotidien/cartes_feed.dart b/frontend/lib/widgets/quotidien/cartes_feed.dart new file mode 100644 index 0000000..279b021 --- /dev/null +++ b/frontend/lib/widgets/quotidien/cartes_feed.dart @@ -0,0 +1,1100 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:p_tits_pas/models/card_assets.dart'; +import 'package:p_tits_pas/models/carte_bulle.dart'; +import 'package:p_tits_pas/services/api/cards_service.dart'; +import 'package:p_tits_pas/services/api/cards_stream_client.dart'; +import 'package:p_tits_pas/widgets/quotidien/carte.dart'; +import 'package:p_tits_pas/widgets/quotidien/quotidien_theme.dart'; + +/// Feed de bulles (file d'attention) — ticket #173. +/// Widget partagé parent / AM ; les actions dépendent du rôle + payload API. +class CartesFeed extends StatefulWidget { + final String? placementId; + final VoidCallback? onAjouterBulle; + + const CartesFeed({ + super.key, + this.placementId, + this.onAjouterBulle, + }); + + @override + State createState() => _CartesFeedState(); +} + +class _CartesFeedState extends State { + static const double _fadeExtent = 0.08; + + List _items = const []; + bool _loading = true; + String? _error; + String? _busyCardId; + + final ScrollController _scrollController = ScrollController(); + bool _isScrollable = false; + bool _fadeTop = false; + bool _fadeBottom = false; + + CardsStreamClient? _stream; + + @override + void initState() { + super.initState(); + _scrollController.addListener(_syncScrollFades); + _load(); + _startStream(); + } + + @override + void dispose() { + _stream?.stop(); + _stream = null; + _scrollController.removeListener(_syncScrollFades); + _scrollController.dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant CartesFeed oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.placementId != widget.placementId) { + _load(); + } + } + + void _startStream() { + _stream?.stop(); + _stream = CardsStreamClient( + onEvent: _onStreamEvent, + onReconnectSync: () { + if (mounted) _load(); + }, + )..start(); + } + + void _onStreamEvent(CardsStreamEvent event) { + if (!mounted) return; + if (event.type == 'heartbeat') return; + + if (event.type == 'card.deleted') { + final id = event.cardId; + if (id == null || id.isEmpty) return; + setState(() { + _items = _trier([ + for (final c in _items) + if (c.id != id) c, + ]); + }); + WidgetsBinding.instance.addPostFrameCallback((_) => _syncScrollFades()); + return; + } + + final card = event.card; + if (card == null) return; + + final filter = widget.placementId; + final matchesFilter = filter == null || + filter.isEmpty || + card.idPlacement == filter; + + setState(() { + if (!matchesFilter) { + _items = _trier([ + for (final c in _items) + if (c.id != card.id) c, + ]); + } else if (_items.any((c) => c.id == card.id)) { + _items = _trier([ + for (final c in _items) if (c.id == card.id) card else c, + ]); + } else { + _items = _trier([..._items, card]); + } + }); + WidgetsBinding.instance.addPostFrameCallback((_) => _syncScrollFades()); + } + + void _syncScrollFades() { + if (!_scrollController.hasClients) return; + final position = _scrollController.position; + final scrollable = position.maxScrollExtent > 0.0; + final threshold = position.viewportDimension * _fadeExtent / 2; + final fadeTop = scrollable && position.pixels > threshold; + final fadeBottom = scrollable && + position.pixels < (position.maxScrollExtent - threshold); + if (scrollable != _isScrollable || + fadeTop != _fadeTop || + fadeBottom != _fadeBottom) { + setState(() { + _isScrollable = scrollable; + _fadeTop = fadeTop; + _fadeBottom = fadeBottom; + }); + } + } + + /// Aligné sur le tri API : refusées → ouvertes → traitées, puis + /// `modifie_le` DESC, `id` en départage (ordre stable entre parents). + static List _trier(List items) { + int rang(CarteStatut s) { + switch (s) { + case CarteStatut.refusee: + return 0; + case CarteStatut.ouverte: + return 1; + case CarteStatut.traitee: + return 2; + } + } + + final out = List.from(items); + out.sort((a, b) { + final byStatut = rang(a.statut).compareTo(rang(b.statut)); + if (byStatut != 0) return byStatut; + final am = a.modifieLe?.millisecondsSinceEpoch ?? 0; + final bm = b.modifieLe?.millisecondsSinceEpoch ?? 0; + final byModif = bm.compareTo(am); // DESC + if (byModif != 0) return byModif; + return a.id.compareTo(b.id); + }); + return out; + } + + Future _load() async { + setState(() { + _loading = true; + _error = null; + }); + try { + final items = await CardsService.lister( + placementId: widget.placementId, + ); + if (!mounted) return; + setState(() { + _items = _trier(items); + _loading = false; + }); + WidgetsBinding.instance.addPostFrameCallback((_) => _syncScrollFades()); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e.toString().replaceFirst('Exception: ', ''); + _loading = false; + }); + } + } + + Future _respond( + CarteBulle card, { + required String action, + String? comment, + }) async { + setState(() => _busyCardId = card.id); + try { + final updated = await CardsService.repondre( + cardId: card.id, + action: action, + comment: comment, + ); + if (!mounted) return; + setState(() { + _items = _trier([ + for (final c in _items) if (c.id == updated.id) updated else c, + ]); + _busyCardId = null; + }); + } catch (e) { + if (!mounted) return; + setState(() => _busyCardId = null); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(e.toString().replaceFirst('Exception: ', '')), + ), + ); + } + } + + Future _onAccept(CarteBulle card) async { + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text( + 'Accepter la demande ?', + style: GoogleFonts.merienda(fontWeight: FontWeight.w600), + ), + content: Text( + card.isCongeAm + ? 'Confirmez-vous les congés ${_periodeDuAu(card)} ?' + : 'Confirmez-vous l’acceptation de « ${card.titreAffiche} » ' + '(${_periodeCourte(card)}) ?', + style: GoogleFonts.merriweather(fontSize: 14, height: 1.4), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Annuler'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Confirmer'), + ), + ], + ), + ); + if (ok == true) { + await _respond(card, action: 'accept'); + } + } + + /// Ack immédiat (arrêt maladie, etc.) — pas de modale : un clic = bien reçu. + Future _onAck(CarteBulle card) => _respond(card, action: 'ack'); + + Future _onRefuse(CarteBulle card) async { + final comment = await showDialog( + context: context, + builder: (ctx) => _RefuseDialog( + title: card.isCongeAm ? 'Refuser les congés ?' : 'Refuser la demande', + message: card.isCongeAm + ? 'Indiquez le motif du refus des congés ' + '${_periodeDuAu(card)}.' + : 'Indiquez le motif du refus pour « ${card.titreAffiche} ».', + ), + ); + if (comment != null && comment.isNotEmpty) { + await _respond(card, action: 'refuse', comment: comment); + } + } + + static const _mois = [ + 'janvier', + 'février', + 'mars', + 'avril', + 'mai', + 'juin', + 'juillet', + 'août', + 'septembre', + 'octobre', + 'novembre', + 'décembre', + ]; + + static String _periodeCourte(CarteBulle card) { + final d = card.dateDebut; + final f = card.dateFin; + String fmt(String? iso) { + if (iso == null || iso.isEmpty) return '…'; + final dt = DateTime.tryParse(iso); + if (dt == null) return iso; + return '${dt.day.toString().padLeft(2, '0')}/' + '${dt.month.toString().padLeft(2, '0')}/${dt.year}'; + } + if (d == null && f == null) return 'période non précisée'; + if (d != null && f != null && d == f) return fmt(d); + return '${fmt(d)} → ${fmt(f)}'; + } + + /// « du 04 novembre 2026 au 06 novembre 2026 » + static String _periodeDuAu(CarteBulle card) { + String fmtLong(String? iso) { + if (iso == null || iso.isEmpty) return '…'; + final dt = DateTime.tryParse(iso); + if (dt == null) return iso; + final jj = dt.day.toString().padLeft(2, '0'); + return '$jj ${_mois[dt.month - 1]} ${dt.year}'; + } + + final d = card.dateDebut; + final f = card.dateFin; + if (d == null && f == null) return 'pour une période non précisée'; + if (d != null && (f == null || d == f)) return 'du ${fmtLong(d)}'; + return 'du ${fmtLong(d)} au ${fmtLong(f)}'; + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + const Icon( + Icons.style_outlined, + color: QuotidienTheme.lavender, + size: 22, + ), + const SizedBox(width: 8), + Text( + 'Cartes', + style: GoogleFonts.merienda( + fontSize: 18, + fontWeight: FontWeight.w600, + color: QuotidienTheme.ink, + ), + ), + const Spacer(), + TextButton.icon( + onPressed: widget.onAjouterBulle, + icon: const Icon(Icons.add, size: 18), + label: Text( + 'Ajouter', + style: GoogleFonts.merriweather(fontSize: 13), + ), + ), + ], + ), + const SizedBox(height: 8), + Expanded(child: _buildBody()), + ], + ); + } + + Widget _buildBody() { + if (_loading) { + return const Center( + child: SizedBox( + width: 28, + height: 28, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ); + } + if (_error != null) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _error!, + textAlign: TextAlign.center, + style: GoogleFonts.merriweather( + fontSize: 13, + color: QuotidienTheme.ink, + ), + ), + const SizedBox(height: 8), + TextButton(onPressed: _load, child: const Text('Réessayer')), + ], + ), + ); + } + if (_items.isEmpty) { + return Center( + child: Text( + 'Aucune bulle pour le moment.\nLa file d’attention est vide — c’est normal.', + textAlign: TextAlign.center, + style: GoogleFonts.merriweather( + fontSize: 13, + color: QuotidienTheme.muted, + height: 1.4, + ), + ), + ); + } + + return NotificationListener( + onNotification: (_) { + _syncScrollFades(); + return false; + }, + child: ShaderMask( + blendMode: BlendMode.dstIn, + shaderCallback: (Rect bounds) { + final stops = [ + 0.0, + _fadeExtent, + 1.0 - _fadeExtent, + 1.0, + ]; + if (!_isScrollable) { + return const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black, + Colors.black, + Colors.black, + Colors.black, + ], + stops: [0.0, 0.08, 0.92, 1.0], + ).createShader(bounds); + } + final topMask = _fadeTop ? Colors.transparent : Colors.black; + final bottomMask = _fadeBottom ? Colors.transparent : Colors.black; + return LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + topMask, + Colors.black, + Colors.black, + bottomMask, + ], + stops: stops, + ).createShader(bounds); + }, + child: RefreshIndicator( + onRefresh: _load, + child: ListView.separated( + controller: _scrollController, + padding: const EdgeInsets.only(top: 4, bottom: 8), + itemCount: _items.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final card = _items[index]; + return _BulleTile( + card: card, + busy: _busyCardId == card.id, + onAccept: () => _onAccept(card), + onAck: () => _onAck(card), + onRefuse: () => _onRefuse(card), + ); + }, + ), + ), + ), + ); + } +} + +/// Encre (titre / date / lien) accordée à la teinte de fond de la bulle. +class _Encre { + final Color ink; + final Color date; + final Color link; + const _Encre(this.ink, this.date, this.link); + + /// Aqua → pétrole désaturé ; pêche → brun chaud ; bleu → marine doux ; + /// rose → bois de rose ; lavande → prune. + static _Encre pour(CardColorHorizontal c) { + switch (c) { + case CardColorHorizontal.aqua: + return const _Encre( + Color(0xFF315F63), + Color(0xFF4A7276), + Color(0xFF2B5868), + ); + case CardColorHorizontal.peach: + return const _Encre( + Color(0xFF7A4A2E), + Color(0xFF8F6248), + Color(0xFF6E4530), + ); + case CardColorHorizontal.blue: + return const _Encre( + Color(0xFF2F4A6E), + Color(0xFF486083), + Color(0xFF2A4670), + ); + case CardColorHorizontal.pink: + return const _Encre( + Color(0xFF7A3E52), + Color(0xFF8E5668), + Color(0xFF6E3A50), + ); + case CardColorHorizontal.lavender: + return const _Encre( + Color(0xFF5C3D5C), + Color(0xFF70536F), + Color(0xFF553A60), + ); + case CardColorHorizontal.red: + case CardColorHorizontal.lime: + case CardColorHorizontal.green: + return const _Encre( + Color(0xFF2F2F2F), + Color(0xFF6B6B6B), + Color(0xFF3A3A3A), + ); + } + } +} + +class _BulleTile extends StatelessWidget { + final CarteBulle card; + final bool busy; + final VoidCallback onAccept; + final VoidCallback onAck; + final VoidCallback onRefuse; + + const _BulleTile({ + required this.card, + required this.busy, + required this.onAccept, + required this.onAck, + required this.onRefuse, + }); + + static const _mois = [ + 'janvier', + 'février', + 'mars', + 'avril', + 'mai', + 'juin', + 'juillet', + 'août', + 'septembre', + 'octobre', + 'novembre', + 'décembre', + ]; + + String _fmtLong(String? iso) { + if (iso == null || iso.isEmpty) return '…'; + final dt = DateTime.tryParse(iso); + if (dt == null) return iso; + final jj = dt.day.toString().padLeft(2, '0'); + return '$jj ${_mois[dt.month - 1]} ${dt.year}'; + } + + /// Une ligne type maquette : « Du 04 au 06 novembre 2026 ». + String _fmtPeriodeUneLigne() { + final d1 = DateTime.tryParse(card.dateDebut ?? ''); + final d2 = DateTime.tryParse(card.dateFin ?? ''); + if (d1 == null && d2 == null) return ''; + if (d1 != null && d2 == null) return 'Le ${_fmtLong(card.dateDebut)}'; + if (d1 == null && d2 != null) return 'Au ${_fmtLong(card.dateFin)}'; + final a = d1!; + final b = d2!; + final j1 = a.day.toString().padLeft(2, '0'); + final j2 = b.day.toString().padLeft(2, '0'); + if (a.year == b.year && a.month == b.month && a.day == b.day) { + return 'Le $j1 ${_mois[a.month - 1]} ${a.year}'; + } + if (a.year == b.year && a.month == b.month) { + return 'Du $j1 au $j2 ${_mois[a.month - 1]} ${a.year}'; + } + if (a.year == b.year) { + return 'Du $j1 ${_mois[a.month - 1]} au $j2 ${_mois[b.month - 1]} ${a.year}'; + } + return 'Du $j1 ${_mois[a.month - 1]} ${a.year} ' + 'au $j2 ${_mois[b.month - 1]} ${b.year}'; + } + + /// Arrêt maladie : reste opaque tant que la période n’est pas finie. + bool get _attenuee { + if (card.isArretMaladie && card.periodeEncoreActive) return false; + return card.statut == CarteStatut.traitee; + } + + /// Congés (Validée / Refusée) ou carte ack (Bien reçu). + bool get _montreStatutReponse { + if (card.peutRepondre) return false; + if (card.isCongeAm && + (card.statut == CarteStatut.traitee || + card.statut == CarteStatut.refusee)) { + return true; + } + return card.responseMode == CarteResponseMode.ack && + card.statut == CarteStatut.traitee; + } + + String get _libelleStatutReponse { + if (card.responseMode == CarteResponseMode.ack) return 'Bien reçu'; + // Accordé à « Demande de congés ». + return card.statut == CarteStatut.refusee ? 'Refusée' : 'Validée'; + } + + @override + Widget build(BuildContext context) { + final periode = _fmtPeriodeUneLigne(); + final encre = _Encre.pour(card.colorFamily); + + return Opacity( + opacity: _attenuee ? 0.55 : 1, + child: Carte( + assetPath: card.backgroundAsset, + constraints: const BoxConstraints(minHeight: 140), + padding: const EdgeInsets.fromLTRB(22, 18, 18, 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + card.titreAffiche, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.merienda( + fontSize: 18, + fontWeight: FontWeight.w700, + color: encre.ink, + ), + ), + if (periode.isNotEmpty) ...[ + const SizedBox(height: 10), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Image.asset( + 'assets/images/cartes/icon_calendrier_crayon.png', + width: 28, + height: 28, + fit: BoxFit.contain, + filterQuality: FilterQuality.high, + color: encre.ink, + colorBlendMode: BlendMode.srcIn, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + periode, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.merienda( + fontSize: 15, + fontWeight: FontWeight.w600, + color: encre.date, + height: 1.25, + ), + ), + ), + ], + ), + ], + if (card.isCongeAm && + card.statut == CarteStatut.refusee && + card.motifRefus != null) ...[ + const SizedBox(height: 8), + Text( + 'Motif : ${card.motifRefus}', + maxLines: 4, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.merriweather( + fontSize: 13, + fontStyle: FontStyle.italic, + height: 1.3, + color: encre.ink, + ), + ), + ] else if (card.statut == CarteStatut.ouverte && + card.isCreator && + (card.isCongeAm || + card.responseMode == CarteResponseMode.ack)) ...[ + const SizedBox(height: 8), + Text( + card.responseMode == CarteResponseMode.ack + ? 'En attente d’acquittement' + : 'En attente de réponse', + style: GoogleFonts.merriweather( + fontSize: 12, + color: QuotidienTheme.muted, + ), + ), + ], + ], + ), + ), + if (card.iconAsset != null) ...[ + const SizedBox(width: 8), + Image.asset( + card.iconAsset!, + width: 72, + height: 72, + fit: BoxFit.contain, + filterQuality: FilterQuality.high, + ), + ], + ], + ), + const SizedBox(height: 10), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (card.peutRepondre) + busy + ? const SizedBox( + width: 28, + height: 28, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : card.responseMode == CarteResponseMode.ack + ? _PilulePeinteButton( + asset: + 'assets/images/cartes/btn_coche_pilule.png', + tooltip: 'Bien reçu', + onTap: onAck, + ) + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + _PilulePeinteButton( + asset: + 'assets/images/cartes/btn_coche_pilule.png', + tooltip: 'Accepter', + onTap: onAccept, + ), + const SizedBox(width: 10), + _PilulePeinteButton( + asset: + 'assets/images/cartes/btn_croix_pilule.png', + tooltip: 'Refuser', + onTap: onRefuse, + ), + ], + ) + else if (_montreStatutReponse) + Expanded( + child: Text( + _libelleStatutReponse, + textAlign: TextAlign.center, + style: GoogleFonts.merienda( + fontSize: 14, + fontWeight: FontWeight.w600, + fontStyle: FontStyle.italic, + color: encre.ink.withValues(alpha: 0.75), + ), + ), + ), + if (card.peutRepondre) const SizedBox(width: 8), + if (!_montreStatutReponse) const Spacer(), + InkWell( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Agenda — à venir.'), + duration: Duration(seconds: 2), + ), + ); + }, + borderRadius: BorderRadius.circular(6), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 4, + ), + child: Text( + 'Ouvrir dans l’agenda ↗', + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.right, + style: GoogleFonts.merienda( + fontSize: 13, + fontWeight: FontWeight.w600, + color: encre.link, + decoration: TextDecoration.underline, + decorationColor: encre.link, + ), + ), + ), + ), + ], + ), + ], + ), + ), + ); + } + + Widget _buildGeneric(BuildContext context) { + final memeJour = card.dateDebut != null && + card.dateFin != null && + card.dateDebut == card.dateFin; + + return Opacity( + opacity: _attenuee ? 0.55 : 1, + child: Carte( + assetPath: card.backgroundAsset, + constraints: const BoxConstraints(minHeight: 120), + padding: const EdgeInsets.fromLTRB(22, 16, 22, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + card.titreAffiche, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.merienda( + fontSize: 18, + fontWeight: FontWeight.w700, + color: QuotidienTheme.ink, + ), + ), + if (card.dateDebut != null || card.dateFin != null) ...[ + const SizedBox(height: 10), + Padding( + padding: const EdgeInsets.only(left: 12), + child: memeJour + ? Text( + 'Le ${_fmtLong(card.dateDebut)}', + style: GoogleFonts.merienda( + fontSize: 17, + fontWeight: FontWeight.w600, + color: QuotidienTheme.ink, + height: 1.35, + ), + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Du ${_fmtLong(card.dateDebut)}', + style: GoogleFonts.merienda( + fontSize: 17, + fontWeight: FontWeight.w600, + color: QuotidienTheme.ink, + height: 1.35, + ), + ), + Text( + 'Au ${_fmtLong(card.dateFin)}', + style: GoogleFonts.merienda( + fontSize: 17, + fontWeight: FontWeight.w600, + color: QuotidienTheme.ink, + height: 1.35, + ), + ), + ], + ), + ), + ], + ], + ), + ), + if (card.iconAsset != null) ...[ + const SizedBox(width: 10), + Image.asset( + card.iconAsset!, + width: 80, + height: 80, + fit: BoxFit.contain, + filterQuality: FilterQuality.high, + ), + ], + ], + ), + if (card.statut == CarteStatut.refusee) ...[ + const SizedBox(height: 6), + Text( + card.lastRefuseComment != null && + card.lastRefuseComment!.trim().isNotEmpty + ? 'Refusé — ${card.lastRefuseComment}' + : 'Refusé', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.merriweather( + fontSize: 12, + fontStyle: FontStyle.italic, + color: QuotidienTheme.ink, + ), + ), + ] else if (card.statut == CarteStatut.ouverte && + card.isCreator) ...[ + const SizedBox(height: 6), + Text( + 'En attente de réponse', + style: GoogleFonts.merriweather( + fontSize: 12, + color: QuotidienTheme.muted, + ), + ), + ], + if (card.peutRepondre) ...[ + const SizedBox(height: 14), + if (busy) + const Center( + child: SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + else if (card.responseMode == CarteResponseMode.acceptRefuse) + Row( + children: [ + Expanded( + child: _ActionPill( + asset: QuotidienTheme.pillTurquoiseAsset, + iconAsset: 'assets/images/coche.png', + tooltip: 'Accepter', + onTap: onAccept, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _ActionPill( + asset: QuotidienTheme.pillCorailAsset, + iconAsset: 'assets/images/red_cross2.png', + tooltip: 'Refuser', + onTap: onRefuse, + ), + ), + ], + ) + else if (card.responseMode == CarteResponseMode.ack) + _ActionPill( + asset: QuotidienTheme.pillTurquoiseAsset, + iconAsset: 'assets/images/coche.png', + tooltip: 'Bien reçu', + onTap: onAck, + ), + ], + ], + ), + ), + ); + } +} + +/// Modale de refus : le controller vit avec le State (Échap / Annuler OK). +class _RefuseDialog extends StatefulWidget { + final String title; + final String message; + + const _RefuseDialog({ + required this.title, + required this.message, + }); + + @override + State<_RefuseDialog> createState() => _RefuseDialogState(); +} + +class _RefuseDialogState extends State<_RefuseDialog> { + late final TextEditingController _controller; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text( + widget.title, + style: GoogleFonts.merienda(fontWeight: FontWeight.w600), + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + widget.message, + style: GoogleFonts.merriweather(fontSize: 14, height: 1.4), + ), + const SizedBox(height: 12), + TextField( + controller: _controller, + maxLines: 3, + autofocus: true, + decoration: const InputDecoration( + hintText: 'Motif du refus…', + border: OutlineInputBorder(), + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Annuler'), + ), + FilledButton( + onPressed: () { + final text = _controller.text.trim(); + if (text.isEmpty) return; + Navigator.pop(context, text); + }, + child: const Text('Refuser'), + ), + ], + ); + } +} + +/// Bouton pilule peinte (coche / croix intégrées) — carte congés. +class _PilulePeinteButton extends StatelessWidget { + final String asset; + final String tooltip; + final VoidCallback onTap; + + const _PilulePeinteButton({ + required this.asset, + required this.tooltip, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Tooltip( + message: tooltip, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(20), + child: Image.asset( + asset, + height: 36, + fit: BoxFit.contain, + filterQuality: FilterQuality.high, + ), + ), + ); + } +} + +/// Bouton pilule dessiné (fond pastel + coche / croix). +class _ActionPill extends StatelessWidget { + final String asset; + final String iconAsset; + final String tooltip; + final VoidCallback onTap; + + const _ActionPill({ + required this.asset, + required this.iconAsset, + required this.tooltip, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Tooltip( + message: tooltip, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(24), + child: SizedBox( + height: 40, + child: Stack( + alignment: Alignment.center, + children: [ + Positioned.fill( + child: Image.asset(asset, fit: BoxFit.fill), + ), + Image.asset( + iconAsset, + height: 22, + width: 22, + fit: BoxFit.contain, + ), + ], + ), + ), + ), + ); + } +} diff --git a/frontend/lib/widgets/quotidien/couple_selector_bandeau.dart b/frontend/lib/widgets/quotidien/couple_selector_bandeau.dart index a95671c..a910baa 100644 --- a/frontend/lib/widgets/quotidien/couple_selector_bandeau.dart +++ b/frontend/lib/widgets/quotidien/couple_selector_bandeau.dart @@ -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 @@ -174,14 +175,10 @@ class _CoupleCard extends StatelessWidget { ); } - return Container( + return Carte.bandeau( + assetPath: QuotidienTheme + .bandeauColors[colorIndex % QuotidienTheme.bandeauColors.length], height: 90, - decoration: BoxDecoration( - image: DecorationImage( - image: AssetImage(QuotidienTheme.bandeauColors[colorIndex % QuotidienTheme.bandeauColors.length]), - fit: BoxFit.fill, - ), - ), padding: const EdgeInsets.symmetric(horizontal: 24), child: Row( children: [ @@ -190,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, @@ -215,12 +213,12 @@ class _CoupleCard extends StatelessWidget { ), 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, ), ], ), @@ -306,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), - ), ); } } @@ -333,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: [ @@ -369,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'), + ), + ], + ), ), ); } diff --git a/frontend/lib/widgets/quotidien/quotidien_theme.dart b/frontend/lib/widgets/quotidien/quotidien_theme.dart index dd5c3bf..9c99701 100644 --- a/frontend/lib/widgets/quotidien/quotidien_theme.dart +++ b/frontend/lib/widgets/quotidien/quotidien_theme.dart @@ -49,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. diff --git a/frontend/pubspec.yaml b/frontend/pubspec.yaml index ccad368..ac1c4c5 100644 --- a/frontend/pubspec.yaml +++ b/frontend/pubspec.yaml @@ -50,8 +50,9 @@ 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: