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