Files
lisael-box/main/modes/routine.c
T
L'électron rare 2fffcfe9a3 feat(box): celebratory spinning star on bravo
Make the end-of-routine "Bravo !" lively: the star now spins
(continuous full turn via lv_image_set_rotation) and bounces
(translate_y ping-pong). Both use layer-free paths — image
rotation transforms against the flush band, translate is not a
layer-forcing style — so the render task stays light (no repeat
of the full-screen-layer task_wdt). Auto-return stretched to
3.6 s so the spin shows and the festive cue finishes.

Tower: seed a more festive bravo.mp3 line ("Youpi, tu as
réussi ! Tu es super !").
2026-06-21 12:50:59 +02:00

479 lines
18 KiB
C

// "Routine" — illustrated step-by-step routine (matin/soir) for Lisael Box.
//
// Content lives on the SD at /sdcard/routines/: routines.json (manifest) +
// per-step Twemoji .bin pictos + .mp3 voice announcements, all pushed by Tower
// over Wi-Fi (no reflash to change a routine). Three screens:
// 1. choice : two big cards (Matin / Soir), tap to open a routine
// 2. step : big picto + label + N/total dots + "C'est fait" + voice
// 3. bravo : star + congratulation, back to Home
//
// Pictos are read ONCE into PSRAM and handed to LVGL as in-RAM image
// descriptors (same trick as lire.c) — never the "S:" FS driver, which re-reads
// the slow microSD on every draw band. Structures live in PSRAM, never a stack.
// The builder runs under the LVGL lock held by the caller: it never re-takes it.
#include "ui/ui.h"
#include "ui/assets/lisael_icons.h"
#include "ui/fonts/lisael_fonts.h"
#include "audio/routine_manifest.h"
#include "audio/aac_player.h"
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include "esp_heap_caps.h"
#include "esp_log.h"
static const char *TAG = "ui.routine";
static lisael_routine_t *s_routines; // PSRAM
static int s_n; // number of routines loaded (<=2)
static lv_obj_t *s_root; // cached choice screen (owned by ui.c)
static lv_obj_t *s_step_screen; // rebuilt per routine run
static int s_ridx; // current routine index
static int s_sidx; // current step index
static const uint32_t s_card_colors[2] = { 0xFFE08A, 0x748FFC }; // matin / soir
static void open_routine(int ridx); // fwd decl (implemented below, after step screen)
// --- content load (PSRAM) ----------------------------------------------------
static int load_routines(void)
{
if (!s_routines) {
s_routines = heap_caps_calloc(LISAEL_MAX_ROUTINES, sizeof(lisael_routine_t),
MALLOC_CAP_SPIRAM);
}
if (!s_routines) {
return -1;
}
int n = lisael_routine_load(s_routines, LISAEL_MAX_ROUTINES);
ESP_LOGI(TAG, "loaded %d routine(s)", n);
return n;
}
// --- PSRAM picto cache (keyed by .bin basename, e.g. "matin_01.bin") --------
typedef struct {
char name[40];
void *buf; // whole .bin in PSRAM (NULL = known-missing)
lv_image_dsc_t dsc;
} routine_icon_t;
#define ROUTINE_ICON_CACHE (LISAEL_MAX_ROUTINES * (LISAEL_MAX_STEPS + 1))
static routine_icon_t *s_icons; // PSRAM
static int s_icons_n;
static const lv_image_dsc_t *routine_icon_dsc(const char *name)
{
if (!name || !name[0]) {
return NULL;
}
for (int i = 0; i < s_icons_n; i++) {
if (!strcmp(s_icons[i].name, name)) {
return s_icons[i].buf ? &s_icons[i].dsc : NULL;
}
}
if (!s_icons) {
s_icons = heap_caps_calloc(ROUTINE_ICON_CACHE, sizeof(routine_icon_t),
MALLOC_CAP_SPIRAM);
if (!s_icons) return NULL;
}
if (s_icons_n >= ROUTINE_ICON_CACHE) return NULL;
routine_icon_t *c = &s_icons[s_icons_n++]; // negatively caches misses too
snprintf(c->name, sizeof(c->name), "%s", name);
c->buf = NULL;
char p[80];
snprintf(p, sizeof(p), "/sdcard/routines/%s", name);
FILE *f = fopen(p, "rb");
if (!f) return NULL;
fseek(f, 0, SEEK_END);
long sz = ftell(f);
fseek(f, 0, SEEK_SET);
if (sz <= (long)sizeof(lv_image_header_t) || sz > 256 * 1024) { fclose(f); return NULL; }
uint8_t *buf = heap_caps_malloc((size_t)sz, MALLOC_CAP_SPIRAM);
if (!buf) { fclose(f); return NULL; }
size_t rd = fread(buf, 1, (size_t)sz, f);
fclose(f);
if (rd != (size_t)sz) { heap_caps_free(buf); return NULL; }
lv_memcpy(&c->dsc.header, buf, sizeof(lv_image_header_t));
c->dsc.data = buf + sizeof(lv_image_header_t);
c->dsc.data_size = (uint32_t)(sz - (long)sizeof(lv_image_header_t));
c->buf = buf;
return &c->dsc;
}
// A picto from the PSRAM cache, or a coloured fallback box with an initial.
static lv_obj_t *make_routine_icon(lv_obj_t *parent, const char *name,
char fallback_initial, uint32_t fallback_color, int px)
{
const lv_image_dsc_t *d = routine_icon_dsc(name);
if (d) {
lv_obj_t *img = lv_image_create(parent);
lv_image_set_src(img, d);
lv_obj_set_size(img, px, px);
lv_image_set_inner_align(img, LV_IMAGE_ALIGN_CONTAIN);
return img;
}
lv_obj_t *box = lv_obj_create(parent);
lv_obj_remove_style_all(box);
lv_obj_set_size(box, px, px);
lv_obj_set_style_radius(box, 14, 0);
lv_obj_set_style_bg_opa(box, LV_OPA_COVER, 0);
lv_obj_set_style_bg_color(box, lv_color_hex(fallback_color), 0);
lv_obj_clear_flag(box, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t *l = lv_label_create(box);
char ini[2] = { fallback_initial ? fallback_initial : '?', '\0' };
lv_label_set_text(l, ini);
lv_obj_set_style_text_color(l, lv_color_white(), 0);
lv_obj_center(l);
return box;
}
// --- choice screen -----------------------------------------------------------
static void card_cb(lv_event_t *e)
{
int ridx = (int)(intptr_t)lv_event_get_user_data(e);
open_routine(ridx);
}
static void add_choice_card(lv_obj_t *parent, int ridx)
{
lisael_routine_t *r = &s_routines[ridx];
uint32_t col = s_card_colors[ridx % 2];
lv_obj_t *card = lv_button_create(parent);
lv_obj_add_style(card, lisael_ui_style_tile(), 0);
lv_obj_set_style_bg_color(card, lv_color_lighten(lv_color_hex(col), 90), 0);
lv_obj_set_style_bg_grad_color(card, lv_color_hex(col), 0);
lv_obj_set_style_bg_grad_dir(card, LV_GRAD_DIR_VER, 0);
lv_obj_set_size(card, 138, 150);
lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(card, LV_FLEX_ALIGN_CENTER,
LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_row(card, 8, 0);
lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_event_cb(card, card_cb, LV_EVENT_CLICKED, (void *)(intptr_t)ridx);
make_routine_icon(card, r->icon, r->title[0], col, 88);
lv_obj_t *l = lv_label_create(card);
lv_label_set_text(l, r->title);
lv_obj_set_style_text_font(l, &lisael_font_24, 0);
}
lv_obj_t *lisael_screen_routine(void)
{
if (s_root) {
return s_root;
}
s_root = lv_obj_create(NULL);
lv_obj_set_style_bg_color(s_root, lv_color_hex(0xEAF3FF), 0);
lv_obj_set_style_bg_grad_color(s_root, lv_color_hex(0xFFFFFF), 0);
lv_obj_set_style_bg_grad_dir(s_root, LV_GRAD_DIR_VER, 0);
lisael_ui_add_back_button(s_root);
lv_obj_t *title = lv_label_create(s_root);
lv_label_set_text(title, "Ma routine");
lv_obj_set_style_text_font(title, &lisael_font_24, 0);
lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 14);
s_n = load_routines();
if (s_n <= 0) {
lv_obj_t *info = lv_label_create(s_root);
lv_label_set_text(info,
"Demande à un parent\nde préparer tes routines.");
lv_obj_set_style_text_align(info, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_center(info);
return s_root;
}
lv_obj_t *row = lv_obj_create(s_root);
lv_obj_remove_style_all(row);
lv_obj_set_size(row, LV_PCT(100), 170);
lv_obj_align(row, LV_ALIGN_TOP_MID, 0, 56);
lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(row, LV_FLEX_ALIGN_SPACE_EVENLY,
LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE);
for (int i = 0; i < s_n; i++) {
add_choice_card(row, i);
}
return s_root;
}
// --- step screen -------------------------------------------------------------
static void show_bravo(void); // fwd decl (Task 4c)
static void play_step_audio(void)
{
lisael_routine_t *r = &s_routines[s_ridx];
if (s_sidx >= r->n_steps) {
return;
}
const char *a = r->steps[s_sidx].audio;
if (!a[0]) {
return; // step with no voice -> silent, non-blocking
}
char p[80];
snprintf(p, sizeof(p), "/sdcard/routines/%s", a);
lisael_aac_play_file(p); // routes .mp3 by extension internally
}
static void build_step_screen(void); // fwd decl
static void step_home_cb(lv_event_t *e)
{
(void)e;
lisael_aac_stop();
lv_obj_t *old = s_step_screen;
s_step_screen = NULL;
lisael_ui_goto(LISAEL_MODE_HOME);
// lisael_ui_goto uses auto_del=false (cached mode screens), so the transient
// step screen would leak without an explicit delete. Schedule it after the
// 250 ms slide animation finishes so it is off-screen before deletion.
if (old) {
lv_obj_delete_delayed(old, 350);
}
}
static void done_cb(lv_event_t *e)
{
(void)e;
lisael_routine_t *r = &s_routines[s_ridx];
// short validation sound (reuse the reading game's spoken-word path is
// overkill; a dedicated cue lives on the SD as a routine asset "ok.mp3").
lisael_aac_play_file("/sdcard/routines/ok.mp3");
if (s_sidx + 1 >= r->n_steps) {
show_bravo();
return;
}
s_sidx++;
build_step_screen(); // rebuild the step screen content in place
}
static void build_step_screen(void)
{
lisael_routine_t *r = &s_routines[s_ridx];
// Build a fresh screen object each call (forward nav). Loading it with an
// animation; the previous step screen is auto-deleted by the load_anim.
lv_obj_t *scr = lv_obj_create(NULL);
lv_obj_set_style_bg_color(scr, lv_color_hex(0xFFF8E8), 0);
lv_obj_set_style_bg_grad_color(scr, lv_color_hex(0xFFFFFF), 0);
lv_obj_set_style_bg_grad_dir(scr, LV_GRAD_DIR_VER, 0);
// back-to-Home button (top-left), separate from "C'est fait"
lv_obj_t *home = lv_button_create(scr);
lv_obj_add_style(home, lisael_ui_style_button(), 0);
lv_obj_set_size(home, 64, 56);
lv_obj_align(home, LV_ALIGN_TOP_LEFT, 6, 6);
lv_obj_add_event_cb(home, step_home_cb, LV_EVENT_CLICKED, NULL);
lv_obj_t *hl = lv_label_create(home);
lv_label_set_text(hl, LV_SYMBOL_HOME);
lv_obj_center(hl);
// progression dots N/total (top-right area)
lv_obj_t *prog = lv_obj_create(scr);
lv_obj_remove_style_all(prog);
lv_obj_set_size(prog, 150, 18);
lv_obj_align(prog, LV_ALIGN_TOP_RIGHT, -10, 22);
lv_obj_set_flex_flow(prog, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(prog, LV_FLEX_ALIGN_END,
LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_clear_flag(prog, LV_OBJ_FLAG_SCROLLABLE);
for (int i = 0; i < r->n_steps; i++) {
lv_obj_t *dot = lv_obj_create(prog);
lv_obj_remove_style_all(dot);
lv_obj_set_size(dot, 11, 11);
lv_obj_set_style_radius(dot, 6, 0);
lv_obj_set_style_margin_left(dot, 3, 0);
lv_obj_set_style_bg_color(dot, lv_color_hex(0x2F9E44), 0);
lv_obj_set_style_bg_opa(dot, i <= s_sidx ? LV_OPA_COVER : LV_OPA_30, 0);
}
// big picto (centered, upper half)
lv_obj_t *pic = make_routine_icon(scr, r->steps[s_sidx].icon,
r->steps[s_sidx].text[0],
s_card_colors[s_ridx % 2], 150);
lv_obj_align(pic, LV_ALIGN_TOP_MID, 0, 50);
// step label (Fredoka, large)
lv_obj_t *lbl = lv_label_create(scr);
lv_label_set_text(lbl, r->steps[s_sidx].text);
lv_label_set_long_mode(lbl, LV_LABEL_LONG_WRAP);
lv_obj_set_width(lbl, 300);
lv_obj_set_style_text_align(lbl, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_set_style_text_font(lbl, &lisael_font_30, 0);
lv_obj_align(lbl, LV_ALIGN_CENTER, 0, 56);
// giant "C'est fait ✓" button (bottom)
lv_obj_t *done = lv_button_create(scr);
lv_obj_add_style(done, lisael_ui_style_button(), 0);
lv_obj_set_style_bg_color(done, lv_color_hex(0x2F9E44), 0);
lv_obj_set_size(done, 240, 64);
lv_obj_align(done, LV_ALIGN_BOTTOM_MID, 0, -10);
lv_obj_add_event_cb(done, done_cb, LV_EVENT_CLICKED, NULL);
lv_obj_t *dl = lv_label_create(done);
lv_label_set_text(dl, LV_SYMBOL_OK " C'est fait");
lv_obj_set_style_text_color(dl, lv_color_white(), 0);
lv_obj_set_style_text_font(dl, &lisael_font_24, 0);
lv_obj_center(dl);
// Keep the memoized choice screen (s_root) alive on the first hop into a
// routine: when s_step_screen is NULL we are coming FROM the choice screen,
// so auto_del must be false to prevent LVGL from deleting s_root at the end
// of the transition. On subsequent step→step hops s_step_screen is the
// previous (transient) step screen, which LVGL can safely delete.
bool del_prev = (s_step_screen != NULL);
lv_screen_load_anim(scr, LV_SCR_LOAD_ANIM_MOVE_LEFT, 200, 0, del_prev);
s_step_screen = scr;
play_step_audio();
}
static void open_routine(int ridx)
{
s_ridx = ridx;
s_sidx = 0;
build_step_screen();
}
// --- Bravo screen (Task 4c) --------------------------------------------------
// One-shot timer pointer shared between the auto-return timer and the tap
// handler so each can cancel the other without a dangling pointer.
static lv_timer_t *s_bravo_timer;
// Return HOME from the bravo screen. Used by both the tap handler and the
// auto-return timer. Idempotent (second call is a no-op).
static void bravo_go_home(void)
{
// Cancel the auto-return timer if the tap fired first, and vice versa.
if (s_bravo_timer) {
lv_timer_delete(s_bravo_timer);
s_bravo_timer = NULL;
}
// Grab and clear s_step_screen BEFORE the goto to prevent re-entry.
lv_obj_t *old = s_step_screen;
s_step_screen = NULL;
lisael_aac_stop();
lisael_ui_goto(LISAEL_MODE_HOME);
// lisael_ui_goto uses auto_del=false for cached mode screens, so the
// transient bravo screen must be freed explicitly. Schedule after 350 ms
// (> 250 ms slide anim) so it is already off-screen before deletion.
if (old) {
lv_obj_delete_delayed(old, 350);
}
}
static void bravo_home_cb(lv_event_t *e)
{
(void)e;
bravo_go_home();
}
static void bravo_auto_return_cb(lv_timer_t *t)
{
(void)t;
// Timer self-deletes when it is one-shot (repeat_count=1). Clear the
// pointer first so bravo_go_home() does not try to delete it again.
s_bravo_timer = NULL;
bravo_go_home();
}
// 3D-ish spin: rotate the star image about its centre. Uses the IMAGE
// rotation property (lv_image_set_rotation), NOT the style transform_rotation:
// the image path transforms inside the draw against the flush band and does
// not allocate a full-screen object layer (that full-screen layer is what
// wedged the render task with FADE_IN / style transform_scale). The source is
// 44x44 in flash; scaled up ~2.5x for a big celebratory star.
static void bravo_spin_cb(void *var, int32_t v)
{
lv_image_set_rotation((lv_obj_t *)var, v); // v in 0.1 degree units
}
// Vertical bounce via translate_y — a pure position offset, layer-free
// (translate is not in LVGL's layer-forcing style set, unlike scale/rotation).
static void bravo_bounce_cb(void *var, int32_t v)
{
lv_obj_set_style_translate_y((lv_obj_t *)var, v, 0);
}
static void show_bravo(void)
{
lv_obj_t *scr = lv_obj_create(NULL);
lv_obj_set_style_bg_color(scr, lv_color_hex(0xFFF4C2), 0);
lv_obj_set_style_bg_grad_color(scr, lv_color_hex(0xFFFFFF), 0);
lv_obj_set_style_bg_grad_dir(scr, LV_GRAD_DIR_VER, 0);
// Spinning, bouncing star (44px source scaled ~2.5x -> ~110px on screen).
lv_obj_t *star = lv_image_create(scr);
lv_image_set_src(star, &lisael_ic_star);
lv_image_set_scale(star, 256 * 5 / 2); // 2.5x
lv_image_set_pivot(star, 22, 22); // centre of the 44px source
lv_obj_align(star, LV_ALIGN_CENTER, 0, -40);
// Continuous 3D-feel spin (full turn every 2 s, forever).
lv_anim_t spin;
lv_anim_init(&spin);
lv_anim_set_var(&spin, star);
lv_anim_set_exec_cb(&spin, bravo_spin_cb);
lv_anim_set_values(&spin, 0, 3600); // 0 -> 360.0 deg
lv_anim_set_duration(&spin, 2000);
lv_anim_set_repeat_count(&spin, LV_ANIM_REPEAT_INFINITE);
lv_anim_start(&spin);
// Gentle bounce (down then up, bouncy easing, ping-pong forever).
lv_anim_t bounce;
lv_anim_init(&bounce);
lv_anim_set_var(&bounce, star);
lv_anim_set_exec_cb(&bounce, bravo_bounce_cb);
lv_anim_set_values(&bounce, -18, 14);
lv_anim_set_duration(&bounce, 650);
lv_anim_set_reverse_duration(&bounce, 650);
lv_anim_set_repeat_count(&bounce, LV_ANIM_REPEAT_INFINITE);
lv_anim_set_path_cb(&bounce, lv_anim_path_ease_in_out);
lv_anim_start(&bounce);
// NB: both anims target `star`; LVGL removes them automatically when the
// bravo screen (and its star child) is deleted on return Home.
// "Bravo !" label (Fredoka large, centred below the star).
lv_obj_t *txt = lv_label_create(scr);
lv_label_set_text(txt, "Bravo !");
lv_obj_set_style_text_font(txt, &lisael_font_30, 0);
lv_obj_align(txt, LV_ALIGN_CENTER, 0, 50);
// "Accueil" button: tap returns HOME immediately (also cancels the timer).
lv_obj_t *home = lv_button_create(scr);
lv_obj_add_style(home, lisael_ui_style_button(), 0);
lv_obj_set_style_bg_color(home, lv_color_hex(0x1971C2), 0);
lv_obj_set_size(home, 200, 60);
lv_obj_align(home, LV_ALIGN_BOTTOM_MID, 0, -16);
lv_obj_add_event_cb(home, bravo_home_cb, LV_EVENT_CLICKED, NULL);
lv_obj_t *hl = lv_label_create(home);
lv_label_set_text(hl, LV_SYMBOL_HOME " Accueil");
lv_obj_set_style_text_color(hl, lv_color_white(), 0);
lv_obj_set_style_text_font(hl, &lisael_font_24, 0);
lv_obj_center(hl);
// Celebratory sound (routine asset; silent fallback if absent on SD).
// provided by Tower routine seed
lisael_aac_play_file("/sdcard/routines/bravo.mp3");
// Slide in (MOVE_LEFT, like every other screen) — NOT FADE_IN: a fade
// applies opacity to the whole screen, forcing a full-screen 320x240 layer
// that wedges the render task on this box. auto_del=true deletes the
// outgoing step screen, which LVGL was already tracking as active.
lv_screen_load_anim(scr, LV_SCR_LOAD_ANIM_MOVE_LEFT, 250, 0, true);
s_step_screen = scr; // track the bravo screen for explicit cleanup on return
// Auto-return after 3.6 s (lets the festive bravo.mp3 ~3.5 s finish and the
// star spin a couple of turns). One-shot; the tap handler deletes it early.
s_bravo_timer = lv_timer_create(bravo_auto_return_cb, 3600, NULL);
lv_timer_set_repeat_count(s_bravo_timer, 1);
}