feat(home): estado vacío amable y vibración háptica al ganar monedas

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.
This commit is contained in:
Jaume Garriga Maestre
2026-07-12 22:06:03 +02:00
parent 3ec611d373
commit 5c79480aa4
6 changed files with 85 additions and 26 deletions

View File

@@ -21,6 +21,9 @@ export class I18nService {
afternoon: { ES: 'TARDE', CA: 'TARDA' },
ready: { ES: 'listo', CA: 'fet' },
noSchool: { ES: 'HOY NO HAY COLE', CA: 'AVUI NO HI HA COLE' },
freeAfternoon: { ES: 'TARDE LIBRE', CA: 'TARDA LLIURE' },
emptyDayTitle: { ES: '¡Hoy no hay tareas!', CA: 'Avui no hi ha tasques!' },
emptyDaySub: { ES: 'Disfruta del día', CA: 'Gaudeix del dia' },
allDone: { ES: '¡TODO LISTO!', CA: 'TOT FET!' },
great: { ES: '¡GENIAL!', CA: 'GENIAL!' },
done: { ES: '¡HECHO!', CA: 'FET!' },

View File

@@ -1,8 +1,10 @@
import { Injectable } from '@angular/core';
/**
* Sonido de recompensa sintetizado con WebAudio (sin ficheros de audio). Un par
* de notas alegres al ganar monedas. Silencioso si WebAudio no está disponible.
* Feedback de recompensa: sonido sintetizado con WebAudio (sin ficheros de
* audio) y vibración háptica sutil en dispositivos que la soportan (tablets).
* Ambos se disparan sobre el MISMO evento (causalidad) para que se perciban
* como una sola respuesta. Silencioso/inerte si el navegador no lo soporta.
*/
@Injectable({ providedIn: 'root' })
export class SoundService {
@@ -47,4 +49,11 @@ export class SoundService {
osc.stop(start + 0.28);
});
}
/** Vibración corta al ganar monedas (no-op si el dispositivo no vibra). */
buzz(): void {
if (typeof navigator !== 'undefined' && 'vibrate' in navigator) {
navigator.vibrate(15);
}
}
}

View File

@@ -22,8 +22,12 @@ import { TaskCardComponent, ToggleEvent } from './task-card.component';
<section class="col">
<h2 class="col__head">🌙 {{ i18n.t('afternoon') }}</h2>
@for (task of afternoon; track task.id) {
<app-task-card [task]="task" [ttsEnabled]="ttsEnabled" (toggle)="toggle.emit($event)" />
@if (afternoon.length) {
@for (task of afternoon; track task.id) {
<app-task-card [task]="task" [ttsEnabled]="ttsEnabled" (toggle)="toggle.emit($event)" />
}
} @else {
<div class="empty">🌈 {{ i18n.t('freeAfternoon') }}</div>
}
</section>
</div>

View File

@@ -17,32 +17,41 @@
</div>
</header>
<!-- Conmutador de modo -->
<div class="modes">
<button type="button" class="modes__btn" [class.modes__btn--on]="mode() === 'BOARD'" (click)="setMode('BOARD')">
🗂️ {{ i18n.t('board') }}
</button>
<button type="button" class="modes__btn" [class.modes__btn--on]="mode() === 'FOCUS'" (click)="setMode('FOCUS')">
🎯 {{ i18n.t('focus') }}
</button>
</div>
<!-- Eventos del día -->
<app-event-banner [events]="t.specialEvents" [ttsEnabled]="t.child.ttsEnabled" />
<!-- Progreso global -->
<app-progress-bar [done]="t.progress.totalDone" [total]="t.progress.total" />
@if (t.progress.total > 0) {
<!-- Conmutador de modo -->
<div class="modes">
<button type="button" class="modes__btn" [class.modes__btn--on]="mode() === 'BOARD'" (click)="setMode('BOARD')">
🗂️ {{ i18n.t('board') }}
</button>
<button type="button" class="modes__btn" [class.modes__btn--on]="mode() === 'FOCUS'" (click)="setMode('FOCUS')">
🎯 {{ i18n.t('focus') }}
</button>
</div>
<!-- Tareas: tablero o foco -->
@if (mode() === 'BOARD') {
<app-board-view
[morning]="t.morning"
[afternoon]="t.afternoon"
[ttsEnabled]="t.child.ttsEnabled"
(toggle)="onToggle($event)"
/>
<!-- Progreso global -->
<app-progress-bar [done]="t.progress.totalDone" [total]="t.progress.total" />
<!-- Tareas: tablero o foco -->
@if (mode() === 'BOARD') {
<app-board-view
[morning]="t.morning"
[afternoon]="t.afternoon"
[ttsEnabled]="t.child.ttsEnabled"
(toggle)="onToggle($event)"
/>
} @else {
<app-focus-view [tasksInput]="allTasks()" [ttsEnabled]="t.child.ttsEnabled" (toggle)="onToggle($event)" />
}
} @else {
<app-focus-view [tasksInput]="allTasks()" [ttsEnabled]="t.child.ttsEnabled" (toggle)="onToggle($event)" />
<!-- Día sin tareas: mensaje amable, nunca una pantalla en blanco. -->
<div class="freeday">
<span class="freeday__mascot">{{ t.child.mascot }}</span>
<p class="freeday__title">{{ i18n.t('emptyDayTitle') }}</p>
<p class="freeday__sub">{{ i18n.t('emptyDaySub') }} 🎉</p>
</div>
}
</main>

View File

@@ -89,6 +89,38 @@
}
}
// Día sin tareas: tarjeta amable con la mascota, nunca una pantalla vacía.
.freeday {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
padding: var(--space-6) var(--space-4);
background: var(--surface);
border-radius: var(--radius-card);
box-shadow: var(--shadow-card);
text-align: center;
animation: slideUp 0.4s ease both;
&__mascot {
font-size: 96px;
line-height: 1;
animation: floatY 3s ease-in-out infinite;
}
&__title {
margin: var(--space-3) 0 0;
font-family: var(--font-display);
font-weight: 700;
font-size: 1.8rem;
color: var(--text-strong);
}
&__sub {
margin: 0;
font-size: 1.2rem;
color: var(--text-1);
}
}
// Moneda voladora: parte de la tarea y viaja al monedero.
.flycoin {
position: fixed;

View File

@@ -127,12 +127,14 @@ export class HomeComponent implements OnInit {
};
this.today.set(updated);
// Feedback positivo solo al ganar monedas (marcar, no desmarcar).
// 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();
}
}