diff --git a/main/modes/routine.c b/main/modes/routine.c index e7849fc..9ce19d3 100644 --- a/main/modes/routine.c +++ b/main/modes/routine.c @@ -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 +#include +#include + +#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); +}