Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd78bc0116 | ||
|
|
d5a857ae6b |
@@ -5,6 +5,7 @@ import {
|
||||
import { Users } from './users.entity';
|
||||
import { ParentsChildren } from './parents_children.entity';
|
||||
import { Dossier } from './dossiers.entity';
|
||||
import { AmChildren } from './am_children.entity';
|
||||
|
||||
@Entity('parents', { schema: 'public' })
|
||||
export class Parents {
|
||||
@@ -25,6 +26,17 @@ export class Parents {
|
||||
@Column({ name: 'numero_dossier', length: 20, nullable: true })
|
||||
numero_dossier?: string;
|
||||
|
||||
/**
|
||||
* Placement AM↔enfant sélectionné sur le TdB parent (couple actif) — ticket #168.
|
||||
* Null / absent = le front prend le premier couple actif retourné par l’API.
|
||||
*/
|
||||
@Column({ name: 'id_placement_garde_courant', type: 'uuid', nullable: true })
|
||||
id_placement_garde_courant?: string;
|
||||
|
||||
@ManyToOne(() => AmChildren, { nullable: true, onDelete: 'SET NULL' })
|
||||
@JoinColumn({ name: 'id_placement_garde_courant', referencedColumnName: 'id' })
|
||||
placement_garde_courant?: AmChildren;
|
||||
|
||||
// Lien vers enfants via la table enfants_parents
|
||||
@OneToMany(() => ParentsChildren, pc => pc.parent)
|
||||
parentChildren: ParentsChildren[];
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
/** Identité minimale enfant pour le bandeau couple — ticket #168 */
|
||||
export class CoupleGardeEnfantDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
id: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
prenom?: string | null;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
nom?: string | null;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
photo_url?: string | null;
|
||||
}
|
||||
|
||||
/** Identité minimale AM pour le bandeau couple — ticket #168 */
|
||||
export class CoupleGardeAmDto {
|
||||
@ApiProperty({ format: 'uuid', description: 'UUID utilisateur de l’AM' })
|
||||
id: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
prenom?: string | null;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
nom?: string | null;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
photo_url?: string | null;
|
||||
}
|
||||
|
||||
/** Un couple de garde = placement actif enfant ↔ AM */
|
||||
export class CoupleGardeDto {
|
||||
@ApiProperty({
|
||||
format: 'uuid',
|
||||
description: 'Id du placement (enfants_assistantes_maternelles.id)',
|
||||
})
|
||||
id: string;
|
||||
|
||||
@ApiProperty({ type: CoupleGardeEnfantDto })
|
||||
enfant: CoupleGardeEnfantDto;
|
||||
|
||||
@ApiProperty({ type: CoupleGardeAmDto })
|
||||
am: CoupleGardeAmDto;
|
||||
|
||||
@ApiProperty({ description: 'True si c’est le couple actuellement sélectionné' })
|
||||
courant: boolean;
|
||||
}
|
||||
|
||||
export class CouplesGardeResponseDto {
|
||||
@ApiProperty({ type: [CoupleGardeDto] })
|
||||
couples: CoupleGardeDto[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
format: 'uuid',
|
||||
nullable: true,
|
||||
description: 'Id du couple courant (null si aucun / premier couple implicite côté client)',
|
||||
})
|
||||
couple_courant_id: string | null;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsUUID } from 'class-validator';
|
||||
|
||||
/** Corps PUT couple de garde courant — ticket #168 */
|
||||
export class DefinirCoupleGardeCourantDto {
|
||||
@ApiProperty({
|
||||
format: 'uuid',
|
||||
description: 'Id du placement (enfants_assistantes_maternelles.id) à sélectionner',
|
||||
})
|
||||
@IsUUID()
|
||||
@IsNotEmpty()
|
||||
couple_id: string;
|
||||
}
|
||||
@@ -13,7 +13,10 @@ describe('ParentsController', () => {
|
||||
createParentDossierStaff: jest.fn(),
|
||||
addCoParentStaff: jest.fn(),
|
||||
};
|
||||
const parentsServiceMock = {};
|
||||
const parentsServiceMock = {
|
||||
listerCouplesGarde: jest.fn(),
|
||||
definirCoupleGardeCourant: jest.fn(),
|
||||
};
|
||||
const userServiceMock = {};
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -39,6 +42,31 @@ describe('ParentsController', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
it('listerCouplesGarde délègue au service (#168)', async () => {
|
||||
parentsServiceMock.listerCouplesGarde.mockResolvedValue({
|
||||
couples: [],
|
||||
couple_courant_id: null,
|
||||
});
|
||||
const res = await controller.listerCouplesGarde('parent-uuid');
|
||||
expect(parentsServiceMock.listerCouplesGarde).toHaveBeenCalledWith('parent-uuid');
|
||||
expect(res.couples).toEqual([]);
|
||||
});
|
||||
|
||||
it('definirCoupleGardeCourant délègue au service (#168)', async () => {
|
||||
parentsServiceMock.definirCoupleGardeCourant.mockResolvedValue({
|
||||
couples: [{ id: 'pl-1', courant: true }],
|
||||
couple_courant_id: 'pl-1',
|
||||
});
|
||||
const res = await controller.definirCoupleGardeCourant('parent-uuid', {
|
||||
couple_id: 'pl-1',
|
||||
});
|
||||
expect(parentsServiceMock.definirCoupleGardeCourant).toHaveBeenCalledWith(
|
||||
'parent-uuid',
|
||||
'pl-1',
|
||||
);
|
||||
expect(res.couple_courant_id).toBe('pl-1');
|
||||
});
|
||||
|
||||
it('createDossier delegates to authService.createParentDossierStaff with CGU accepted', async () => {
|
||||
authServiceMock.createParentDossierStaff.mockResolvedValue({
|
||||
message: 'Dossier famille créé et validé. Un e-mail de création de mot de passe a été envoyé.',
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ParentsService } from './parents.service';
|
||||
@@ -39,6 +40,8 @@ import { User } from 'src/common/decorators/user.decorator';
|
||||
import { PendingFamilyDto } from './dto/pending-family.dto';
|
||||
import { DossierFamilleCompletDto } from './dto/dossier-famille-complet.dto';
|
||||
import { mapParentForApi, mapParentsForApi } from './parents.mapper';
|
||||
import { CouplesGardeResponseDto } from './dto/couples-garde.dto';
|
||||
import { DefinirCoupleGardeCourantDto } from './dto/definir-couple-garde-courant.dto';
|
||||
|
||||
@ApiTags('Parents')
|
||||
@ApiBearerAuth('access-token')
|
||||
@@ -51,6 +54,39 @@ export class ParentsController {
|
||||
private readonly authService: AuthService,
|
||||
) {}
|
||||
|
||||
@Get('me/couples-garde')
|
||||
@Roles(RoleType.PARENT)
|
||||
@ApiOperation({
|
||||
summary: 'Lister les couples de garde du parent connecté — ticket #168',
|
||||
description:
|
||||
'Retourne les placements actifs enfant↔AM rattachés au parent, ' +
|
||||
'avec indication du couple courant (bandeau TdB quotidien).',
|
||||
})
|
||||
@ApiResponse({ status: 200, type: CouplesGardeResponseDto })
|
||||
@ApiResponse({ status: 403, description: 'Réservé au rôle parent' })
|
||||
@ApiResponse({ status: 404, description: 'Parent introuvable' })
|
||||
listerCouplesGarde(@User('id') userId: string): Promise<CouplesGardeResponseDto> {
|
||||
return this.parentsService.listerCouplesGarde(userId);
|
||||
}
|
||||
|
||||
@Put('me/couples-garde/courant')
|
||||
@Roles(RoleType.PARENT)
|
||||
@ApiOperation({
|
||||
summary: 'Définir le couple de garde courant — ticket #168',
|
||||
description:
|
||||
'Persiste la préférence de couple actif (enfant|nounou) pour contextualiser le TdB.',
|
||||
})
|
||||
@ApiBody({ type: DefinirCoupleGardeCourantDto })
|
||||
@ApiResponse({ status: 200, type: CouplesGardeResponseDto })
|
||||
@ApiResponse({ status: 400, description: 'Couple hors périmètre du parent' })
|
||||
@ApiResponse({ status: 404, description: 'Parent ou couple introuvable' })
|
||||
definirCoupleGardeCourant(
|
||||
@User('id') userId: string,
|
||||
@Body() dto: DefinirCoupleGardeCourantDto,
|
||||
): Promise<CouplesGardeResponseDto> {
|
||||
return this.parentsService.definirCoupleGardeCourant(userId, dto.couple_id);
|
||||
}
|
||||
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||
@Post('dossier')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
|
||||
@@ -5,6 +5,7 @@ import { JwtModule } from '@nestjs/jwt';
|
||||
import { Parents } from 'src/entities/parents.entity';
|
||||
import { DossierFamille, DossierFamilleEnfant } from 'src/entities/dossier_famille.entity';
|
||||
import { ParentsChildren } from 'src/entities/parents_children.entity';
|
||||
import { AmChildren } from 'src/entities/am_children.entity';
|
||||
import { ParentsController } from './parents.controller';
|
||||
import { ParentsService } from './parents.service';
|
||||
import { Users } from 'src/entities/users.entity';
|
||||
@@ -13,7 +14,14 @@ import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Parents, Users, DossierFamille, DossierFamilleEnfant, ParentsChildren]),
|
||||
TypeOrmModule.forFeature([
|
||||
Parents,
|
||||
Users,
|
||||
DossierFamille,
|
||||
DossierFamilleEnfant,
|
||||
ParentsChildren,
|
||||
AmChildren,
|
||||
]),
|
||||
forwardRef(() => UserModule),
|
||||
forwardRef(() => AuthModule),
|
||||
JwtModule.registerAsync({
|
||||
|
||||
@@ -1,12 +1,43 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { ParentsService } from './parents.service';
|
||||
import { Parents } from 'src/entities/parents.entity';
|
||||
import { Users } from 'src/entities/users.entity';
|
||||
import { DossierFamille } from 'src/entities/dossier_famille.entity';
|
||||
import { ParentsChildren } from 'src/entities/parents_children.entity';
|
||||
import { AmChildren } from 'src/entities/am_children.entity';
|
||||
|
||||
describe('ParentsService', () => {
|
||||
describe('ParentsService — couples de garde (#168)', () => {
|
||||
let service: ParentsService;
|
||||
|
||||
const parentsRepository = {
|
||||
findOne: jest.fn(),
|
||||
update: jest.fn(),
|
||||
};
|
||||
const parentsChildrenRepository = {
|
||||
find: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
};
|
||||
const amChildrenRepository = {
|
||||
find: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [ParentsService],
|
||||
providers: [
|
||||
ParentsService,
|
||||
{ provide: getRepositoryToken(Parents), useValue: parentsRepository },
|
||||
{ provide: getRepositoryToken(Users), useValue: {} },
|
||||
{ provide: getRepositoryToken(DossierFamille), useValue: {} },
|
||||
{
|
||||
provide: getRepositoryToken(ParentsChildren),
|
||||
useValue: parentsChildrenRepository,
|
||||
},
|
||||
{ provide: getRepositoryToken(AmChildren), useValue: amChildrenRepository },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<ParentsService>(ParentsService);
|
||||
@@ -15,4 +46,107 @@ describe('ParentsService', () => {
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('listerCouplesGarde', () => {
|
||||
it('retourne une liste vide si le parent n’a pas d’enfant', async () => {
|
||||
parentsRepository.findOne.mockResolvedValue({
|
||||
user_id: 'p1',
|
||||
id_placement_garde_courant: null,
|
||||
});
|
||||
parentsChildrenRepository.find.mockResolvedValue([]);
|
||||
|
||||
const res = await service.listerCouplesGarde('p1');
|
||||
expect(res).toEqual({ couples: [], couple_courant_id: null });
|
||||
expect(amChildrenRepository.find).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('mappe les placements actifs en couples et marque le courant', async () => {
|
||||
parentsRepository.findOne.mockResolvedValue({
|
||||
user_id: 'p1',
|
||||
id_placement_garde_courant: 'pl-2',
|
||||
});
|
||||
parentsChildrenRepository.find.mockResolvedValue([
|
||||
{ enfantId: 'e1' },
|
||||
{ enfantId: 'e2' },
|
||||
]);
|
||||
amChildrenRepository.find.mockResolvedValue([
|
||||
{
|
||||
id: 'pl-1',
|
||||
amId: 'am-1',
|
||||
child: { id: 'e1', first_name: 'Léo', last_name: 'M', photo_url: null },
|
||||
am: { user: { prenom: 'Marie', nom: 'N', photo_url: '/a.jpg' } },
|
||||
},
|
||||
{
|
||||
id: 'pl-2',
|
||||
amId: 'am-2',
|
||||
child: { id: 'e2', first_name: 'Léa', last_name: 'M', photo_url: null },
|
||||
am: { user: { prenom: 'Sophie', nom: 'P', photo_url: null } },
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await service.listerCouplesGarde('p1');
|
||||
expect(res.couples).toHaveLength(2);
|
||||
expect(res.couple_courant_id).toBe('pl-2');
|
||||
expect(res.couples[1].courant).toBe(true);
|
||||
expect(res.couples[0].am.prenom).toBe('Marie');
|
||||
expect(res.couples[0].enfant.prenom).toBe('Léo');
|
||||
});
|
||||
|
||||
it('404 si parent inconnu', async () => {
|
||||
parentsRepository.findOne.mockResolvedValue(null);
|
||||
await expect(service.listerCouplesGarde('x')).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('definirCoupleGardeCourant', () => {
|
||||
it('persiste le couple si l’enfant est rattaché au parent', async () => {
|
||||
parentsRepository.findOne.mockResolvedValue({
|
||||
user_id: 'p1',
|
||||
id_placement_garde_courant: null,
|
||||
});
|
||||
amChildrenRepository.findOne.mockResolvedValue({
|
||||
id: 'pl-1',
|
||||
enfantId: 'e1',
|
||||
date_fin: null,
|
||||
});
|
||||
parentsChildrenRepository.findOne.mockResolvedValue({
|
||||
parentId: 'p1',
|
||||
enfantId: 'e1',
|
||||
});
|
||||
parentsRepository.update.mockResolvedValue({ affected: 1 });
|
||||
// second call via listerCouplesGarde
|
||||
parentsChildrenRepository.find.mockResolvedValue([{ enfantId: 'e1' }]);
|
||||
amChildrenRepository.find.mockResolvedValue([
|
||||
{
|
||||
id: 'pl-1',
|
||||
amId: 'am-1',
|
||||
child: { id: 'e1', first_name: 'Léo', last_name: null, photo_url: null },
|
||||
am: { user: { prenom: 'Marie', nom: null, photo_url: null } },
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await service.definirCoupleGardeCourant('p1', 'pl-1');
|
||||
expect(parentsRepository.update).toHaveBeenCalledWith(
|
||||
{ user_id: 'p1' },
|
||||
{ id_placement_garde_courant: 'pl-1' },
|
||||
);
|
||||
expect(res.couple_courant_id).toBe('pl-1');
|
||||
expect(res.couples[0].courant).toBe(true);
|
||||
});
|
||||
|
||||
it('400 si le couple n’appartient pas au parent', async () => {
|
||||
parentsRepository.findOne.mockResolvedValue({ user_id: 'p1' });
|
||||
amChildrenRepository.findOne.mockResolvedValue({
|
||||
id: 'pl-1',
|
||||
enfantId: 'e99',
|
||||
});
|
||||
parentsChildrenRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.definirCoupleGardeCourant('p1', 'pl-1'),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { In, Repository } from 'typeorm';
|
||||
import { In, IsNull, Repository } from 'typeorm';
|
||||
import { Parents } from 'src/entities/parents.entity';
|
||||
import { DossierFamille } from 'src/entities/dossier_famille.entity';
|
||||
import { RoleType, Users } from 'src/entities/users.entity';
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
import { ParentsChildren } from 'src/entities/parents_children.entity';
|
||||
import { Children } from 'src/entities/children.entity';
|
||||
import { UpdateParentFicheAdminDto } from './dto/update-parent-fiche-admin.dto';
|
||||
import { AmChildren } from 'src/entities/am_children.entity';
|
||||
import { CouplesGardeResponseDto } from './dto/couples-garde.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ParentsService {
|
||||
@@ -32,6 +34,8 @@ export class ParentsService {
|
||||
private readonly dossierFamilleRepository: Repository<DossierFamille>,
|
||||
@InjectRepository(ParentsChildren)
|
||||
private readonly parentsChildrenRepository: Repository<ParentsChildren>,
|
||||
@InjectRepository(AmChildren)
|
||||
private readonly amChildrenRepository: Repository<AmChildren>,
|
||||
) {}
|
||||
|
||||
// Création d’un parent
|
||||
@@ -505,4 +509,108 @@ export class ParentsService {
|
||||
}
|
||||
return raw.map((r: { id: string }) => r.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste les couples de garde (enfant ↔ AM) du parent connecté — ticket #168.
|
||||
* Un couple = un placement actif dans enfants_assistantes_maternelles pour un enfant du parent.
|
||||
*/
|
||||
async listerCouplesGarde(parentUserId: string): Promise<CouplesGardeResponseDto> {
|
||||
const parent = await this.parentsRepository.findOne({
|
||||
where: { user_id: parentUserId },
|
||||
});
|
||||
if (!parent) {
|
||||
throw new NotFoundException('Parent introuvable');
|
||||
}
|
||||
|
||||
const liensEnfants = await this.parentsChildrenRepository.find({
|
||||
where: { parentId: parentUserId },
|
||||
select: ['enfantId'],
|
||||
});
|
||||
const enfantIds = liensEnfants.map((l) => l.enfantId);
|
||||
if (enfantIds.length === 0) {
|
||||
return { couples: [], couple_courant_id: null };
|
||||
}
|
||||
|
||||
const placements = await this.amChildrenRepository.find({
|
||||
where: { enfantId: In(enfantIds), date_fin: IsNull() },
|
||||
relations: ['child', 'am', 'am.user'],
|
||||
order: { date_debut: 'ASC' },
|
||||
});
|
||||
|
||||
let idCourant = parent.id_placement_garde_courant ?? null;
|
||||
const idsValides = new Set(placements.map((p) => p.id));
|
||||
if (idCourant && !idsValides.has(idCourant)) {
|
||||
idCourant = null;
|
||||
await this.parentsRepository.update(
|
||||
{ user_id: parentUserId },
|
||||
{ id_placement_garde_courant: () => 'NULL' },
|
||||
);
|
||||
}
|
||||
if (!idCourant && placements.length === 1) {
|
||||
idCourant = placements[0].id;
|
||||
}
|
||||
|
||||
const couples = placements.map((p) => {
|
||||
const amUser = p.am?.user;
|
||||
return {
|
||||
id: p.id,
|
||||
enfant: {
|
||||
id: p.child.id,
|
||||
prenom: p.child.first_name ?? null,
|
||||
nom: p.child.last_name ?? null,
|
||||
photo_url: p.child.photo_url ?? null,
|
||||
},
|
||||
am: {
|
||||
id: p.amId,
|
||||
prenom: amUser?.prenom ?? null,
|
||||
nom: amUser?.nom ?? null,
|
||||
photo_url: amUser?.photo_url ?? null,
|
||||
},
|
||||
courant: idCourant != null && p.id === idCourant,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
couples,
|
||||
couple_courant_id: idCourant,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Persiste le couple de garde actif pour le parent — ticket #168.
|
||||
*/
|
||||
async definirCoupleGardeCourant(
|
||||
parentUserId: string,
|
||||
coupleId: string,
|
||||
): Promise<CouplesGardeResponseDto> {
|
||||
const parent = await this.parentsRepository.findOne({
|
||||
where: { user_id: parentUserId },
|
||||
});
|
||||
if (!parent) {
|
||||
throw new NotFoundException('Parent introuvable');
|
||||
}
|
||||
|
||||
const placement = await this.amChildrenRepository.findOne({
|
||||
where: { id: coupleId, date_fin: IsNull() },
|
||||
});
|
||||
if (!placement) {
|
||||
throw new NotFoundException('Couple de garde introuvable ou inactif');
|
||||
}
|
||||
|
||||
const lien = await this.parentsChildrenRepository.findOne({
|
||||
where: { parentId: parentUserId, enfantId: placement.enfantId },
|
||||
});
|
||||
if (!lien) {
|
||||
throw new BadRequestException(
|
||||
'Ce couple ne concerne pas un enfant rattaché à ce parent',
|
||||
);
|
||||
}
|
||||
|
||||
await this.parentsRepository.update(
|
||||
{ user_id: parentUserId },
|
||||
{ id_placement_garde_courant: coupleId },
|
||||
);
|
||||
|
||||
return this.listerCouplesGarde(parentUserId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,7 +154,10 @@ CREATE INDEX idx_assistantes_maternelles_numero_dossier
|
||||
CREATE TABLE parents (
|
||||
id_utilisateur UUID PRIMARY KEY REFERENCES utilisateurs(id) ON DELETE CASCADE,
|
||||
id_co_parent UUID REFERENCES utilisateurs(id),
|
||||
numero_dossier VARCHAR(20)
|
||||
numero_dossier VARCHAR(20),
|
||||
-- Préférence couple de garde actif (TdB quotidien) — ticket #168
|
||||
-- FK ajoutée après création de enfants_assistantes_maternelles (voir ALTER plus bas)
|
||||
id_placement_garde_courant UUID
|
||||
);
|
||||
|
||||
CREATE INDEX idx_parents_numero_dossier
|
||||
@@ -206,6 +209,16 @@ CREATE UNIQUE INDEX uq_enfant_garde_active
|
||||
ON enfants_assistantes_maternelles (id_enfant)
|
||||
WHERE date_fin IS NULL;
|
||||
|
||||
-- FK couple courant parent → placement (#168) — après table enfants_assistantes_maternelles
|
||||
ALTER TABLE parents
|
||||
ADD CONSTRAINT fk_parents_placement_garde_courant
|
||||
FOREIGN KEY (id_placement_garde_courant)
|
||||
REFERENCES enfants_assistantes_maternelles(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX idx_parents_placement_garde_courant
|
||||
ON parents(id_placement_garde_courant)
|
||||
WHERE id_placement_garde_courant IS NOT NULL;
|
||||
|
||||
-- ==========================================================
|
||||
-- Table : dossier_famille (inscription parent — ticket #119)
|
||||
-- ==========================================================
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Ticket #168 — Couple de garde courant (préférence parent)
|
||||
-- Idempotent : safe à rejouer.
|
||||
|
||||
ALTER TABLE parents
|
||||
ADD COLUMN IF NOT EXISTS id_placement_garde_courant UUID
|
||||
REFERENCES enfants_assistantes_maternelles(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_parents_placement_garde_courant
|
||||
ON parents(id_placement_garde_courant)
|
||||
WHERE id_placement_garde_courant IS NOT NULL;
|
||||
|
Before Width: | Height: | Size: 216 KiB |
|
Before Width: | Height: | Size: 249 KiB |
|
Before Width: | Height: | Size: 277 KiB |
|
Before Width: | Height: | Size: 171 KiB |
|
Before Width: | Height: | Size: 290 KiB |
|
Before Width: | Height: | Size: 239 KiB |
|
Before Width: | Height: | Size: 441 KiB |
|
Before Width: | Height: | Size: 169 KiB |
|
Before Width: | Height: | Size: 223 KiB |
|
Before Width: | Height: | Size: 253 KiB |
|
Before Width: | Height: | Size: 217 KiB |
|
Before Width: | Height: | Size: 289 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 41 KiB |
@@ -1,113 +0,0 @@
|
||||
/// Modèles du couple de garde (enfant ↔ AM) — ticket #167.
|
||||
/// Contrat backend #168 : GET /parents/me/couples-garde.
|
||||
|
||||
/// Identité minimale d'une personne du couple (enfant, AM ou parent).
|
||||
class CoupleMembre {
|
||||
final String id;
|
||||
final String? prenom;
|
||||
final String? nom;
|
||||
final String? photoUrl;
|
||||
|
||||
const CoupleMembre({
|
||||
required this.id,
|
||||
this.prenom,
|
||||
this.nom,
|
||||
this.photoUrl,
|
||||
});
|
||||
|
||||
/// Nom d'affichage : prénom seul si dispo, sinon « Prénom Nom », sinon repli.
|
||||
String displayName({String fallback = ''}) {
|
||||
final p = (prenom ?? '').trim();
|
||||
final n = (nom ?? '').trim();
|
||||
if (p.isNotEmpty && n.isNotEmpty) return '$p $n';
|
||||
if (p.isNotEmpty) return p;
|
||||
if (n.isNotEmpty) return n;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
factory CoupleMembre.fromJson(Map<String, dynamic> json) {
|
||||
return CoupleMembre(
|
||||
id: (json['id'] ?? '').toString(),
|
||||
prenom: json['prenom']?.toString(),
|
||||
nom: json['nom']?.toString(),
|
||||
photoUrl: json['photo_url']?.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Un couple de garde = placement actif enfant ↔ AM.
|
||||
class CoupleGarde {
|
||||
final String id;
|
||||
final CoupleMembre enfant;
|
||||
final CoupleMembre am;
|
||||
final bool courant;
|
||||
|
||||
const CoupleGarde({
|
||||
required this.id,
|
||||
required this.enfant,
|
||||
required this.am,
|
||||
this.courant = false,
|
||||
});
|
||||
|
||||
factory CoupleGarde.fromJson(Map<String, dynamic> json) {
|
||||
return CoupleGarde(
|
||||
id: (json['id'] ?? '').toString(),
|
||||
enfant: CoupleMembre.fromJson(
|
||||
Map<String, dynamic>.from(json['enfant'] ?? const {}),
|
||||
),
|
||||
am: CoupleMembre.fromJson(
|
||||
Map<String, dynamic>.from(json['am'] ?? const {}),
|
||||
),
|
||||
courant: json['courant'] == true,
|
||||
);
|
||||
}
|
||||
|
||||
CoupleGarde copyWith({bool? courant}) {
|
||||
return CoupleGarde(
|
||||
id: id,
|
||||
enfant: enfant,
|
||||
am: am,
|
||||
courant: courant ?? this.courant,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Réponse de l'API couples de garde (liste + id du couple courant).
|
||||
class CouplesGardeResponse {
|
||||
final List<CoupleGarde> couples;
|
||||
final String? coupleCourantId;
|
||||
|
||||
const CouplesGardeResponse({
|
||||
required this.couples,
|
||||
this.coupleCourantId,
|
||||
});
|
||||
|
||||
bool get isEmpty => couples.isEmpty;
|
||||
bool get isUnique => couples.length == 1;
|
||||
|
||||
/// Couple courant : celui marqué `courant`, sinon celui de [coupleCourantId],
|
||||
/// sinon le premier (repli implicite côté client, prévu par le back).
|
||||
CoupleGarde? get coupleCourant {
|
||||
if (couples.isEmpty) return null;
|
||||
for (final c in couples) {
|
||||
if (c.courant) return c;
|
||||
}
|
||||
if (coupleCourantId != null) {
|
||||
for (final c in couples) {
|
||||
if (c.id == coupleCourantId) return c;
|
||||
}
|
||||
}
|
||||
return couples.first;
|
||||
}
|
||||
|
||||
factory CouplesGardeResponse.fromJson(Map<String, dynamic> json) {
|
||||
final list = (json['couples'] as List?) ?? const [];
|
||||
return CouplesGardeResponse(
|
||||
couples: list
|
||||
.whereType<Map>()
|
||||
.map((e) => CoupleGarde.fromJson(Map<String, dynamic>.from(e)))
|
||||
.toList(),
|
||||
coupleCourantId: json['couple_courant_id']?.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,36 +1,41 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:p_tits_pas/models/couple_garde.dart';
|
||||
import 'package:p_tits_pas/controllers/parent_dashboard_controller.dart';
|
||||
import 'package:p_tits_pas/models/user.dart';
|
||||
import 'package:p_tits_pas/services/auth_service.dart';
|
||||
import 'package:p_tits_pas/services/couple_garde_service.dart';
|
||||
import 'package:p_tits_pas/widgets/quotidien/couple_selector_bandeau.dart';
|
||||
import 'package:p_tits_pas/widgets/quotidien/quotidien_shell.dart';
|
||||
import 'package:p_tits_pas/widgets/quotidien/quotidien_theme.dart';
|
||||
import 'package:p_tits_pas/services/dashboardService.dart';
|
||||
import 'package:p_tits_pas/widgets/app_footer.dart';
|
||||
import 'package:p_tits_pas/widgets/dashbord_parent/children_sidebar.dart';
|
||||
import 'package:p_tits_pas/widgets/dashbord_parent/wid_dashbord.dart';
|
||||
import 'package:p_tits_pas/widgets/dashboard/dashboard_bandeau.dart';
|
||||
import 'package:p_tits_pas/widgets/main_content_area.dart';
|
||||
import 'package:p_tits_pas/widgets/messaging_sidebar.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
/// Tableau de bord parent — coquille 3 colonnes quotidien (#166).
|
||||
/// Colonne gauche : sélecteur de couple enfant–nounou (#167).
|
||||
/// Métier cartes / blog / messagerie : tickets C/D/E.
|
||||
class ParentDashboardScreen extends StatefulWidget {
|
||||
const ParentDashboardScreen({super.key});
|
||||
const ParentDashboardScreen({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<ParentDashboardScreen> createState() => _ParentDashboardScreenState();
|
||||
}
|
||||
|
||||
class _ParentDashboardScreenState extends State<ParentDashboardScreen> {
|
||||
QuotidienNavSection _section = QuotidienNavSection.liaison;
|
||||
int selectedIndex = 0;
|
||||
AppUser? _user;
|
||||
|
||||
List<CoupleGarde> _couples = const [];
|
||||
String? _selectedCoupleId;
|
||||
bool _couplesLoading = true;
|
||||
String? _couplesError;
|
||||
void onTabChange(int index) {
|
||||
setState(() {
|
||||
selectedIndex = index;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadUser();
|
||||
_loadCouples();
|
||||
// Initialiser les données du dashboard
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
context.read<ParentDashboardController>().initDashboard();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadUser() async {
|
||||
@@ -38,148 +43,218 @@ class _ParentDashboardScreenState extends State<ParentDashboardScreen> {
|
||||
if (mounted) setState(() => _user = user);
|
||||
}
|
||||
|
||||
Future<void> _loadCouples() async {
|
||||
setState(() {
|
||||
_couplesLoading = true;
|
||||
_couplesError = null;
|
||||
});
|
||||
try {
|
||||
final res = await CoupleGardeService.getCouplesGarde();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_couples = res.couples;
|
||||
_selectedCoupleId = res.coupleCourant?.id;
|
||||
_couplesLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_couplesError = e.toString().replaceFirst('Exception: ', '');
|
||||
_couplesLoading = false;
|
||||
});
|
||||
Widget _getBody() {
|
||||
switch (selectedIndex) {
|
||||
case 0:
|
||||
return Dashbord_body();
|
||||
case 1:
|
||||
return const Center(child: Text("🔍 Trouver une nounou"));
|
||||
case 2:
|
||||
return const Center(child: Text("⚙️ Paramètres"));
|
||||
default:
|
||||
return const Center(child: Text("Page non trouvée"));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _selectCouple(CoupleGarde couple) async {
|
||||
if (couple.id == _selectedCoupleId) return;
|
||||
// Optimiste : on bascule tout de suite, l'API persiste ensuite.
|
||||
setState(() => _selectedCoupleId = couple.id);
|
||||
try {
|
||||
final res = await CoupleGardeService.definirCoupleCourant(couple.id);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_couples = res.couples;
|
||||
_selectedCoupleId = res.coupleCourant?.id ?? couple.id;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Impossible de changer de garde : '
|
||||
'${e.toString().replaceFirst('Exception: ', '')}',
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ChangeNotifierProvider(
|
||||
create: (context) => ParentDashboardController(DashboardService())..initDashboard(),
|
||||
child: Scaffold(
|
||||
appBar: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(60.0),
|
||||
child: DashboardBandeau(
|
||||
tabItems: const [
|
||||
DashboardTabItem(label: 'Mon tableau de bord'),
|
||||
DashboardTabItem(label: 'Trouver une nounou'),
|
||||
DashboardTabItem(label: 'Paramètres'),
|
||||
],
|
||||
selectedTabIndex: selectedIndex,
|
||||
onTabSelected: onTabChange,
|
||||
userDisplayName: _user?.fullName.isNotEmpty == true
|
||||
? _user!.fullName
|
||||
: 'Parent',
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 'Parent';
|
||||
}
|
||||
|
||||
void _soon(String label) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('$label — à venir')),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return QuotidienShell(
|
||||
selectedSection: _section,
|
||||
onSectionSelected: (s) => setState(() => _section = s),
|
||||
userDisplayName: _displayName,
|
||||
userEmail: _user?.email,
|
||||
onProfileTap: () => _soon('Profil'),
|
||||
onSearchAmTap: () => _soon('Recherche AM'),
|
||||
onSettingsTap: () => _soon('Paramètres'),
|
||||
leftColumn: _LeftColumn(
|
||||
couples: _couples,
|
||||
selectedCoupleId: _selectedCoupleId,
|
||||
loading: _couplesLoading,
|
||||
error: _couplesError,
|
||||
onRetry: _loadCouples,
|
||||
onCoupleSelected: _selectCouple,
|
||||
),
|
||||
centerColumn: const QuotidienColumnPlaceholder(
|
||||
title: 'Blog',
|
||||
subtitle:
|
||||
'Fil du quotidien (affichage par défaut)\n(à brancher — ticket #178).',
|
||||
icon: Icons.auto_stories_outlined,
|
||||
),
|
||||
rightColumn: const QuotidienColumnPlaceholder(
|
||||
title: 'Messagerie',
|
||||
subtitle:
|
||||
'Mess. AM · Mess. RPE\n(à brancher — ticket #184).',
|
||||
icon: Icons.chat_bubble_outline,
|
||||
),
|
||||
agendaBody: const QuotidienStubPage(
|
||||
title: 'Agenda',
|
||||
message: 'Agenda — contenu à venir (stub #187).',
|
||||
),
|
||||
contratBody: const QuotidienStubPage(
|
||||
title: 'Contrat',
|
||||
message: 'Contrat — contenu à venir (stub #187).',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Colonne gauche : bandeau couple (#167) puis flux de cartes (#173 à venir).
|
||||
class _LeftColumn extends StatelessWidget {
|
||||
final List<CoupleGarde> couples;
|
||||
final String? selectedCoupleId;
|
||||
final bool loading;
|
||||
final String? error;
|
||||
final VoidCallback onRetry;
|
||||
final ValueChanged<CoupleGarde> onCoupleSelected;
|
||||
|
||||
const _LeftColumn({
|
||||
required this.couples,
|
||||
required this.selectedCoupleId,
|
||||
required this.loading,
|
||||
required this.error,
|
||||
required this.onRetry,
|
||||
required this.onCoupleSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
body: Column(
|
||||
children: [
|
||||
CoupleSelectorBandeau(
|
||||
couples: couples,
|
||||
selectedCoupleId: selectedCoupleId,
|
||||
loading: loading,
|
||||
errorMessage: error,
|
||||
onRetry: onRetry,
|
||||
onCoupleSelected: onCoupleSelected,
|
||||
Expanded(child: _getBody()),
|
||||
const AppFooter(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildResponsiveBody(BuildContext context, ParentDashboardController controller) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
if (constraints.maxWidth < 768) {
|
||||
// Layout mobile : colonnes empilées
|
||||
return _buildMobileLayout(controller);
|
||||
} else if (constraints.maxWidth < 1024) {
|
||||
// Layout tablette : 2 colonnes
|
||||
return _buildTabletLayout(controller);
|
||||
} else {
|
||||
// Layout desktop : 3 colonnes
|
||||
return _buildDesktopLayout(controller);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDesktopLayout(ParentDashboardController controller) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Sidebar gauche - Enfants
|
||||
SizedBox(
|
||||
width: 280,
|
||||
child: ChildrenSidebar(
|
||||
children: controller.children,
|
||||
selectedChildId: controller.selectedChildId,
|
||||
onChildSelected: controller.selectChild,
|
||||
onAddChild: controller.showAddChildModal,
|
||||
),
|
||||
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,
|
||||
),
|
||||
|
||||
// Contenu central
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: MainContentArea(
|
||||
selectedChild: controller.selectedChild,
|
||||
selectedAssistant: controller.selectedAssistant,
|
||||
events: controller.upcomingEvents,
|
||||
contracts: controller.contracts,
|
||||
),
|
||||
),
|
||||
|
||||
// Sidebar droite - Messagerie
|
||||
SizedBox(
|
||||
width: 320,
|
||||
child: MessagingSidebar(
|
||||
conversations: controller.conversations,
|
||||
notifications: controller.notifications,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTabletLayout(ParentDashboardController controller) {
|
||||
return Row(
|
||||
children: [
|
||||
// Sidebar enfants plus étroite
|
||||
SizedBox(
|
||||
width: 240,
|
||||
child: ChildrenSidebar(
|
||||
children: controller.children,
|
||||
selectedChildId: controller.selectedChildId,
|
||||
onChildSelected: controller.selectChild,
|
||||
onAddChild: controller.showAddChildModal,
|
||||
isCompact: true,
|
||||
),
|
||||
),
|
||||
|
||||
// Contenu principal avec messagerie intégrée
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: MainContentArea(
|
||||
selectedChild: controller.selectedChild,
|
||||
selectedAssistant: controller.selectedAssistant,
|
||||
events: controller.upcomingEvents,
|
||||
contracts: controller.contracts,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 200,
|
||||
child: MessagingSidebar(
|
||||
conversations: controller.conversations,
|
||||
notifications: controller.notifications,
|
||||
isCompact: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMobileLayout(ParentDashboardController controller) {
|
||||
return DefaultTabController(
|
||||
length: 4,
|
||||
child: Column(
|
||||
children: [
|
||||
// Navigation par onglets sur mobile
|
||||
Container(
|
||||
color: Theme.of(context).primaryColor.withOpacity(0.1),
|
||||
child: const TabBar(
|
||||
isScrollable: true,
|
||||
tabs: [
|
||||
Tab(text: 'Enfants', icon: Icon(Icons.child_care)),
|
||||
Tab(text: 'Planning', icon: Icon(Icons.calendar_month)),
|
||||
Tab(text: 'Contrats', icon: Icon(Icons.description)),
|
||||
Tab(text: 'Messages', icon: Icon(Icons.message)),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
children: [
|
||||
// Onglet Enfants
|
||||
ChildrenSidebar(
|
||||
children: controller.children,
|
||||
selectedChildId: controller.selectedChildId,
|
||||
onChildSelected: controller.selectChild,
|
||||
onAddChild: controller.showAddChildModal,
|
||||
isMobile: true,
|
||||
),
|
||||
|
||||
// Onglet Planning
|
||||
MainContentArea(
|
||||
selectedChild: controller.selectedChild,
|
||||
selectedAssistant: controller.selectedAssistant,
|
||||
events: controller.upcomingEvents,
|
||||
contracts: controller.contracts,
|
||||
showOnlyCalendar: true,
|
||||
),
|
||||
|
||||
// Onglet Contrats
|
||||
MainContentArea(
|
||||
selectedChild: controller.selectedChild,
|
||||
selectedAssistant: controller.selectedAssistant,
|
||||
events: controller.upcomingEvents,
|
||||
contracts: controller.contracts,
|
||||
showOnlyContracts: true,
|
||||
),
|
||||
|
||||
// Onglet Messages
|
||||
MessagingSidebar(
|
||||
conversations: controller.conversations,
|
||||
notifications: controller.notifications,
|
||||
isMobile: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -62,10 +62,6 @@ class ApiConfig {
|
||||
static const String parents = '/parents';
|
||||
/// Création dossier famille actif par le staff (#129) — body type register parent.
|
||||
static const String parentsDossier = '/parents/dossier';
|
||||
/// Couples de garde du parent connecté (#167 / #168).
|
||||
static const String parentsCouplesGarde = '/parents/me/couples-garde';
|
||||
static const String parentsCoupleGardeCourant =
|
||||
'/parents/me/couples-garde/courant';
|
||||
static const String assistantesMaternelles = '/assistantes-maternelles';
|
||||
/// Création dossier AM actif par le staff (#156) — body type register AM.
|
||||
static const String assistantesMaternellesDossier =
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:p_tits_pas/models/couple_garde.dart';
|
||||
import 'package:p_tits_pas/services/api/api_config.dart';
|
||||
import 'package:p_tits_pas/services/api/tokenService.dart';
|
||||
|
||||
/// Accès API aux couples de garde du parent connecté — tickets #167 / #168.
|
||||
/// - GET /parents/me/couples-garde
|
||||
/// - PUT /parents/me/couples-garde/courant { couple_id }
|
||||
class CoupleGardeService {
|
||||
static Future<Map<String, String>> _headers() async {
|
||||
final token = await TokenService.getToken();
|
||||
return token != null
|
||||
? ApiConfig.authHeaders(token)
|
||||
: Map<String, String>.from(ApiConfig.headers);
|
||||
}
|
||||
|
||||
static String _extractError(String body, String fallback) {
|
||||
try {
|
||||
final decoded = jsonDecode(body);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
final message = decoded['message'];
|
||||
if (message is String && message.trim().isNotEmpty) {
|
||||
return message;
|
||||
}
|
||||
if (message is Map && message['message'] is String) {
|
||||
return message['message'] as String;
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/// Liste les couples de garde et le couple courant du parent connecté.
|
||||
static Future<CouplesGardeResponse> getCouplesGarde() async {
|
||||
final response = await http.get(
|
||||
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.parentsCouplesGarde}'),
|
||||
headers: await _headers(),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception(
|
||||
_extractError(response.body, 'Erreur chargement des couples de garde'),
|
||||
);
|
||||
}
|
||||
|
||||
final decoded = jsonDecode(response.body);
|
||||
return CouplesGardeResponse.fromJson(
|
||||
Map<String, dynamic>.from(decoded as Map),
|
||||
);
|
||||
}
|
||||
|
||||
/// Persiste le couple courant (préférence utilisateur) et renvoie la liste
|
||||
/// à jour.
|
||||
static Future<CouplesGardeResponse> definirCoupleCourant(
|
||||
String coupleId,
|
||||
) async {
|
||||
final response = await http.put(
|
||||
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.parentsCoupleGardeCourant}'),
|
||||
headers: await _headers(),
|
||||
body: jsonEncode({'couple_id': coupleId}),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception(
|
||||
_extractError(response.body, 'Erreur sélection du couple de garde'),
|
||||
);
|
||||
}
|
||||
|
||||
final decoded = jsonDecode(response.body);
|
||||
return CouplesGardeResponse.fromJson(
|
||||
Map<String, dynamic>.from(decoded as Map),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,7 @@ import 'package:p_tits_pas/models/m_dashbord/child_model.dart';
|
||||
import 'package:p_tits_pas/services/bug_report_service.dart';
|
||||
|
||||
class AppFooter extends StatelessWidget {
|
||||
/// Ligne grise droite au-dessus du footer. À désactiver quand l'écran
|
||||
/// fournit déjà son propre séparateur (ex. trait crayon du quotidien).
|
||||
final bool showTopBorder;
|
||||
|
||||
const AppFooter({Key? key, this.showTopBorder = true}) : super(key: key);
|
||||
const AppFooter({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -17,9 +13,9 @@ class AppFooter extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||
decoration: BoxDecoration(
|
||||
// color: Colors.white,
|
||||
border: showTopBorder
|
||||
? Border(top: BorderSide(color: Colors.grey.shade300))
|
||||
: null,
|
||||
border: Border(
|
||||
top: BorderSide(color: Colors.grey.shade300),
|
||||
),
|
||||
),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class Childrensidebarwidget extends StatelessWidget{
|
||||
final void Function(String childId) onChildSelected;
|
||||
|
||||
const Childrensidebarwidget({
|
||||
Key? key,
|
||||
required this.onChildSelected,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final children = [
|
||||
{'id': '1', 'name': 'Léna', 'photo': null, 'status': 'Actif'},
|
||||
{'id': '2', 'name': 'Noé', 'photo': null, 'status': 'Inactif'},
|
||||
];
|
||||
|
||||
return Container(
|
||||
color: const Color(0xFFF7F7F7),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
// Avatar parent + bouton
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const CircleAvatar(radius: 24, child: Icon(Icons.person)),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add),
|
||||
onPressed: () {
|
||||
// Naviguer vers ajout d'enfant
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text("Mes enfants", style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
// Liste des enfants
|
||||
...children.map((child) {
|
||||
return GestureDetector(
|
||||
onTap: () => onChildSelected(child['id']!),
|
||||
child: Card(
|
||||
color: child['status'] == 'Actif' ? Colors.teal.shade50 : Colors.white,
|
||||
child: ListTile(
|
||||
leading: const CircleAvatar(child: Icon(Icons.child_care)),
|
||||
title: Text(child['name']!),
|
||||
subtitle: Text(child['status']!),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList()
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppLayout extends StatelessWidget {
|
||||
final PreferredSizeWidget appBar;
|
||||
final Widget body;
|
||||
final Widget? footer;
|
||||
|
||||
const AppLayout({
|
||||
Key? key,
|
||||
required this.appBar,
|
||||
required this.body,
|
||||
this.footer,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF5F7FA),
|
||||
appBar: appBar,
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(child: body),
|
||||
if (footer != null) footer!,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:p_tits_pas/models/m_dashbord/child_model.dart';
|
||||
|
||||
class ChildrenSidebar extends StatelessWidget {
|
||||
final List<ChildModel> children;
|
||||
final String? selectedChildId;
|
||||
final Function(String) onChildSelected;
|
||||
final VoidCallback onAddChild;
|
||||
final bool isCompact;
|
||||
final bool isMobile;
|
||||
|
||||
const ChildrenSidebar({
|
||||
Key? key,
|
||||
required this.children,
|
||||
this.selectedChildId,
|
||||
required this.onChildSelected,
|
||||
required this.onAddChild,
|
||||
this.isCompact = false,
|
||||
this.isMobile = false,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: EdgeInsets.all(isMobile ? 16 : 24),
|
||||
color: Colors.white,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildHeader(context),
|
||||
const SizedBox(height: 20),
|
||||
_buildAddChildButton(context),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(child: _buildChildrenList()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
// UserAvatar(
|
||||
// size: isCompact ? 40 : 60,
|
||||
// name: 'Emma Dupont', // TODO: Récupérer depuis le contexte utilisateur
|
||||
// ),
|
||||
if (!isCompact) ...[
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: const [
|
||||
Text(
|
||||
'Emma Dupont',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
Icon(Icons.keyboard_arrow_down),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAddChildButton(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: onAddChild,
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text(isCompact ? 'Ajouter' : 'Ajouter un enfant'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: isCompact ? 8 : 12,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChildrenList() {
|
||||
if (children.isEmpty) {
|
||||
return const Center(
|
||||
child: Text(
|
||||
'Aucun enfant ajouté',
|
||||
style: TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
itemCount: children.length,
|
||||
separatorBuilder: (context, index) => const SizedBox(height: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final child = children[index];
|
||||
final isSelected = child.id == selectedChildId;
|
||||
|
||||
return _buildChildCard(context, child, isSelected);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChildCard(BuildContext context, ChildModel child, bool isSelected) {
|
||||
return InkWell(
|
||||
onTap: () => onChildSelected(child.id),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? const Color(0xFF9CC5C0).withOpacity(0.1) : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isSelected ? const Color(0xFF9CC5C0) : Colors.grey.shade300,
|
||||
width: isSelected ? 2 : 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// UserAvatar(
|
||||
// // size: isCompact ? 32 : 40,
|
||||
// // name: child.fullName,
|
||||
// // imageUrl: child.photoUrl,
|
||||
// ),
|
||||
if (!isCompact) ...[
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
child.firstName,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
_buildChildStatus(child.status),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChildStatus(ChildStatus status) {
|
||||
String label;
|
||||
Color color;
|
||||
|
||||
switch (status) {
|
||||
case ChildStatus.withAssistant:
|
||||
label = 'En garde';
|
||||
color = Colors.green;
|
||||
break;
|
||||
case ChildStatus.available:
|
||||
label = 'Disponible';
|
||||
color = Colors.blue;
|
||||
break;
|
||||
case ChildStatus.onHoliday:
|
||||
label = 'En vacances';
|
||||
color = Colors.orange;
|
||||
break;
|
||||
case ChildStatus.sick:
|
||||
label = 'Malade';
|
||||
color = Colors.red;
|
||||
break;
|
||||
case ChildStatus.searching:
|
||||
label = 'Recherche AM';
|
||||
color = Colors.purple;
|
||||
break;
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: color,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:p_tits_pas/widgets/dashbord_parent/ChildrenSidebarwidget.dart';
|
||||
import 'package:p_tits_pas/widgets/dashbord_parent/children_sidebar.dart';
|
||||
import 'package:p_tits_pas/widgets/dashbord_parent/wid_mainContentArea.dart';
|
||||
import 'package:p_tits_pas/widgets/messaging_sidebar.dart';
|
||||
|
||||
Widget Dashbord_body() {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 1️⃣ Colonne de gauche : enfants
|
||||
SizedBox(
|
||||
width: 250,
|
||||
child: Childrensidebarwidget(
|
||||
onChildSelected: (childId) {
|
||||
// Met à jour l'enfant sélectionné
|
||||
// Tu peux stocker cet ID dans un state `selectedChildId`
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: WMainContentArea(
|
||||
// Passe l’enfant sélectionné si besoin
|
||||
),
|
||||
),
|
||||
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:p_tits_pas/widgets/messaging_sidebar.dart';
|
||||
|
||||
class WMainContentArea extends StatelessWidget {
|
||||
const WMainContentArea({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
color: Colors.white,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 🔷 Informations assistante maternelle (ligne complète)
|
||||
Card(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
const CircleAvatar(
|
||||
radius: 30,
|
||||
backgroundImage: AssetImage("assets/images/am_photo.jpg"), // à adapter
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: const [
|
||||
Text("Julie Dupont", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||||
SizedBox(height: 4),
|
||||
Text("Taux horaire : 10€/h"),
|
||||
Text("Frais journaliers : 5€"),
|
||||
],
|
||||
),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
// Ouvrir le contrat
|
||||
},
|
||||
child: const Text("Voir le contrat"),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 🔷 Deux colonnes : planning + messagerie
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
// 📆 Planning de garde
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: const [
|
||||
Text("Planning de garde", style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
SizedBox(height: 12),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Text("Composant calendrier à intégrer ici"),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 16),
|
||||
|
||||
// 💬 Messagerie
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: MessagingSidebar(
|
||||
conversations: [],
|
||||
notifications: [],
|
||||
isCompact: false,
|
||||
isMobile: false,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,14 +10,6 @@ class ImageButton extends StatelessWidget {
|
||||
final VoidCallback onPressed;
|
||||
final double fontSize; // Ajout pour la flexibilité
|
||||
|
||||
/// Forme utilisée pour le focus / hover / splash. Les fonds « dessinés »
|
||||
/// sont des pastilles : le stadium suit leur contour au lieu d'un rectangle.
|
||||
final OutlinedBorder shape;
|
||||
|
||||
/// Opacité du fond seul (le texte reste net) : permet un état « inactif »
|
||||
/// plus clair sans changer d'asset.
|
||||
final double bgOpacity;
|
||||
|
||||
const ImageButton({
|
||||
super.key,
|
||||
required this.bg,
|
||||
@@ -27,8 +19,6 @@ class ImageButton extends StatelessWidget {
|
||||
required this.textColor,
|
||||
required this.onPressed,
|
||||
this.fontSize = 16, // Valeur par défaut
|
||||
this.shape = const StadiumBorder(),
|
||||
this.bgOpacity = 1.0,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -46,16 +36,14 @@ class ImageButton extends StatelessWidget {
|
||||
style: TextButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
shape: shape,
|
||||
shape:
|
||||
const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
|
||||
),
|
||||
child: Ink(
|
||||
// Pas de découpe du PNG : le trait « dessiné » déborde un peu du
|
||||
// stadium, on ne rogne que le focus / hover / splash.
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: AssetImage(bg),
|
||||
fit: BoxFit.fill,
|
||||
opacity: bgOpacity,
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
|
||||
@@ -1,364 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:p_tits_pas/models/couple_garde.dart';
|
||||
import 'package:p_tits_pas/services/api/api_config.dart';
|
||||
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
|
||||
import 'package:p_tits_pas/widgets/quotidien/quotidien_theme.dart';
|
||||
|
||||
/// Rôle qui consulte le bandeau : côté parent (enfant | nounou) ou côté AM
|
||||
/// (enfant | parent(s)). Le composant est le même, seule la lecture change.
|
||||
/// Ticket #167 (parent) ; #170 réutilise en mode AM.
|
||||
enum CoupleBandeauMode { parent, assistanteMaternelle }
|
||||
|
||||
/// Bandeau « couple de garde » en haut de la colonne gauche du TdB.
|
||||
///
|
||||
/// - Plusieurs couples → contrôle unique avec chevron (dropdown de bascule).
|
||||
/// - Un seul couple → affichage informatif (pas de chevron, pas de menu).
|
||||
/// - Aucun couple → état vide discret.
|
||||
class CoupleSelectorBandeau extends StatelessWidget {
|
||||
final CoupleBandeauMode mode;
|
||||
final List<CoupleGarde> couples;
|
||||
final String? selectedCoupleId;
|
||||
final ValueChanged<CoupleGarde>? onCoupleSelected;
|
||||
final bool loading;
|
||||
final String? errorMessage;
|
||||
final VoidCallback? onRetry;
|
||||
|
||||
const CoupleSelectorBandeau({
|
||||
super.key,
|
||||
this.mode = CoupleBandeauMode.parent,
|
||||
required this.couples,
|
||||
this.selectedCoupleId,
|
||||
this.onCoupleSelected,
|
||||
this.loading = false,
|
||||
this.errorMessage,
|
||||
this.onRetry,
|
||||
});
|
||||
|
||||
CoupleGarde? get _selected {
|
||||
if (couples.isEmpty) return null;
|
||||
if (selectedCoupleId != null) {
|
||||
for (final c in couples) {
|
||||
if (c.id == selectedCoupleId) return c;
|
||||
}
|
||||
}
|
||||
for (final c in couples) {
|
||||
if (c.courant) return c;
|
||||
}
|
||||
return couples.first;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(builder: (context, constraints) {
|
||||
if (loading) return const _CoupleBandeauSkeleton();
|
||||
if (errorMessage != null) {
|
||||
return _CoupleBandeauError(message: errorMessage!, onRetry: onRetry);
|
||||
}
|
||||
if (couples.isEmpty) return const _CoupleBandeauEmpty();
|
||||
|
||||
final selected = _selected!;
|
||||
final multi = couples.length > 1;
|
||||
|
||||
final card = _CoupleCard(
|
||||
mode: mode,
|
||||
couple: selected,
|
||||
showChevron: multi,
|
||||
);
|
||||
|
||||
if (!multi) return card;
|
||||
|
||||
return PopupMenuButton<String>(
|
||||
tooltip: 'Changer de garde',
|
||||
offset: const Offset(0, 4), // Rapproché du bandeau principal
|
||||
color: Colors.transparent, // Le fond devient invisible
|
||||
elevation: 0, // Pas d'ombre carrée
|
||||
constraints: BoxConstraints.tightFor(width: constraints.maxWidth),
|
||||
padding: EdgeInsets.zero,
|
||||
onSelected: (id) {
|
||||
final chosen = couples.firstWhere((c) => c.id == id);
|
||||
onCoupleSelected?.call(chosen);
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
for (final c in couples)
|
||||
// On ne réaffiche pas le couple actuellement sélectionné dans le menu déroulant
|
||||
// puisqu'il est déjà visible en tant que "bouton" principal.
|
||||
if (c.id != selected.id)
|
||||
PopupMenuItem<String>(
|
||||
value: c.id,
|
||||
padding: EdgeInsets.zero,
|
||||
// Marge verticale pour que les pastilles ne soient pas collées
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: _CoupleCard(
|
||||
mode: mode,
|
||||
couple: c,
|
||||
showChevron: false,
|
||||
selected: false,
|
||||
isMenuItem: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
child: card,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Carte principale : enfant à gauche, séparateur, AM/parents à droite.
|
||||
class _CoupleCard extends StatelessWidget {
|
||||
final CoupleBandeauMode mode;
|
||||
final CoupleGarde couple;
|
||||
final bool showChevron;
|
||||
final bool selected;
|
||||
final bool isMenuItem;
|
||||
|
||||
const _CoupleCard({
|
||||
required this.mode,
|
||||
required this.couple,
|
||||
required this.showChevron,
|
||||
this.selected = true,
|
||||
this.isMenuItem = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Dans le menu, les non-sélectionnés sont un peu translucides
|
||||
final opacity = (!selected && isMenuItem) ? 0.65 : 1.0;
|
||||
|
||||
return Container(
|
||||
height: 90,
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: AssetImage(QuotidienTheme.bandeauAssetForCouple(couple.id)),
|
||||
fit: BoxFit.fill,
|
||||
opacity: opacity,
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Opacity(
|
||||
opacity: opacity,
|
||||
child: _MembreTile(
|
||||
photoUrl: couple.enfant.photoUrl,
|
||||
name: couple.enfant.displayName(fallback: 'Enfant'),
|
||||
fallbackIcon: Icons.child_care,
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 1,
|
||||
height: 44,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12),
|
||||
color: QuotidienTheme.ink.withValues(alpha: 0.15 * opacity),
|
||||
),
|
||||
Expanded(
|
||||
child: Opacity(
|
||||
opacity: opacity,
|
||||
child: _MembreTile(
|
||||
photoUrl: couple.am.photoUrl,
|
||||
name: couple.am.displayName(
|
||||
fallback: mode == CoupleBandeauMode.parent
|
||||
? 'Nounou'
|
||||
: 'Parent',
|
||||
),
|
||||
fallbackIcon: mode == CoupleBandeauMode.parent
|
||||
? Icons.volunteer_activism
|
||||
: Icons.person_outline,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showChevron)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4),
|
||||
child: Icon(
|
||||
Icons.keyboard_arrow_down,
|
||||
color: QuotidienTheme.ink.withValues(alpha: opacity),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Photo ronde + nom (une moitié du couple).
|
||||
class _MembreTile extends StatelessWidget {
|
||||
final String? photoUrl;
|
||||
final String name;
|
||||
final IconData fallbackIcon;
|
||||
|
||||
const _MembreTile({
|
||||
required this.photoUrl,
|
||||
required this.name,
|
||||
required this.fallbackIcon,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
_Avatar(photoUrl: photoUrl, fallbackIcon: fallbackIcon, size: 48),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: GoogleFonts.merienda(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: QuotidienTheme.ink,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Avatar extends StatelessWidget {
|
||||
final String? photoUrl;
|
||||
final IconData fallbackIcon;
|
||||
final double size;
|
||||
|
||||
const _Avatar({
|
||||
required this.photoUrl,
|
||||
required this.fallbackIcon,
|
||||
required this.size,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final url = ApiConfig.absoluteMediaUrl(photoUrl);
|
||||
final placeholder = Container(
|
||||
width: size,
|
||||
height: size,
|
||||
color: QuotidienTheme.lavender.withValues(alpha: 0.35),
|
||||
child: Icon(fallbackIcon, size: size * 0.5, color: QuotidienTheme.ink),
|
||||
);
|
||||
return ClipOval(
|
||||
child: SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: url.isEmpty
|
||||
? placeholder
|
||||
: AuthNetworkImage(
|
||||
url: url,
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => placeholder,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CoupleBandeauSkeleton extends StatelessWidget {
|
||||
const _CoupleBandeauSkeleton();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 90,
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: const AssetImage(QuotidienTheme.bandeauLime), // Un asset existant
|
||||
fit: BoxFit.fill,
|
||||
colorFilter: ColorFilter.mode(
|
||||
Colors.white.withValues(alpha: 0.5),
|
||||
BlendMode.lighten,
|
||||
),
|
||||
),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: const SizedBox(
|
||||
width: 26,
|
||||
height: 26,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CoupleBandeauEmpty extends StatelessWidget {
|
||||
const _CoupleBandeauEmpty();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 90,
|
||||
decoration: const BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: AssetImage(QuotidienTheme.bandeauLime), // Un asset existant par défaut
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.info_outline, color: QuotidienTheme.muted),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Aucune garde active pour le moment.',
|
||||
style: GoogleFonts.merriweather(
|
||||
fontSize: 14,
|
||||
color: QuotidienTheme.muted,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CoupleBandeauError extends StatelessWidget {
|
||||
final String message;
|
||||
final VoidCallback? onRetry;
|
||||
|
||||
const _CoupleBandeauError({required this.message, this.onRetry});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 90,
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: const AssetImage(QuotidienTheme.bandeauLime), // Un asset existant
|
||||
fit: BoxFit.fill,
|
||||
colorFilter: ColorFilter.mode(
|
||||
QuotidienTheme.coral.withValues(alpha: 0.3),
|
||||
BlendMode.srcATop,
|
||||
),
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.error_outline, color: QuotidienTheme.ink),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: GoogleFonts.merriweather(
|
||||
fontSize: 13,
|
||||
color: QuotidienTheme.ink,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (onRetry != null)
|
||||
TextButton(
|
||||
onPressed: onRetry,
|
||||
child: const Text('Réessayer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,282 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:p_tits_pas/services/auth_service.dart';
|
||||
import 'package:p_tits_pas/widgets/image_button.dart';
|
||||
import 'package:p_tits_pas/widgets/quotidien/quotidien_theme.dart';
|
||||
|
||||
/// Bandeau quotidien pastel : logo · pastilles Cahier de liaison / Agenda /
|
||||
/// Contrat · menu user.
|
||||
/// Réutilisable parent (#166) et AM (#169).
|
||||
class QuotidienBandeau extends StatelessWidget {
|
||||
final QuotidienNavSection selectedSection;
|
||||
final ValueChanged<QuotidienNavSection> onSectionSelected;
|
||||
final String userDisplayName;
|
||||
final String? userEmail;
|
||||
final VoidCallback? onProfileTap;
|
||||
final VoidCallback? onSearchAmTap;
|
||||
final VoidCallback? onSettingsTap;
|
||||
final VoidCallback? onLogout;
|
||||
|
||||
const QuotidienBandeau({
|
||||
super.key,
|
||||
required this.selectedSection,
|
||||
required this.onSectionSelected,
|
||||
required this.userDisplayName,
|
||||
this.userEmail,
|
||||
this.onProfileTap,
|
||||
this.onSearchAmTap,
|
||||
this.onSettingsTap,
|
||||
this.onLogout,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Image.asset(
|
||||
QuotidienTheme.logoAsset,
|
||||
height: 44,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
const Spacer(),
|
||||
_NavPills(
|
||||
selected: selectedSection,
|
||||
onSelected: onSectionSelected,
|
||||
),
|
||||
const Spacer(),
|
||||
_UserMenu(
|
||||
displayName: userDisplayName,
|
||||
email: userEmail,
|
||||
onProfileTap: onProfileTap,
|
||||
onSearchAmTap: onSearchAmTap,
|
||||
onSettingsTap: onSettingsTap,
|
||||
onLogout: onLogout,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NavPills extends StatelessWidget {
|
||||
final QuotidienNavSection selected;
|
||||
final ValueChanged<QuotidienNavSection> onSelected;
|
||||
|
||||
const _NavPills({
|
||||
required this.selected,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_pill(
|
||||
label: 'Cahier de liaison',
|
||||
section: QuotidienNavSection.liaison,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
_pill(
|
||||
label: 'Agenda',
|
||||
section: QuotidienNavSection.agenda,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
_pill(
|
||||
label: 'Contrat',
|
||||
section: QuotidienNavSection.contrat,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _pill({
|
||||
required String label,
|
||||
required QuotidienNavSection section,
|
||||
}) {
|
||||
final active = selected == section;
|
||||
// Même famille que l’inscription : fonds « dessinés » (pas Material).
|
||||
// Les deux pastilles ont la même silhouette (ratio ~4:1) : la largeur
|
||||
// est dérivée de la hauteur pour ne jamais déformer le PNG.
|
||||
// Chaque section garde sa couleur ; l'inactive est juste plus claire.
|
||||
return ImageButton(
|
||||
bg: QuotidienTheme.pillAssetFor(section),
|
||||
bgOpacity: active ? 1.0 : QuotidienTheme.pillInactiveOpacity,
|
||||
width: _pillHeight * QuotidienTheme.pillAspectRatio,
|
||||
height: _pillHeight,
|
||||
text: label,
|
||||
textColor: QuotidienTheme.ink,
|
||||
fontSize: 14,
|
||||
onPressed: () => onSelected(section),
|
||||
);
|
||||
}
|
||||
|
||||
static const double _pillHeight = 46;
|
||||
}
|
||||
|
||||
class _UserMenu extends StatelessWidget {
|
||||
final String displayName;
|
||||
final String? email;
|
||||
final VoidCallback? onProfileTap;
|
||||
final VoidCallback? onSearchAmTap;
|
||||
final VoidCallback? onSettingsTap;
|
||||
final VoidCallback? onLogout;
|
||||
|
||||
const _UserMenu({
|
||||
required this.displayName,
|
||||
this.email,
|
||||
this.onProfileTap,
|
||||
this.onSearchAmTap,
|
||||
this.onSettingsTap,
|
||||
this.onLogout,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final shortName = displayName.trim().isEmpty ? 'Compte' : displayName.trim();
|
||||
const double pillHeight = 46;
|
||||
const double pillWidth = pillHeight * QuotidienTheme.pillAspectRatio;
|
||||
return PopupMenuButton<String>(
|
||||
tooltip: 'Menu utilisateur',
|
||||
offset: const Offset(0, pillHeight + 4),
|
||||
color: QuotidienTheme.ivory,
|
||||
elevation: 3,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(color: QuotidienTheme.lavender.withOpacity(0.6)),
|
||||
),
|
||||
onSelected: (value) async {
|
||||
switch (value) {
|
||||
case 'profile':
|
||||
onProfileTap?.call();
|
||||
break;
|
||||
case 'search_am':
|
||||
onSearchAmTap?.call();
|
||||
break;
|
||||
case 'settings':
|
||||
onSettingsTap?.call();
|
||||
break;
|
||||
case 'logout':
|
||||
await _confirmLogout(context);
|
||||
break;
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
if (email != null && email!.trim().isNotEmpty)
|
||||
PopupMenuItem(
|
||||
enabled: false,
|
||||
child: Text(
|
||||
email!,
|
||||
style: TextStyle(color: QuotidienTheme.muted, fontSize: 12),
|
||||
),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
const PopupMenuItem(
|
||||
value: 'profile',
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Icon(Icons.person_outline, size: 20),
|
||||
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'),
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'settings',
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Icon(Icons.settings_outlined, size: 20),
|
||||
title: Text('Paramètres'),
|
||||
),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
const PopupMenuItem(
|
||||
value: 'logout',
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Icon(Icons.logout, size: 20),
|
||||
title: Text('Déconnexion'),
|
||||
),
|
||||
),
|
||||
],
|
||||
// Même pastille « dessinée » que la nav, teinte violet pastel charte.
|
||||
child: Container(
|
||||
width: pillWidth,
|
||||
height: pillHeight,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
decoration: const BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: AssetImage(QuotidienTheme.pillLavenderAsset),
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.person_outline,
|
||||
size: 20,
|
||||
color: QuotidienTheme.ink,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
shortName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: GoogleFonts.merienda(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: QuotidienTheme.ink,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
Icons.keyboard_arrow_down,
|
||||
size: 18,
|
||||
color: QuotidienTheme.ink,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _confirmLogout(BuildContext context) async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Déconnexion'),
|
||||
content: const Text('Voulez-vous vraiment vous déconnecter ?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child: const Text('Déconnexion'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok != true) return;
|
||||
onLogout?.call();
|
||||
await AuthService.logout();
|
||||
if (context.mounted) {
|
||||
context.go('/login');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,320 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:p_tits_pas/widgets/app_footer.dart';
|
||||
import 'package:p_tits_pas/widgets/quotidien/quotidien_bandeau.dart';
|
||||
import 'package:p_tits_pas/widgets/quotidien/quotidien_theme.dart';
|
||||
|
||||
/// Coquille « Cahier de liaison » quotidien 3 colonnes + bandeau (#166).
|
||||
/// L’AM (#169) réutilise ce widget en injectant ses slots.
|
||||
class QuotidienShell extends StatelessWidget {
|
||||
final QuotidienNavSection selectedSection;
|
||||
final ValueChanged<QuotidienNavSection> onSectionSelected;
|
||||
final String userDisplayName;
|
||||
final String? userEmail;
|
||||
final VoidCallback? onProfileTap;
|
||||
final VoidCallback? onSearchAmTap;
|
||||
final VoidCallback? onSettingsTap;
|
||||
final VoidCallback? onLogout;
|
||||
|
||||
/// Colonne gauche (couple + cartes + actions).
|
||||
final Widget leftColumn;
|
||||
|
||||
/// Colonne milieu (blog).
|
||||
final Widget centerColumn;
|
||||
|
||||
/// Colonne droite (messagerie).
|
||||
final Widget rightColumn;
|
||||
|
||||
/// Corps affiché hors Cahier de liaison (Agenda / Contrat stubs).
|
||||
final Widget? agendaBody;
|
||||
final Widget? contratBody;
|
||||
|
||||
const QuotidienShell({
|
||||
super.key,
|
||||
required this.selectedSection,
|
||||
required this.onSectionSelected,
|
||||
required this.userDisplayName,
|
||||
required this.leftColumn,
|
||||
required this.centerColumn,
|
||||
required this.rightColumn,
|
||||
this.userEmail,
|
||||
this.onProfileTap,
|
||||
this.onSearchAmTap,
|
||||
this.onSettingsTap,
|
||||
this.onLogout,
|
||||
this.agendaBody,
|
||||
this.contratBody,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: QuotidienTheme.ivory,
|
||||
body: Container(
|
||||
decoration: QuotidienTheme.paperBackground(),
|
||||
child: Column(
|
||||
children: [
|
||||
QuotidienBandeau(
|
||||
selectedSection: selectedSection,
|
||||
onSectionSelected: onSectionSelected,
|
||||
userDisplayName: userDisplayName,
|
||||
userEmail: userEmail,
|
||||
onProfileTap: onProfileTap,
|
||||
onSearchAmTap: onSearchAmTap,
|
||||
onSettingsTap: onSettingsTap,
|
||||
onLogout: onLogout,
|
||||
),
|
||||
const QuotidienPencilDivider(),
|
||||
Expanded(child: _bodyForSection(context)),
|
||||
const QuotidienPencilDivider(),
|
||||
const AppFooter(showTopBorder: false),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _bodyForSection(BuildContext context) {
|
||||
switch (selectedSection) {
|
||||
case QuotidienNavSection.liaison:
|
||||
return _ThreeColumns(
|
||||
left: leftColumn,
|
||||
center: centerColumn,
|
||||
right: rightColumn,
|
||||
);
|
||||
case QuotidienNavSection.agenda:
|
||||
return agendaBody ??
|
||||
const QuotidienStubPage(
|
||||
title: 'Agenda',
|
||||
message: 'Page Agenda — à venir.',
|
||||
);
|
||||
case QuotidienNavSection.contrat:
|
||||
return contratBody ??
|
||||
const QuotidienStubPage(
|
||||
title: 'Contrat',
|
||||
message: 'Page Contrat — à venir.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Trait « crayon gris » dessiné à la main : sépare bandeau / corps / footer
|
||||
/// (horizontal) et les 3 colonnes (vertical). Le PNG (2400×28) est étiré dans
|
||||
/// le sens du trait seulement : sur un trait, c'est invisible.
|
||||
class QuotidienPencilDivider extends StatelessWidget {
|
||||
final Axis axis;
|
||||
|
||||
/// Épaisseur de la zone du trait (hauteur si horizontal, largeur sinon).
|
||||
final double thickness;
|
||||
final EdgeInsets padding;
|
||||
|
||||
const QuotidienPencilDivider({
|
||||
super.key,
|
||||
this.axis = Axis.horizontal,
|
||||
this.thickness = 14,
|
||||
this.padding = const EdgeInsets.symmetric(horizontal: 24),
|
||||
});
|
||||
|
||||
const QuotidienPencilDivider.vertical({
|
||||
super.key,
|
||||
this.thickness = 14,
|
||||
this.padding = const EdgeInsets.symmetric(vertical: 16),
|
||||
}) : axis = Axis.vertical;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final horizontal = axis == Axis.horizontal;
|
||||
return Padding(
|
||||
padding: padding,
|
||||
child: SizedBox(
|
||||
width: horizontal ? double.infinity : thickness,
|
||||
height: horizontal ? thickness : double.infinity,
|
||||
child: Image.asset(
|
||||
horizontal
|
||||
? QuotidienTheme.pencilLineAsset
|
||||
: QuotidienTheme.pencilLineVerticalAsset,
|
||||
fit: BoxFit.fill,
|
||||
filterQuality: FilterQuality.medium,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ThreeColumns extends StatelessWidget {
|
||||
final Widget left;
|
||||
final Widget center;
|
||||
final Widget right;
|
||||
|
||||
const _ThreeColumns({
|
||||
required this.left,
|
||||
required this.center,
|
||||
required this.right,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final wide = constraints.maxWidth >= 900;
|
||||
if (!wide) {
|
||||
// Socle mobile temporaire (#188 = swipe dédié) : pile verticale.
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(12, 4, 12, 12),
|
||||
children: [
|
||||
_ColumnPanel(child: left),
|
||||
const QuotidienPencilDivider(
|
||||
padding: EdgeInsets.symmetric(horizontal: 32, vertical: 4),
|
||||
),
|
||||
_ColumnPanel(child: center),
|
||||
const QuotidienPencilDivider(
|
||||
padding: EdgeInsets.symmetric(horizontal: 32, vertical: 4),
|
||||
),
|
||||
_ColumnPanel(child: right),
|
||||
],
|
||||
);
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(child: _ColumnPanel(child: left)),
|
||||
const QuotidienPencilDivider.vertical(),
|
||||
Expanded(child: _ColumnPanel(child: center)),
|
||||
const QuotidienPencilDivider.vertical(),
|
||||
Expanded(child: _ColumnPanel(child: right)),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Panneau colonne (carte pastel semi-transparente).
|
||||
class _ColumnPanel extends StatelessWidget {
|
||||
final Widget child;
|
||||
|
||||
const _ColumnPanel({required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
// Pas de bordure Material : la séparation est assurée par les traits
|
||||
// crayon, on garde juste un léger fond.
|
||||
decoration: BoxDecoration(
|
||||
color: QuotidienTheme.columnCardFill.withOpacity(0.82),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Placeholder de colonne métier (branchable par tickets C/D/E).
|
||||
class QuotidienColumnPlaceholder extends StatelessWidget {
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final IconData icon;
|
||||
|
||||
const QuotidienColumnPlaceholder({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.icon,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(icon, color: QuotidienTheme.lavender, size: 22),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
title,
|
||||
style: GoogleFonts.merienda(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: QuotidienTheme.ink,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Text(
|
||||
subtitle,
|
||||
textAlign: TextAlign.center,
|
||||
style: GoogleFonts.merriweather(
|
||||
fontSize: 13,
|
||||
color: QuotidienTheme.muted,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stub pleine page (Agenda / Contrat) — contenu riche hors #166.
|
||||
class QuotidienStubPage extends StatelessWidget {
|
||||
final String title;
|
||||
final String message;
|
||||
|
||||
const QuotidienStubPage({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.message,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.all(24),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 32),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.85),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: GoogleFonts.merienda(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: QuotidienTheme.ink,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: GoogleFonts.merriweather(
|
||||
fontSize: 14,
|
||||
color: QuotidienTheme.muted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Couleurs / tokens du quotidien parent–AM (#166) — lignée papier / pastel.
|
||||
/// Pas le violet Material du dashboard staff.
|
||||
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 lavender = Color(0xFFC6A3D8);
|
||||
static const Color coral = Color(0xFFF4A28C);
|
||||
static const Color softGreenPill = Color(0xFFB8D9A8);
|
||||
static const Color columnCardFill = Color(0xFFF7F3EA);
|
||||
static const Color muted = Color(0xFF6B6B6B);
|
||||
|
||||
static const String paperAsset = 'assets/images/paper2.png';
|
||||
static const String logoAsset = 'assets/images/logo.png';
|
||||
|
||||
/// Pastilles « dessinées » du bandeau : même silhouette (1190×299), une
|
||||
/// couleur charte par section ; l'inactive est la même, plus transparente.
|
||||
static const double pillInactiveOpacity = 0.45;
|
||||
static const String pillIvoryAsset = 'assets/images/bg_ivoire_pill.png';
|
||||
static const String pillBlueAsset = 'assets/images/bg_blue_pill.png';
|
||||
|
||||
// Bandeaux pour les couples (ratio 10:1, dessinés au crayon, couleur dynamique)
|
||||
static const String bandeauLime = 'assets/images/bandeau_lime.png';
|
||||
static const String bandeauBlue = 'assets/images/bandeau_blue.png';
|
||||
static const String bandeauPeach = 'assets/images/bandeau_peach.png';
|
||||
static const String bandeauYellow = 'assets/images/bandeau_yellow.png';
|
||||
static const String bandeauLavender = 'assets/images/bandeau_lavender.png';
|
||||
|
||||
static const List<String> bandeauColors = [
|
||||
bandeauLime,
|
||||
bandeauBlue,
|
||||
bandeauPeach,
|
||||
bandeauYellow,
|
||||
bandeauLavender,
|
||||
];
|
||||
|
||||
/// Retourne un asset bandeau fixe pour un couple donné (basé sur son ID)
|
||||
static String bandeauAssetForCouple(String coupleId) {
|
||||
if (coupleId.isEmpty) return bandeauLime;
|
||||
final hash = coupleId.hashCode.abs();
|
||||
return bandeauColors[hash % bandeauColors.length];
|
||||
}
|
||||
static const String pillYellowAsset = 'assets/images/bg_yellow_pill.png';
|
||||
static const String pillPeachAsset = 'assets/images/bg_peach_pill.png';
|
||||
static const String pillTurquoiseAsset =
|
||||
'assets/images/bg_turquoise_pill.png';
|
||||
static const String pillLavenderAsset =
|
||||
'assets/images/bg_lavender_pill.png';
|
||||
|
||||
/// Trait crayon gris (2400×28, transparent) : séparateurs bandeau / corps /
|
||||
/// footer. Version verticale (28×2400) entre les colonnes.
|
||||
static const String pencilLineAsset = 'assets/images/pencil_line_grey.png';
|
||||
static const String pencilLineVerticalAsset =
|
||||
'assets/images/pencil_line_grey_v.png';
|
||||
|
||||
static String pillAssetFor(QuotidienNavSection section) {
|
||||
switch (section) {
|
||||
case QuotidienNavSection.liaison:
|
||||
return pillYellowAsset;
|
||||
case QuotidienNavSection.agenda:
|
||||
return pillPeachAsset;
|
||||
case QuotidienNavSection.contrat:
|
||||
return pillTurquoiseAsset;
|
||||
}
|
||||
}
|
||||
|
||||
/// Largeur / hauteur des PNG de pastille, à respecter pour ne pas déformer.
|
||||
static const double pillAspectRatio = 1190 / 298;
|
||||
|
||||
static BoxDecoration paperBackground() {
|
||||
return const BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: AssetImage(paperAsset),
|
||||
fit: BoxFit.cover,
|
||||
repeat: ImageRepeat.repeat,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// Sections du bandeau quotidien (Cahier de liaison / Agenda / Contrat).
|
||||
/// `liaison` = le hub « Cahier de liaison » (cartes · blog · messagerie).
|
||||
enum QuotidienNavSection {
|
||||
liaison,
|
||||
agenda,
|
||||
contrat,
|
||||
}
|
||||