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:
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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user