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:
Jaume Garriga Maestre
2026-06-21 10:48:57 +02:00
commit 52e559a159
160 changed files with 29022 additions and 0 deletions

View 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());
}
}