Files
recordalexia/frontend/src/app/features/home/focus-view.component.ts
Jaume Garriga Maestre 52e559a159 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.
2026-06-21 10:48:57 +02:00

146 lines
5.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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());
}
}