Un día sin tareas muestra una tarjeta con la mascota del niño en vez de una pantalla en blanco; la columna de tarde vacía tiene su propio mensaje. El feedback de recompensa suma una vibración corta (Vibration API) que se dispara junto al sonido sobre el mismo evento.
197 lines
6.4 KiB
TypeScript
197 lines
6.4 KiB
TypeScript
import { Component, OnInit, ViewChild, computed, inject, signal } from '@angular/core';
|
|
import { ActivatedRoute, Router } from '@angular/router';
|
|
import { ApiService } from '../../core/api.service';
|
|
import { FontPreferenceService } from '../../core/font-preference.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);
|
|
private readonly fontPreference = inject(FontPreferenceService);
|
|
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);
|
|
// Aplica la preferencia de tipografía de ESTE niño.
|
|
this.fontPreference.apply(data.child.dyslexiaFont);
|
|
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). Sonido y
|
|
// vibración se disparan juntos sobre el mismo evento (armonía multimodal).
|
|
if (result.coinsEarned > 0) {
|
|
this.flyCoins(ev.x, ev.y, result.coinsEarned);
|
|
this.wallet?.bump();
|
|
if (current.child.soundEnabled) {
|
|
this.sound.playReward();
|
|
this.sound.buzz();
|
|
}
|
|
}
|
|
|
|
// 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]);
|
|
}
|
|
}
|