feat(box): routine load + choice screen

Replace stub routine.c with manifest load into PSRAM, PSRAM picto cache
(mirrors lire.c: no S: driver, negative-cache misses, reject tiny),
and choice screen with per-routine tap cards or no-manifest message.
open_routine() placeholder wired with routine index; Task 4b fills it.
This commit is contained in:
L'électron rare
2026-06-21 10:59:45 +02:00
parent 21bfd2f7de
commit 0591c8acf6
+204 -2
View File
@@ -1,14 +1,216 @@
// "Routine" — illustrated step-by-step routine (matin/soir) for Lisael Box.
// Full implementation lands in Task 4; this stub keeps the link green.
//
// 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 (Task 4b)
// --- 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)
{
static lv_obj_t *s_root;
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;
}
// Provisional: real body lands in Task 4b (builds the step screen).
static void open_routine(int ridx)
{
s_ridx = ridx;
s_sidx = 0;
(void)s_step_screen;
ESP_LOGI(TAG, "open_routine(%d) — placeholder, Task 4b will implement", ridx);
}