feat: app completa recordaLexia (fases 1-5)
App web familiar de rutinas visuales para niños con TDAH: muestra cada día el material del cole y las rutinas de tarde, con gamificación por monedas y tienda de recompensas. Multi-niño y bilingüe ES/CA. Uso doméstico/homelab. Backend (Spring Boot 3.5 / Java 21 / Gradle): - Dominio por capas, PostgreSQL + Liquibase, datos semilla. - API REST con DTOs: /today, toggle con monedas y bonos de bloque/día, monedero, tienda/canje, ajustes y CRUD del panel de padres. - Seguridad ligera por PIN (BCrypt + sesion en memoria), sin Keycloak. - Tests JUnit: generacion del dia, monedas/bonos con reversion, canje, seguridad. Frontend (Angular 19, standalone + signals): - Perfiles, Home (Tablero y Foco), Tienda y panel de padres (5 pestañas). - Tipografia OpenDyslexic conmutable (accesibilidad), i18n ES/CA, TTS y sonido. - Tokens de diseño fieles al handoff (paleta, animaciones, monedas voladoras). Empaquetado: - Docker multi-stage + docker-compose (PostgreSQL + backend + Nginx). - Decisiones de arquitectura documentadas en docs/adr.
This commit is contained in:
68
frontend/src/app/features/home/board-view.component.ts
Normal file
68
frontend/src/app/features/home/board-view.component.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Component, EventEmitter, Input, Output, inject } from '@angular/core';
|
||||
import { I18nService } from '../../core/i18n.service';
|
||||
import { TaskView } from '../../core/models';
|
||||
import { TaskCardComponent, ToggleEvent } from './task-card.component';
|
||||
|
||||
/** Vista TABLERO: dos columnas (cole / tarde) con las tareas del día. */
|
||||
@Component({
|
||||
selector: 'app-board-view',
|
||||
imports: [TaskCardComponent],
|
||||
template: `
|
||||
<div class="board">
|
||||
<section class="col">
|
||||
<h2 class="col__head">🎒 {{ i18n.t('school') }}</h2>
|
||||
@if (morning.length) {
|
||||
@for (task of morning; track task.id) {
|
||||
<app-task-card [task]="task" [ttsEnabled]="ttsEnabled" (toggle)="toggle.emit($event)" />
|
||||
}
|
||||
} @else {
|
||||
<div class="empty">🏖️ {{ i18n.t('noSchool') }}</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="col">
|
||||
<h2 class="col__head">🌙 {{ i18n.t('afternoon') }}</h2>
|
||||
@for (task of afternoon; track task.id) {
|
||||
<app-task-card [task]="task" [ttsEnabled]="ttsEnabled" (toggle)="toggle.emit($event)" />
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.board {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.board { grid-template-columns: 1fr; }
|
||||
}
|
||||
.col { display: flex; flex-direction: column; gap: var(--space-3); }
|
||||
.col__head {
|
||||
margin: 0 0 var(--space-2);
|
||||
font-size: 1.4rem;
|
||||
color: var(--text-1);
|
||||
}
|
||||
.empty {
|
||||
padding: var(--space-6) var(--space-4);
|
||||
text-align: center;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 1.3rem;
|
||||
color: var(--text-2);
|
||||
background: var(--surface);
|
||||
border: 2px dashed var(--border-2);
|
||||
border-radius: var(--radius-card);
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class BoardViewComponent {
|
||||
@Input() morning: TaskView[] = [];
|
||||
@Input() afternoon: TaskView[] = [];
|
||||
@Input() ttsEnabled = true;
|
||||
@Output() toggle = new EventEmitter<ToggleEvent>();
|
||||
|
||||
protected readonly i18n = inject(I18nService);
|
||||
}
|
||||
104
frontend/src/app/features/home/celebration-overlay.component.ts
Normal file
104
frontend/src/app/features/home/celebration-overlay.component.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { Component, EventEmitter, Input, Output, inject } from '@angular/core';
|
||||
import { I18nService } from '../../core/i18n.service';
|
||||
|
||||
interface Confetti {
|
||||
left: number;
|
||||
delay: number;
|
||||
color: string;
|
||||
emoji: string;
|
||||
}
|
||||
|
||||
/** Overlay celebratorio al completar TODO el día: confeti, mascota, monedas y botón. */
|
||||
@Component({
|
||||
selector: 'app-celebration-overlay',
|
||||
imports: [],
|
||||
template: `
|
||||
<div class="cel">
|
||||
@for (c of confetti; track $index) {
|
||||
<span
|
||||
class="cel__confetti"
|
||||
[style.left.%]="c.left"
|
||||
[style.animation-delay.s]="c.delay"
|
||||
[style.color]="c.color"
|
||||
>{{ c.emoji }}</span
|
||||
>
|
||||
}
|
||||
<div class="cel__card">
|
||||
<div class="cel__mascot">{{ mascot }}🎉</div>
|
||||
<h2 class="cel__title">{{ i18n.t('allDone') }}</h2>
|
||||
<p class="cel__coins">+{{ coinsDay }} 🪙</p>
|
||||
<button type="button" class="cel__btn" (click)="close.emit()">
|
||||
{{ i18n.t('great') }} 👍
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.cel {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(35, 49, 66, 0.45);
|
||||
backdrop-filter: blur(3px);
|
||||
overflow: hidden;
|
||||
}
|
||||
.cel__confetti {
|
||||
position: absolute;
|
||||
top: -20vh;
|
||||
font-size: 26px;
|
||||
animation: confFall 2.4s linear infinite;
|
||||
}
|
||||
.cel__card {
|
||||
position: relative;
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-card);
|
||||
padding: 36px 40px;
|
||||
text-align: center;
|
||||
box-shadow: var(--shadow-pop);
|
||||
animation: celebPop 0.4s ease both;
|
||||
}
|
||||
.cel__mascot { font-size: 72px; animation: floatY 2.5s ease-in-out infinite; }
|
||||
.cel__title { margin: 12px 0; font-size: 2rem; color: var(--text-strong); }
|
||||
.cel__coins {
|
||||
margin: 0 0 20px;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 1.6rem;
|
||||
color: var(--coin-text);
|
||||
}
|
||||
.cel__btn {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 1.2rem;
|
||||
border: 0;
|
||||
border-radius: 18px;
|
||||
padding: 14px 28px;
|
||||
min-height: var(--touch-nav);
|
||||
background: var(--accent-green);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class CelebrationOverlayComponent {
|
||||
@Input() coinsDay = 0;
|
||||
@Input() mascot = '🦊';
|
||||
@Output() close = new EventEmitter<void>();
|
||||
|
||||
protected readonly i18n = inject(I18nService);
|
||||
|
||||
// Confeti generado una vez al crear el overlay.
|
||||
protected readonly confetti: Confetti[] = Array.from({ length: 28 }, () => ({
|
||||
left: Math.random() * 100,
|
||||
delay: Math.random() * 2,
|
||||
color: ['#F2A65A', '#5B8DEF', '#A78BD0', '#7FBF6B', '#5BC0BE', '#F4C95D', '#EC8FA4'][
|
||||
Math.floor(Math.random() * 7)
|
||||
],
|
||||
emoji: ['🎉', '⭐', '🪙', '✨'][Math.floor(Math.random() * 4)],
|
||||
}));
|
||||
}
|
||||
54
frontend/src/app/features/home/event-banner.component.ts
Normal file
54
frontend/src/app/features/home/event-banner.component.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Component, Input, inject } from '@angular/core';
|
||||
import { I18nService } from '../../core/i18n.service';
|
||||
import { TtsService } from '../../core/tts.service';
|
||||
import { EventView } from '../../core/models';
|
||||
|
||||
/** Banner de eventos del día (examen 📋 / deberes 📎) con lectura en voz alta. */
|
||||
@Component({
|
||||
selector: 'app-event-banner',
|
||||
imports: [],
|
||||
template: `
|
||||
@for (ev of events; track ev.id) {
|
||||
<div class="evt" [style.--c]="ev.color">
|
||||
<span class="evt__icon">{{ ev.icon }}</span>
|
||||
<span class="evt__text">{{ i18n.label(ev.titleEs, ev.titleCa) }}</span>
|
||||
@if (ttsEnabled && tts.supported) {
|
||||
<button type="button" class="evt__tts" (click)="speak(ev)" aria-label="Leer en voz alta">🔊</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.evt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--c) 16%, #fff);
|
||||
border: 2px solid color-mix(in srgb, var(--c) 45%, #fff);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.evt__icon { font-size: 28px; }
|
||||
.evt__text {
|
||||
flex: 1;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
.evt__tts { all: unset; cursor: pointer; font-size: 24px; }
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class EventBannerComponent {
|
||||
@Input() events: EventView[] = [];
|
||||
@Input() ttsEnabled = true;
|
||||
|
||||
protected readonly i18n = inject(I18nService);
|
||||
protected readonly tts = inject(TtsService);
|
||||
|
||||
speak(ev: EventView): void {
|
||||
this.tts.speak(this.i18n.label(ev.titleEs, ev.titleCa), this.i18n.lang());
|
||||
}
|
||||
}
|
||||
145
frontend/src/app/features/home/focus-view.component.ts
Normal file
145
frontend/src/app/features/home/focus-view.component.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
Component,
|
||||
ElementRef,
|
||||
EventEmitter,
|
||||
Input,
|
||||
Output,
|
||||
ViewChild,
|
||||
computed,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { I18nService } from '../../core/i18n.service';
|
||||
import { TtsService } from '../../core/tts.service';
|
||||
import { TaskView } from '../../core/models';
|
||||
import { ToggleEvent } from './task-card.component';
|
||||
|
||||
/** Vista FOCO: una sola tarea a pantalla completa, clave para reducir carga en TDAH. */
|
||||
@Component({
|
||||
selector: 'app-focus-view',
|
||||
imports: [],
|
||||
template: `
|
||||
@if (current(); as task) {
|
||||
<div class="focus">
|
||||
<div class="focus__nav">
|
||||
<button type="button" class="navbtn" [disabled]="index() === 0" (click)="prev()">‹</button>
|
||||
|
||||
<div class="focus__stage">
|
||||
<span class="hero" [class.hero--done]="task.done" [style.--c]="task.color">{{ task.icon }}</span>
|
||||
<h2 class="focus__label">{{ i18n.label(task.labelEs, task.labelCa) }}</h2>
|
||||
</div>
|
||||
|
||||
<button type="button" class="navbtn" [disabled]="index() >= tasks.length - 1" (click)="next()">›</button>
|
||||
</div>
|
||||
|
||||
<div class="dots">
|
||||
@for (t of tasks; track t.id; let i = $index) {
|
||||
<span class="dot" [class.dot--done]="t.done" [class.dot--current]="i === index()"></span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="focus__actions">
|
||||
<button #doneBtn type="button" class="bigbtn" [class.bigbtn--done]="task.done" (click)="emitToggle()">
|
||||
{{ task.done ? '✓' : i18n.t('done') }}
|
||||
</button>
|
||||
@if (ttsEnabled && tts.supported) {
|
||||
<button type="button" class="speakbtn" (click)="speak(task)" aria-label="Leer en voz alta">🔊</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<p class="focus__foot">
|
||||
{{ i18n.t('left') }} {{ remaining() }} · {{ i18n.t('next') }}: {{ nextLabel() }}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.focus { display: flex; flex-direction: column; align-items: center; gap: var(--space-5); padding: var(--space-5) 0; }
|
||||
.focus__nav { display: flex; align-items: center; gap: var(--space-5); }
|
||||
.focus__stage { display: flex; flex-direction: column; align-items: center; gap: var(--space-4); min-width: 260px; }
|
||||
.hero {
|
||||
width: var(--hero-size);
|
||||
height: var(--hero-size);
|
||||
border-radius: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 120px;
|
||||
background: color-mix(in srgb, var(--c) 16%, #fff);
|
||||
border: 4px solid color-mix(in srgb, var(--c) 35%, #fff);
|
||||
animation: floatY 3.5s ease-in-out infinite;
|
||||
}
|
||||
.hero--done { background: color-mix(in srgb, var(--c) 22%, #fff); border-color: var(--c); animation: pop 0.4s ease; }
|
||||
.focus__label { margin: 0; font-size: 2rem; text-transform: uppercase; text-align: center; color: var(--text-strong); }
|
||||
.navbtn {
|
||||
all: unset; cursor: pointer; width: var(--touch-nav); height: var(--touch-nav);
|
||||
border-radius: 50%; background: var(--surface); box-shadow: var(--shadow-btn);
|
||||
display: flex; align-items: center; justify-content: center; font-size: 38px; color: var(--text-4);
|
||||
}
|
||||
.navbtn:disabled { opacity: 0.3; cursor: default; }
|
||||
.dots { display: flex; gap: 10px; }
|
||||
.dot { width: 14px; height: 14px; border-radius: 50%; background: var(--border-2); transition: background 0.2s, transform 0.2s; }
|
||||
.dot--done { background: var(--accent-green); }
|
||||
.dot--current { transform: scale(1.4); box-shadow: 0 0 0 3px var(--surface-softer); }
|
||||
.focus__actions { display: flex; align-items: center; gap: var(--space-4); }
|
||||
.bigbtn {
|
||||
font-family: var(--font-display); font-weight: 700; font-size: 1.6rem; color: #fff;
|
||||
border: 0; border-radius: 24px; padding: 18px 48px; min-height: 72px; cursor: pointer;
|
||||
background: var(--accent-blue); transition: transform 0.12s;
|
||||
}
|
||||
.bigbtn--done { background: var(--accent-green); }
|
||||
.bigbtn:active { transform: scale(0.97); }
|
||||
.speakbtn { all: unset; cursor: pointer; font-size: 34px; }
|
||||
.focus__foot { margin: 0; color: var(--text-2); font-family: var(--font-display); font-weight: 600; }
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class FocusViewComponent {
|
||||
@Input() set tasksInput(value: TaskView[]) {
|
||||
this.tasks = value;
|
||||
if (this.index() >= value.length) {
|
||||
this.index.set(Math.max(0, value.length - 1));
|
||||
}
|
||||
}
|
||||
@Input() ttsEnabled = true;
|
||||
@Output() toggle = new EventEmitter<ToggleEvent>();
|
||||
|
||||
@ViewChild('doneBtn') private doneBtn?: ElementRef<HTMLElement>;
|
||||
|
||||
protected tasks: TaskView[] = [];
|
||||
protected readonly index = signal(0);
|
||||
protected readonly i18n = inject(I18nService);
|
||||
protected readonly tts = inject(TtsService);
|
||||
|
||||
protected readonly current = computed(() => this.tasks[this.index()] ?? null);
|
||||
protected readonly remaining = computed(() => this.tasks.filter((t) => !t.done).length);
|
||||
protected readonly nextLabel = computed(() => {
|
||||
const nextTask = this.tasks[this.index() + 1];
|
||||
return nextTask ? this.i18n.label(nextTask.labelEs, nextTask.labelCa) : '—';
|
||||
});
|
||||
|
||||
prev(): void {
|
||||
this.index.update((i) => Math.max(0, i - 1));
|
||||
}
|
||||
next(): void {
|
||||
this.index.update((i) => Math.min(this.tasks.length - 1, i + 1));
|
||||
}
|
||||
|
||||
emitToggle(): void {
|
||||
const task = this.current();
|
||||
if (!task) {
|
||||
return;
|
||||
}
|
||||
const rect = this.doneBtn?.nativeElement.getBoundingClientRect();
|
||||
this.toggle.emit({
|
||||
taskId: task.id,
|
||||
x: rect ? rect.left + rect.width / 2 : window.innerWidth / 2,
|
||||
y: rect ? rect.top : window.innerHeight / 2,
|
||||
});
|
||||
}
|
||||
|
||||
speak(task: TaskView): void {
|
||||
this.tts.speak(this.i18n.label(task.labelEs, task.labelCa), this.i18n.lang());
|
||||
}
|
||||
}
|
||||
73
frontend/src/app/features/home/home.component.html
Normal file
73
frontend/src/app/features/home/home.component.html
Normal file
@@ -0,0 +1,73 @@
|
||||
@if (today(); as t) {
|
||||
<main class="home">
|
||||
<!-- Cabecera -->
|
||||
<header class="home__top">
|
||||
<button type="button" class="iconbtn" (click)="goProfiles()" aria-label="Volver a perfiles">‹</button>
|
||||
|
||||
<div class="home__greet">
|
||||
<p class="home__hello">{{ i18n.t('hello') }}, {{ t.child.name }} {{ t.child.mascot }}</p>
|
||||
<p class="home__date">{{ dateLabel() }}</p>
|
||||
</div>
|
||||
|
||||
<app-morning-timer [minutes]="t.timer.minutesUntilDeparture" />
|
||||
|
||||
<div class="home__wallet">
|
||||
<button type="button" class="iconbtn" (click)="goStore()" aria-label="Tienda">🎁</button>
|
||||
<app-wallet #wallet [coins]="t.wallet.coins" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Conmutador de modo -->
|
||||
<div class="modes">
|
||||
<button type="button" class="modes__btn" [class.modes__btn--on]="mode() === 'BOARD'" (click)="setMode('BOARD')">
|
||||
🗂️ {{ i18n.t('board') }}
|
||||
</button>
|
||||
<button type="button" class="modes__btn" [class.modes__btn--on]="mode() === 'FOCUS'" (click)="setMode('FOCUS')">
|
||||
🎯 {{ i18n.t('focus') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Eventos del día -->
|
||||
<app-event-banner [events]="t.specialEvents" [ttsEnabled]="t.child.ttsEnabled" />
|
||||
|
||||
<!-- Progreso global -->
|
||||
<app-progress-bar [done]="t.progress.totalDone" [total]="t.progress.total" />
|
||||
|
||||
<!-- Tareas: tablero o foco -->
|
||||
@if (mode() === 'BOARD') {
|
||||
<app-board-view
|
||||
[morning]="t.morning"
|
||||
[afternoon]="t.afternoon"
|
||||
[ttsEnabled]="t.child.ttsEnabled"
|
||||
(toggle)="onToggle($event)"
|
||||
/>
|
||||
} @else {
|
||||
<app-focus-view [tasksInput]="allTasks()" [ttsEnabled]="t.child.ttsEnabled" (toggle)="onToggle($event)" />
|
||||
}
|
||||
</main>
|
||||
|
||||
<!-- Monedas voladoras (capa superpuesta) -->
|
||||
@for (coin of coins(); track coin.id) {
|
||||
<span
|
||||
class="flycoin"
|
||||
[style.left.px]="coin.x"
|
||||
[style.top.px]="coin.y"
|
||||
[style.transform]="coin.flying ? 'translate(' + coin.dx + 'px,' + coin.dy + 'px) scale(.4)' : 'translate(0,0) scale(1)'"
|
||||
[style.opacity]="coin.flying ? 0 : 1"
|
||||
>🪙</span
|
||||
>
|
||||
}
|
||||
|
||||
<!-- Celebración al completar el día -->
|
||||
@if (celebrating()) {
|
||||
<app-celebration-overlay
|
||||
[coinsDay]="lastEarned()"
|
||||
[mascot]="t.child.mascot"
|
||||
(close)="celebrating.set(false)"
|
||||
/>
|
||||
}
|
||||
} @else if (loading()) {
|
||||
<p class="home__msg">Cargando el día…</p>
|
||||
} @else {
|
||||
<p class="home__msg">No se pudo cargar el día. ¿Está arrancado el backend?</p>
|
||||
}
|
||||
100
frontend/src/app/features/home/home.component.scss
Normal file
100
frontend/src/app/features/home/home.component.scss
Normal file
@@ -0,0 +1,100 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.home {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-5) var(--space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-5);
|
||||
|
||||
&__msg {
|
||||
text-align: center;
|
||||
padding: var(--space-6);
|
||||
color: var(--text-1);
|
||||
}
|
||||
|
||||
&__top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__greet {
|
||||
flex: 1;
|
||||
min-width: 180px;
|
||||
}
|
||||
&__hello {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 1.6rem;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
&__date {
|
||||
margin: 2px 0 0;
|
||||
color: var(--text-2);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
&__wallet {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
}
|
||||
|
||||
.iconbtn {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
width: var(--touch-nav);
|
||||
height: var(--touch-nav);
|
||||
border-radius: 50%;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-btn);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28px;
|
||||
color: var(--text-2);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
// Conmutador Tablero / Foco.
|
||||
.modes {
|
||||
display: inline-flex;
|
||||
align-self: center;
|
||||
background: var(--surface-softer);
|
||||
border-radius: var(--radius-pill);
|
||||
padding: 4px;
|
||||
gap: 4px;
|
||||
|
||||
&__btn {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
padding: 10px 22px;
|
||||
border-radius: var(--radius-pill);
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
color: var(--text-2);
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
&__btn--on {
|
||||
background: var(--surface);
|
||||
color: var(--text-strong);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
}
|
||||
|
||||
// Moneda voladora: parte de la tarea y viaja al monedero.
|
||||
.flycoin {
|
||||
position: fixed;
|
||||
z-index: 60;
|
||||
font-size: 40px;
|
||||
pointer-events: none;
|
||||
will-change: transform, opacity;
|
||||
transition: transform 0.72s cubic-bezier(0.4, 0, 0.5, 1), opacity 0.72s;
|
||||
}
|
||||
190
frontend/src/app/features/home/home.component.ts
Normal file
190
frontend/src/app/features/home/home.component.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { Component, OnInit, ViewChild, computed, inject, signal } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { ApiService } from '../../core/api.service';
|
||||
import { I18nService } from '../../core/i18n.service';
|
||||
import { SoundService } from '../../core/sound.service';
|
||||
import { TodayResponse, ViewMode } from '../../core/models';
|
||||
import { BoardViewComponent } from './board-view.component';
|
||||
import { FocusViewComponent } from './focus-view.component';
|
||||
import { WalletComponent } from './wallet.component';
|
||||
import { MorningTimerComponent } from './morning-timer.component';
|
||||
import { ProgressBarComponent } from './progress-bar.component';
|
||||
import { EventBannerComponent } from './event-banner.component';
|
||||
import { CelebrationOverlayComponent } from './celebration-overlay.component';
|
||||
import { ToggleEvent } from './task-card.component';
|
||||
|
||||
/** Moneda voladora: parte del check de la tarea y vuela al monedero. */
|
||||
interface FlyingCoin {
|
||||
id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
dx: number;
|
||||
dy: number;
|
||||
flying: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pantalla "HOY". Orquesta la carga del día, el conmutado Tablero/Foco (persistido),
|
||||
* el marcado de tareas con su feedback (monedas voladoras, rebote del monedero,
|
||||
* sonido) y la celebración al completar el día.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-home',
|
||||
imports: [
|
||||
BoardViewComponent,
|
||||
FocusViewComponent,
|
||||
WalletComponent,
|
||||
MorningTimerComponent,
|
||||
ProgressBarComponent,
|
||||
EventBannerComponent,
|
||||
CelebrationOverlayComponent,
|
||||
],
|
||||
templateUrl: './home.component.html',
|
||||
styleUrl: './home.component.scss',
|
||||
})
|
||||
export class HomeComponent implements OnInit {
|
||||
private readonly api = inject(ApiService);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly sound = inject(SoundService);
|
||||
protected readonly i18n = inject(I18nService);
|
||||
|
||||
@ViewChild('wallet') private wallet?: WalletComponent;
|
||||
|
||||
protected readonly today = signal<TodayResponse | null>(null);
|
||||
protected readonly loading = signal(true);
|
||||
protected readonly mode = signal<ViewMode>('BOARD');
|
||||
protected readonly coins = signal<FlyingCoin[]>([]);
|
||||
protected readonly celebrating = signal(false);
|
||||
protected readonly lastEarned = signal(0);
|
||||
|
||||
private childId!: number;
|
||||
private coinSeq = 0;
|
||||
|
||||
/** Todas las tareas en orden (mañana y luego tarde) para el modo Foco. */
|
||||
protected readonly allTasks = computed(() => {
|
||||
const t = this.today();
|
||||
return t ? [...t.morning, ...t.afternoon] : [];
|
||||
});
|
||||
|
||||
/** Fecha de hoy formateada en el idioma activo. */
|
||||
protected readonly dateLabel = computed(() => {
|
||||
const locale = this.i18n.lang() === 'CA' ? 'ca-ES' : 'es-ES';
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
}).format(new Date());
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
this.childId = Number(this.route.snapshot.paramMap.get('childId'));
|
||||
this.load();
|
||||
}
|
||||
|
||||
private load(): void {
|
||||
this.api.getToday(this.childId).subscribe({
|
||||
next: (data) => {
|
||||
this.today.set(data);
|
||||
this.mode.set(data.child.viewMode);
|
||||
this.i18n.setLang(data.child.language);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => this.loading.set(false),
|
||||
});
|
||||
}
|
||||
|
||||
/** Cambia entre Tablero y Foco y persiste la preferencia del niño. */
|
||||
setMode(mode: ViewMode): void {
|
||||
if (mode === this.mode()) {
|
||||
return;
|
||||
}
|
||||
this.mode.set(mode);
|
||||
this.api.updateSettings(this.childId, { viewMode: mode }).subscribe();
|
||||
}
|
||||
|
||||
/** Marca/desmarca una tarea y aplica el feedback (monedas, sonido, celebración). */
|
||||
onToggle(ev: ToggleEvent): void {
|
||||
this.api.toggleTask(ev.taskId).subscribe((result) => {
|
||||
const current = this.today();
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Actualiza estado del día de forma inmutable.
|
||||
const apply = (list: typeof current.morning) =>
|
||||
list.map((task) => (task.id === ev.taskId ? { ...task, done: result.done } : task));
|
||||
const updated: TodayResponse = {
|
||||
...current,
|
||||
morning: apply(current.morning),
|
||||
afternoon: apply(current.afternoon),
|
||||
progress: result.progress,
|
||||
wallet: { coins: result.newBalance },
|
||||
};
|
||||
this.today.set(updated);
|
||||
|
||||
// Feedback positivo solo al ganar monedas (marcar, no desmarcar).
|
||||
if (result.coinsEarned > 0) {
|
||||
this.flyCoins(ev.x, ev.y, result.coinsEarned);
|
||||
this.wallet?.bump();
|
||||
if (current.child.soundEnabled) {
|
||||
this.sound.playReward();
|
||||
}
|
||||
}
|
||||
|
||||
// Celebración al completar TODO el día.
|
||||
if (result.progress.total > 0 && result.progress.totalDone === result.progress.total) {
|
||||
this.lastEarned.set(result.coinsEarned);
|
||||
this.celebrating.set(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Lanza monedas desde (x,y) hacia el monedero. */
|
||||
private flyCoins(x: number, y: number, earned: number): void {
|
||||
const rect = this.wallet?.getRect();
|
||||
if (!rect) {
|
||||
return;
|
||||
}
|
||||
const targetX = rect.left + rect.width / 2;
|
||||
const targetY = rect.top + rect.height / 2;
|
||||
const n = Math.min(Math.max(1, Math.round(earned / 5)), 6);
|
||||
|
||||
const nuevas: FlyingCoin[] = [];
|
||||
for (let k = 0; k < n; k++) {
|
||||
const jitterX = (k - n / 2) * 14;
|
||||
const startX = x + jitterX;
|
||||
const startY = y;
|
||||
nuevas.push({
|
||||
id: this.coinSeq++,
|
||||
x: startX,
|
||||
y: startY,
|
||||
dx: targetX - startX,
|
||||
dy: targetY - startY,
|
||||
flying: false,
|
||||
});
|
||||
}
|
||||
this.coins.update((c) => [...c, ...nuevas]);
|
||||
|
||||
// En el siguiente frame, activa la transición (vuelo).
|
||||
requestAnimationFrame(() =>
|
||||
requestAnimationFrame(() =>
|
||||
this.coins.update((c) =>
|
||||
c.map((coin) => (nuevas.some((nc) => nc.id === coin.id) ? { ...coin, flying: true } : coin)),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Retira las monedas tras la animación.
|
||||
const ids = new Set(nuevas.map((c) => c.id));
|
||||
setTimeout(() => this.coins.update((c) => c.filter((coin) => !ids.has(coin.id))), 800);
|
||||
}
|
||||
|
||||
goProfiles(): void {
|
||||
this.router.navigate(['/']);
|
||||
}
|
||||
|
||||
goStore(): void {
|
||||
this.router.navigate(['/store', this.childId]);
|
||||
}
|
||||
}
|
||||
41
frontend/src/app/features/home/morning-timer.component.ts
Normal file
41
frontend/src/app/features/home/morning-timer.component.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Component, Input, inject } from '@angular/core';
|
||||
import { I18nService } from '../../core/i18n.service';
|
||||
|
||||
/** Temporizador de salida de la mañana: "SALIMOS EN {min} min" con anillo glow. */
|
||||
@Component({
|
||||
selector: 'app-morning-timer',
|
||||
imports: [],
|
||||
template: `
|
||||
@if (minutes !== null) {
|
||||
<div class="timer">
|
||||
<span class="timer__ring">⏰</span>
|
||||
<span class="timer__text">{{ i18n.t('leaveIn') }} {{ minutes }} {{ i18n.t('min') }}</span>
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.timer {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: color-mix(in srgb, var(--accent-orange) 14%, #fff);
|
||||
color: var(--text-strong);
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
}
|
||||
.timer__ring {
|
||||
font-size: 22px;
|
||||
border-radius: 50%;
|
||||
animation: ringGlow 2.2s ease-in-out infinite;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class MorningTimerComponent {
|
||||
@Input() minutes: number | null = null;
|
||||
|
||||
protected readonly i18n = inject(I18nService);
|
||||
}
|
||||
49
frontend/src/app/features/home/progress-bar.component.ts
Normal file
49
frontend/src/app/features/home/progress-bar.component.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Component, Input, inject } from '@angular/core';
|
||||
import { I18nService } from '../../core/i18n.service';
|
||||
|
||||
/** Barra de progreso global del día: "{hechas}/{total} listo ✨". */
|
||||
@Component({
|
||||
selector: 'app-progress-bar',
|
||||
imports: [],
|
||||
template: `
|
||||
<div class="prog">
|
||||
<div class="prog__track">
|
||||
<div class="prog__fill" [style.width.%]="pct"></div>
|
||||
</div>
|
||||
<p class="prog__label">{{ done }}/{{ total }} {{ i18n.t('ready') }} ✨</p>
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.prog { display: flex; flex-direction: column; gap: 6px; }
|
||||
.prog__track {
|
||||
height: 16px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--surface-softer);
|
||||
overflow: hidden;
|
||||
}
|
||||
.prog__fill {
|
||||
height: 100%;
|
||||
border-radius: var(--radius-pill);
|
||||
background: linear-gradient(90deg, var(--accent-green), var(--accent-teal));
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
.prog__label {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
color: var(--text-1);
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class ProgressBarComponent {
|
||||
@Input() done = 0;
|
||||
@Input() total = 0;
|
||||
|
||||
protected readonly i18n = inject(I18nService);
|
||||
|
||||
get pct(): number {
|
||||
return this.total > 0 ? Math.round((this.done / this.total) * 100) : 0;
|
||||
}
|
||||
}
|
||||
131
frontend/src/app/features/home/task-card.component.ts
Normal file
131
frontend/src/app/features/home/task-card.component.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { Component, ElementRef, EventEmitter, Input, Output, ViewChild, inject } from '@angular/core';
|
||||
import { I18nService } from '../../core/i18n.service';
|
||||
import { TtsService } from '../../core/tts.service';
|
||||
import { TaskView } from '../../core/models';
|
||||
|
||||
/** Coordenadas (centro del check) desde donde sale la moneda voladora. */
|
||||
export interface ToggleEvent {
|
||||
taskId: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tarjeta de tarea del Tablero: icono en tile, etiqueta en MAYÚSCULAS, botón de
|
||||
* lectura (TTS) y check grande. Réplica fiel del handoff (borde 3px, radio 26px,
|
||||
* tinte del color al completar, animaciones pop/checkPop).
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-task-card',
|
||||
imports: [],
|
||||
template: `
|
||||
<div
|
||||
class="card"
|
||||
[class.card--done]="task.done"
|
||||
[style.--c]="task.color"
|
||||
(click)="emitToggle()"
|
||||
>
|
||||
<span class="card__tile">{{ task.icon }}</span>
|
||||
<span class="card__label">{{ i18n.label(task.labelEs, task.labelCa) }}</span>
|
||||
@if (ttsEnabled && tts.supported) {
|
||||
<button type="button" class="card__tts" (click)="speak($event)" aria-label="Leer en voz alta">
|
||||
🔊
|
||||
</button>
|
||||
}
|
||||
<span #check class="card__check">{{ task.done ? '✓' : '' }}</span>
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
min-height: var(--card-min-height);
|
||||
padding: 14px 16px;
|
||||
background: var(--surface);
|
||||
border: var(--card-border-width) solid var(--card-border-idle);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s, border-color 0.25s, background 0.25s;
|
||||
}
|
||||
.card:active { transform: scale(0.99); }
|
||||
.card__tile {
|
||||
width: var(--tile-size);
|
||||
height: var(--tile-size);
|
||||
border-radius: var(--radius-tile);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 38px;
|
||||
flex: none;
|
||||
background: color-mix(in srgb, var(--c) 16%, #fff);
|
||||
}
|
||||
.card__label {
|
||||
flex: 1;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 1.15rem;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
.card__tts {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
font-size: 26px;
|
||||
padding: 6px;
|
||||
border-radius: 50%;
|
||||
flex: none;
|
||||
}
|
||||
.card__check {
|
||||
width: var(--touch-check);
|
||||
height: var(--touch-check);
|
||||
border-radius: 50%;
|
||||
border: 3px solid var(--border-2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28px;
|
||||
color: #fff;
|
||||
flex: none;
|
||||
}
|
||||
.card--done {
|
||||
background: color-mix(in srgb, var(--c) 14%, #fff);
|
||||
border-color: var(--c);
|
||||
animation: pop 0.35s ease;
|
||||
}
|
||||
.card--done .card__check {
|
||||
background: var(--c);
|
||||
border-color: var(--c);
|
||||
animation: checkPop 0.35s ease;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class TaskCardComponent {
|
||||
@Input({ required: true }) task!: TaskView;
|
||||
@Input() ttsEnabled = true;
|
||||
|
||||
@Output() toggle = new EventEmitter<ToggleEvent>();
|
||||
|
||||
@ViewChild('check') private check!: ElementRef<HTMLElement>;
|
||||
|
||||
protected readonly i18n = inject(I18nService);
|
||||
protected readonly tts = inject(TtsService);
|
||||
|
||||
emitToggle(): void {
|
||||
const rect = this.check.nativeElement.getBoundingClientRect();
|
||||
this.toggle.emit({
|
||||
taskId: this.task.id,
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top + rect.height / 2,
|
||||
});
|
||||
}
|
||||
|
||||
/** Lee la etiqueta en voz alta sin propagar el toque al check. */
|
||||
speak(event: Event): void {
|
||||
event.stopPropagation();
|
||||
this.tts.speak(this.i18n.label(this.task.labelEs, this.task.labelCa), this.i18n.lang());
|
||||
}
|
||||
}
|
||||
49
frontend/src/app/features/home/wallet.component.ts
Normal file
49
frontend/src/app/features/home/wallet.component.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Component, ElementRef, Input, inject, signal } from '@angular/core';
|
||||
|
||||
/** Monedero: pill con 🪙 y el saldo. Hace walletBump cuando se le pide (al ganar). */
|
||||
@Component({
|
||||
selector: 'app-wallet',
|
||||
imports: [],
|
||||
template: `
|
||||
<span class="wallet" [class.wallet--bump]="bumping()">
|
||||
<span class="wallet__coin">🪙</span>
|
||||
<span class="wallet__amount">{{ coins }}</span>
|
||||
</span>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.wallet {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 9px 18px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--coin-bg);
|
||||
color: var(--coin-text);
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
.wallet--bump { animation: walletBump 0.45s ease; }
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class WalletComponent {
|
||||
@Input({ required: true }) coins = 0;
|
||||
|
||||
protected readonly bumping = signal(false);
|
||||
private readonly el = inject(ElementRef<HTMLElement>);
|
||||
|
||||
/** Rectángulo del monedero: destino de las monedas voladoras. */
|
||||
getRect(): DOMRect {
|
||||
return (this.el.nativeElement as HTMLElement).getBoundingClientRect();
|
||||
}
|
||||
|
||||
/** Dispara la animación de "rebote" del monedero. */
|
||||
bump(): void {
|
||||
this.bumping.set(false);
|
||||
// Reinicia la animación en el siguiente frame.
|
||||
requestAnimationFrame(() => this.bumping.set(true));
|
||||
setTimeout(() => this.bumping.set(false), 500);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user