#include #include #include #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "esp_lcd_panel_io.h" #include "esp_lcd_panel_vendor.h" #include "esp_lcd_panel_ops.h" #include "driver/gpio.h" #include "driver/spi_master.h" #include "esp_err.h" #include "esp_log.h" #include "esp_timer.h" #include "lvgl.h" #include "esp_lvgl_port.h" #include "gui.h" #include "gpio.h" #include "keypad.h" #include "bubble.h" #include "system.h" #include "bt_app.h" #define DEVKIT #undef DEVKIT static const char *TAG = "gui"; #define UNLOCK() lvgl_port_unlock() #define LOCK() lvgl_port_lock(0) // LCD Pin Configuration #define LCD_PIXEL_CLOCK_HZ (5 * 1000 * 1000) #define LCD_SPI_HOST SPI2_HOST #define PIN_NUM_nON 26 // ST7735S properties #define LCD_H_RES 160 #define LCD_V_RES 80 #define LCD_CMD_BITS 8 #define LCD_PARAM_BITS 8 #define LCD_COLOR_SPACE ESP_LCD_COLOR_SPACE_RGB #define LCD_BITS_PER_PIXEL 16 static esp_lcd_panel_handle_t panel_handle = NULL; esp_lcd_panel_io_handle_t io_handle = NULL; static lv_disp_t *disp = NULL; static lv_obj_t *imu_label = NULL; // Label for IMU data static lv_style_t style_mono8; typedef struct { int selected; int count; lv_obj_t *obj; } menu_context_t; static void gui_task(void *pvParameters); static void createBubble(lv_obj_t * scr); static void build_scrollable_menu(void); static void currentFocusIndex(menu_context_t *ctx); static lv_obj_t * addMenuItem(lv_obj_t *page, const char *text); static lv_obj_t * addMenuItemWithCheckbox(lv_obj_t *page, const char *text, bool checked, lv_obj_t **checkbox_out); static void update_checkbox_focus_style(lv_obj_t *cb, bool focused); static lv_obj_t* create_menu_container(void); static void show_bt_device_list(void); static void show_volume_control(void); static lv_obj_t* create_volume_page(void); static void update_volume_display(int volume); static void ensure_menu_styles(void); static void bt_device_click_cb(lv_event_t * e); static void show_calibration_menu(void); static lv_obj_t* create_calibration_page(void); static void calibration_click_cb(lv_event_t * e); static void update_label_scroll(lv_obj_t * btn, bool focused); // Menu stack management functions static void menu_stack_push(lv_obj_t *page); static lv_obj_t* menu_stack_pop(void); static void menu_stack_clear(void); static bool menu_stack_is_empty(void); static void menu_go_back(void); #define MAX_ITEMS 10 #define VISIBLE_ITEMS 3 #define MENU_MAX_STRING_LENGTH 30 static const char *menu_items[MAX_ITEMS] = { "V-Moda Crossfade", "Item 2", "Item 3", "Item 4", "Item 5", "Item 6", "Item 7", "Item 8", "Item 9", "Item 10" }; static lv_obj_t *list; static lv_obj_t *buttons[MAX_ITEMS]; static int selected_index = 0; static lv_obj_t *_bubble = NULL; static lv_obj_t * _menu = NULL; static lv_obj_t *_currentPage = NULL; static GuiMode_t _mode = GUI_BUBBLE; // Backlight timeout #define BACKLIGHT_TIMEOUT_MS 30000 static TickType_t _lastButtonPress = 0; static bool _backlightOn = true; static void backlight_reset_timeout(void) { _lastButtonPress = xTaskGetTickCount(); if (!_backlightOn) { gpio_set_level(PIN_NUM_BK_LIGHT, 1); _backlightOn = true; } } // Menu navigation stack #define MAX_MENU_STACK_SIZE 8 typedef struct { lv_obj_t *pages[MAX_MENU_STACK_SIZE]; int count; } menu_stack_t; static menu_stack_t _menuStack = {0}; /* 1. Prepare a default (unfocused) style */ static lv_style_t _styleUnfocusedBtn; /* 2. Prepare a focus style */ static lv_style_t _styleFocusedBtn; static menu_context_t _menuContext; // Bluetooth page state static lv_obj_t* _bt_page = NULL; static lv_obj_t* _bt_status_item = NULL; // Top status item (unselectable) static lv_obj_t* _bt_device_container = NULL; // Container for device list items static TickType_t _bt_scan_start_time = 0; // When scan was started // Volume page state static lv_obj_t* _volume_page = NULL; static lv_obj_t* _volume_bar = NULL; static lv_obj_t* _volume_label = NULL; static int _current_volume = 50; // Default volume (0-100) // Calibration page state static lv_obj_t* _calibration_page = NULL; static lv_timer_t* _calibration_timer = NULL; // Swap L/R checkbox static lv_obj_t* _swapLR_checkbox = NULL; // Hide *all* headers/back buttons that LVGL may show for this menu static void menu_hide_headers(lv_obj_t *menu) { if (!menu) return; // Main header (normal menus) lv_obj_t *mh = lv_menu_get_main_header(menu); if (mh) { lv_obj_add_flag(mh, LV_OBJ_FLAG_HIDDEN); lv_obj_set_height(mh, 0); } lv_obj_t *mh_back = lv_menu_get_main_header_back_button(menu); if (mh_back) { lv_obj_add_flag(mh_back, LV_OBJ_FLAG_HIDDEN); lv_obj_set_width(mh_back, 0); lv_obj_set_height(mh_back, 0); } // Sidebar header (if you ever use sidebar mode) lv_obj_t *sh = lv_menu_get_sidebar_header(menu); if (sh) { lv_obj_add_flag(sh, LV_OBJ_FLAG_HIDDEN); lv_obj_set_height(sh, 0); } lv_obj_t *sh_back = lv_menu_get_sidebar_header_back_button(menu); if (sh_back) { lv_obj_add_flag(sh_back, LV_OBJ_FLAG_HIDDEN); lv_obj_set_width(sh_back, 0); lv_obj_set_height(sh_back, 0); } } static bool notify_lvgl_flush_ready(void *user_ctx) { if (disp) { lv_display_flush_ready(disp); } return true; } static lv_obj_t* create_main_page(void) { lv_obj_t* main_page; ensure_menu_styles(); lv_obj_t* menu = create_menu_container(); main_page = lv_menu_page_create(menu, NULL); lv_obj_set_style_radius(main_page, 0, LV_PART_MAIN | LV_STATE_ANY); lv_obj_set_style_border_width(main_page, 0, LV_PART_MAIN | LV_STATE_ANY); lv_obj_set_style_pad_all(main_page, 0, LV_PART_MAIN); lv_obj_set_style_pad_row(main_page, 0, LV_PART_MAIN); lv_obj_set_style_pad_column(main_page, 0, LV_PART_MAIN); lv_obj_set_scrollbar_mode(main_page, LV_SCROLLBAR_MODE_AUTO); lv_obj_set_size(main_page, lv_pct(100), lv_pct(100)); // Add menu items lv_obj_t* tmpObj; tmpObj = addMenuItem(main_page, "Bluetooth"); lv_obj_add_state(tmpObj, LV_STATE_FOCUSED); // First item focused update_label_scroll(tmpObj, true); // Start scrolling on initially focused item addMenuItem(main_page, "Calibration"); addMenuItem(main_page, "Volume"); addMenuItemWithCheckbox(main_page, "Swap L/R", system_getSwapLR(), &_swapLR_checkbox); //addMenuItem(main_page, "About"); addMenuItem(main_page, "Exit"); return main_page; } static void show_menu(void) { lv_obj_t* menu = create_menu_container(); lv_obj_t* main_page = create_main_page(); // Clear the menu stack when starting fresh menu navigation menu_stack_clear(); lv_menu_set_page(menu, main_page); _currentPage = main_page; // Initialize menu context to track the focused item currentFocusIndex(&_menuContext); menu_hide_headers(menu); lv_obj_remove_flag(menu, LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(_bubble, LV_OBJ_FLAG_HIDDEN); } static void cleanup_menu(void) { if (_menu) { lv_obj_del(_menu); _menu = NULL; _currentPage = NULL; _bt_page = NULL; _bt_status_item = NULL; _bt_device_container = NULL; _bt_scan_start_time = 0; _calibration_page = NULL; _volume_page = NULL; _volume_bar = NULL; _volume_label = NULL; _swapLR_checkbox = NULL; if (_calibration_timer) { lv_timer_del(_calibration_timer); _calibration_timer = NULL; } ESP_LOGI(TAG, "Menu cleaned up to free memory"); } } static void show_bubble(void) { cleanup_menu(); // Free menu memory when returning to bubble lv_obj_remove_flag(_bubble, LV_OBJ_FLAG_HIDDEN); } static void create_lvgl_demo(void) { lvgl_port_lock(0); // Create a screen with black background lv_obj_t *scr = lv_scr_act(); lv_obj_set_style_bg_color(scr, lv_color_black(), 0); lv_obj_set_style_bg_opa(scr, LV_OPA_COVER, 0); createBubble(scr); // Menu will be created lazily when needed lvgl_port_unlock(); } static void lcd_init(void) { ESP_LOGI(TAG, "Initialize SPI bus"); spi_bus_config_t buscfg = { .sclk_io_num = PIN_NUM_CLK, .mosi_io_num = PIN_NUM_MOSI, .miso_io_num = -1, .quadwp_io_num = -1, .quadhd_io_num = -1, //.max_transfer_sz = LCD_H_RES * LCD_V_RES * 2 .max_transfer_sz = LCD_H_RES * 25 * (LCD_BITS_PER_PIXEL/8), }; ESP_ERROR_CHECK(spi_bus_initialize(LCD_SPI_HOST, &buscfg, SPI_DMA_CH_AUTO)); ESP_LOGI(TAG, "Install panel IO"); esp_lcd_panel_io_spi_config_t io_config = { .dc_gpio_num = PIN_NUM_DC, .cs_gpio_num = PIN_NUM_CS, .pclk_hz = LCD_PIXEL_CLOCK_HZ, .lcd_cmd_bits = LCD_CMD_BITS, .lcd_param_bits = LCD_PARAM_BITS, .spi_mode = 3, .trans_queue_depth = 10, .user_ctx = NULL, }; ESP_ERROR_CHECK(esp_lcd_new_panel_io_spi((esp_lcd_spi_bus_handle_t)LCD_SPI_HOST, &io_config, &io_handle)); esp_lcd_panel_dev_config_t panel_config = { .reset_gpio_num = PIN_NUM_RST, .rgb_endian = LCD_RGB_ENDIAN_BGR, .bits_per_pixel = LCD_BITS_PER_PIXEL, }; ESP_ERROR_CHECK(esp_lcd_new_panel_st7789(io_handle, &panel_config, &panel_handle)); ESP_ERROR_CHECK(esp_lcd_panel_reset(panel_handle)); ESP_ERROR_CHECK(esp_lcd_panel_init(panel_handle)); ESP_ERROR_CHECK(esp_lcd_panel_invert_color(panel_handle, true)); ESP_ERROR_CHECK(esp_lcd_panel_set_gap(panel_handle, 1, 26)); // ST7735S typically needs these offsets ESP_ERROR_CHECK(esp_lcd_panel_disp_on_off(panel_handle, true)); } static void lvgl_init(void) { lv_init(); #if 1 const lvgl_port_cfg_t lvgl_cfg = { .task_priority = 4, // LVGL task priority .task_stack = 8192, // LVGL task stack size (reduced for memory savings) .task_affinity = 0, // LVGL task can run on any core .task_max_sleep_ms = 500, // Maximum sleep in LVGL task .timer_period_ms = 5 // LVGL timer period }; ESP_ERROR_CHECK(lvgl_port_init(&lvgl_cfg)); #endif const lvgl_port_display_cfg_t disp_cfg = { .io_handle = io_handle, .panel_handle = panel_handle, // .buffer_size = LCD_H_RES * LCD_V_RES * 2, .buffer_size = LCD_H_RES * 25 * (LCD_BITS_PER_PIXEL/8), // was full screen .double_buffer = false, .hres = LCD_H_RES, .vres = LCD_V_RES, .monochrome = false, .rotation = { .swap_xy = true, .mirror_x = true, .mirror_y = false, }, .flags = { //.buff_dma = true, .swap_bytes = true, } }; disp = lvgl_port_add_disp(&disp_cfg); //lv_display_set_color_format(disp, LV_COLOR_FORMAT_RGB565); } static void createBubble(lv_obj_t * scr) { // 2) Create a bubble level of size 200×60, with range [−30°, +30°], initial 0°: lv_obj_t * level = bubble_create(scr, 150, 40, -10.0f, +10.0f, 0.0f); lv_obj_align(level, LV_ALIGN_CENTER, 0, 0); // 3) … Later, when you read your accelerometer or keypad‐derived angle … float new_angle = 10.0f; bubble_setValue(level, new_angle); // 4) You can call bubble_level_set_value(level, …) as often as you like. // Each call invalidates the object and LVGL will call the draw callback // (usually on the next tick, or immediately if LVGL is idle). _bubble = level; } void gui_start(void) { // Initialize LCD lcd_init(); // Initialize LVGL lvgl_init(); // Create UI create_lvgl_demo(); keypad_start(); gpio_set_level(PIN_NUM_BK_LIGHT, 1); xTaskCreate(gui_task, "gui_task", 8192, NULL, 5, NULL); } static uint32_t waitForKeyPress(void) { QueueHandle_t q = keypad_getQueue(); uint32_t ev = 0; if (xQueueReceive(q, &ev, portMAX_DELAY) == pdTRUE) { return ev; } return 0; } static void handleMainMenu(void) { lvgl_port_lock(0); // Create a screen with black background lv_obj_t *scr = lv_scr_act(); //create_menu(scr); lvgl_port_unlock(); } // ───── MENU CONFIG ───── #define ROW_H 20 // height of each row static const char * items[] = { // your menu entries "VMode Crossmade", "Second Choice", "Another Option", "Yet Another", "Yet Another", "Last One" }; #define ITEM_COUNT (sizeof(items)/sizeof(items[0])) // ───── STATE & HELPERS ───── static lv_obj_t * btn_array[ITEM_COUNT]; static int selected_idx = 0; // Called whenever a button is "activated" (Enter/Click) static void btn_click_cb(lv_event_t * e) { lv_obj_t * btn = lv_event_get_target(e); const char * txt = lv_label_get_text(lv_obj_get_child(btn, 0)); ESP_LOGI(TAG, "Activated: %s\n", txt); // Handle specific menu items if (strcmp(txt, "Bluetooth") == 0) { LOCK(); // Push current page onto stack before navigating menu_stack_push(_currentPage); show_bt_device_list(); UNLOCK(); } else if (strcmp(txt, "Calibration") == 0) { LOCK(); // Push current page onto stack before navigating menu_stack_push(_currentPage); show_calibration_menu(); UNLOCK(); } else if (strcmp(txt, "Volume") == 0) { LOCK(); // Push current page onto stack before navigating menu_stack_push(_currentPage); show_volume_control(); UNLOCK(); } else if (strcmp(txt, "Swap L/R") == 0) { // Toggle the swap L/R state system_toggleSwapLR(); // Update the checkbox visual (now a label showing "X" or empty) if (_swapLR_checkbox) { lv_label_set_text(_swapLR_checkbox, system_getSwapLR() ? "X" : ""); } ESP_LOGI(TAG, "Swap L/R toggled to: %s", system_getSwapLR() ? "ON" : "OFF"); } else if (strcmp(txt, "Back") == 0) { LOCK(); menu_go_back(); UNLOCK(); } else if (strcmp(txt, "Exit") == 0) { LOCK(); _mode = GUI_BUBBLE; show_bubble(); UNLOCK(); } // Add more menu handlers here as needed ESP_LOGI(TAG, "End btn_click_cb"); } // Repaint all rows so only btn_array[selected_idx] is highlighted static void refresh_highlight(void) { return; lvgl_port_lock(0); int index = 0; lv_obj_t *page = _currentPage; lv_obj_t * child = NULL; lv_obj_t * next = lv_obj_get_child(page, index); lv_obj_t * selected = NULL; while(next) { child = next; ESP_LOGI(TAG, "Child: %p", child); if (lv_obj_has_flag(child, LV_OBJ_FLAG_USER_1)) { lv_obj_set_style_bg_color(child, lv_color_hex(0xFFFF00), 0); lv_obj_set_style_text_color(lv_obj_get_child(child,0), lv_color_black(), 0); selected = child; ESP_LOGI(TAG, "Selected"); } else { lv_obj_set_style_bg_color(child, lv_color_black(), 0); lv_obj_set_style_text_color(lv_obj_get_child(child,0), lv_color_white(), 0); } index++; next = lv_obj_get_child(page, index); } if (selected) { lv_obj_scroll_to_view(selected, LV_ANIM_ON); } lvgl_port_unlock(); } static void menuInc(int inc) { LOCK(); currentFocusIndex(&_menuContext); ESP_LOGI(TAG, "Current Index: %d", _menuContext.selected); // Safety check: if no focused object found, bail out if (_menuContext.selected < 0 || _menuContext.obj == NULL) { ESP_LOGW(TAG, "No focused object found, cannot navigate"); UNLOCK(); return; } lv_obj_t *next = NULL; // check if we are at the first or last in the page lv_obj_t *test = lv_obj_get_child(_currentPage, (inc > 0 ? -1 : 0)); if (_menuContext.obj != test) { int search_index = _menuContext.selected + inc; next = lv_obj_get_child(_currentPage, search_index); // Skip disabled/non-clickable items while (next && (!lv_obj_has_flag(next, LV_OBJ_FLAG_CLICKABLE) || lv_obj_has_state(next, LV_STATE_DISABLED))) { search_index += inc; next = lv_obj_get_child(_currentPage, search_index); } if (next) { // Update scroll on old focused item (stop scrolling) update_label_scroll(_menuContext.obj, false); // Update checkbox style on old focused item (if it has one) lv_obj_t *old_cb = lv_obj_get_user_data(_menuContext.obj); if (old_cb) { update_checkbox_focus_style(old_cb, false); } lv_obj_add_state(next, LV_STATE_FOCUSED); lv_obj_clear_state(_menuContext.obj, LV_STATE_FOCUSED); lv_obj_scroll_to_view(next, LV_ANIM_ON); // Update scroll on new focused item (start scrolling) update_label_scroll(next, true); // Update checkbox style on new focused item (if it has one) lv_obj_t *new_cb = lv_obj_get_user_data(next); if (new_cb) { update_checkbox_focus_style(new_cb, true); } _menuContext.obj = next; _menuContext.selected = search_index; } } UNLOCK(); } static void menuNext(void) { menuInc(1); } static void menuPrevious(void) { menuInc(-1); } // Fire the “clicked” event on the selected row static void activate_selected(void) { LOCK(); lv_obj_send_event(_menuContext.obj, LV_EVENT_CLICKED, NULL); UNLOCK(); } // Helper function to update label scroll mode based on focus static void update_label_scroll(lv_obj_t * btn, bool focused) { if (!btn) return; lv_obj_t * lbl = lv_obj_get_child(btn, 0); // Get the label child if (!lbl) return; const char* txt = lv_label_get_text(lbl); if (focused) { ESP_LOGI(TAG, "Label focused - starting scroll: %s", txt); lv_label_set_long_mode(lbl, LV_LABEL_LONG_SCROLL_CIRCULAR); } else { ESP_LOGI(TAG, "Label defocused - stopping scroll: %s", txt); lv_label_set_long_mode(lbl, LV_LABEL_LONG_CLIP); } } // Event handler to control label scrolling based on focus state static void label_scroll_focus_cb(lv_event_t * e) { lv_event_code_t code = lv_event_get_code(e); lv_obj_t * btn = lv_event_get_target(e); if (code == LV_EVENT_FOCUSED) { update_label_scroll(btn, true); } else if (code == LV_EVENT_DEFOCUSED) { update_label_scroll(btn, false); } } static lv_obj_t * addMenuItemWithCheckbox(lv_obj_t *page, const char *text, bool checked, lv_obj_t **checkbox_out) { if (!page || !text) { ESP_LOGE(TAG, "Null parameters in addMenuItemWithCheckbox"); return NULL; } // Ensure styles are initialized ensure_menu_styles(); lv_obj_t * btn = lv_btn_create(page); if (!btn) { ESP_LOGE(TAG, "Failed to create button in addMenuItemWithCheckbox"); return NULL; } lv_obj_set_size(btn, LV_PCT(100), ROW_H); lv_obj_add_style(btn, &_styleUnfocusedBtn, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_add_style(btn, &_styleFocusedBtn, LV_PART_MAIN | LV_STATE_FOCUSED); lv_obj_add_state(btn, LV_STATE_DEFAULT); lv_obj_set_style_radius(btn, 0, LV_PART_MAIN); lv_obj_set_style_border_width(btn, 0, LV_PART_MAIN); lv_obj_clear_flag(btn, LV_OBJ_FLAG_SCROLLABLE); // Label on the left (same as regular menu item) lv_obj_t * lbl = lv_label_create(btn); if (!lbl) { ESP_LOGE(TAG, "Failed to create label in addMenuItemWithCheckbox"); lv_obj_del(btn); return NULL; } lv_label_set_text(lbl, text); lv_label_set_long_mode(lbl, LV_LABEL_LONG_CLIP); lv_obj_set_width(lbl, LV_PCT(80)); lv_obj_set_style_radius(lbl, 0, LV_PART_MAIN | LV_STATE_ANY); lv_obj_set_style_border_width(lbl, 0, LV_PART_MAIN | LV_STATE_ANY); lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 0, 0); // Label on the right to act as checkbox (shows "X" when checked, empty when not) lv_obj_t * cb = lv_label_create(btn); lv_label_set_text(cb, checked ? "X" : ""); lv_obj_set_style_text_color(cb, lv_color_white(), 0); // Unfocused: white lv_obj_align(cb, LV_ALIGN_RIGHT_MID, -4, 0); if (checkbox_out) { *checkbox_out = cb; } // Add focus event handler to control scrolling lv_obj_add_event_cb(btn, label_scroll_focus_cb, LV_EVENT_ALL, NULL); // click callback lv_obj_add_event_cb(btn, btn_click_cb, LV_EVENT_CLICKED, NULL); // Store checkbox in user data for focus styling updates lv_obj_set_user_data(btn, cb); return btn; } // Update checkbox label color based on parent focus state static void update_checkbox_focus_style(lv_obj_t *cb, bool focused) { if (!cb) return; if (focused) { // Focused: black text (on yellow background) lv_obj_set_style_text_color(cb, lv_color_black(), 0); } else { // Unfocused: white text (on black background) lv_obj_set_style_text_color(cb, lv_color_white(), 0); } } static lv_obj_t * addMenuItem(lv_obj_t *page, const char *text) { if (!page || !text) { ESP_LOGE(TAG, "Null parameters in addMenuItem"); return NULL; } // Ensure styles are initialized ensure_menu_styles(); lv_obj_t * btn = lv_btn_create(page); if (!btn) { ESP_LOGE(TAG, "Failed to create button in addMenuItem"); return NULL; } lv_obj_set_size(btn, LV_PCT(100), ROW_H); lv_obj_add_style(btn, &_styleUnfocusedBtn, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_add_style(btn, &_styleFocusedBtn, LV_PART_MAIN | LV_STATE_FOCUSED); lv_obj_add_state(btn, LV_STATE_DEFAULT); // style it just like your old list // lv_obj_set_style_bg_color(btn, lv_color_white(), 0); lv_obj_set_style_radius(btn, 0, LV_PART_MAIN); lv_obj_set_style_border_width(btn, 0, LV_PART_MAIN); lv_obj_clear_flag(btn, LV_OBJ_FLAG_SCROLLABLE); // label & center lv_obj_t * lbl = lv_label_create(btn); if (!lbl) { ESP_LOGE(TAG, "Failed to create label in addMenuItem"); lv_obj_del(btn); return NULL; } lv_label_set_text(lbl, text); lv_label_set_long_mode(lbl, LV_LABEL_LONG_CLIP); // Start with clipping, scrolling only when focused lv_obj_set_width(lbl, LV_PCT(95)); // Set width to allow scrolling lv_obj_set_style_radius(lbl, 0, LV_PART_MAIN | LV_STATE_ANY); lv_obj_set_style_border_width(lbl, 0, LV_PART_MAIN | LV_STATE_ANY); lv_obj_set_style_anim_time(lbl, 4000, LV_PART_MAIN); // Set scrolling duration (4 seconds per cycle) lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 0, 0); // Center vertically, align left horizontally // Add focus event handler to control scrolling (use LV_EVENT_ALL to catch all events) lv_obj_add_event_cb(btn, label_scroll_focus_cb, LV_EVENT_ALL, NULL); ESP_LOGI(TAG, "Registered scroll callbacks for menu item: %s", text); // click callback lv_obj_add_event_cb(btn, btn_click_cb, LV_EVENT_CLICKED, NULL); return btn; } static int getSelectedIndex(lv_obj_t *page) { return -1; } static void currentFocusIndex(menu_context_t *ctx) { ctx->count = lv_obj_get_child_cnt(_currentPage); ctx->obj = NULL; ctx->selected = -1; // return the index of the currently focused object for(int i = 0; i < ctx->count; i++) { lv_obj_t * child = lv_obj_get_child(_currentPage, i); if (lv_obj_has_state(child, LV_STATE_FOCUSED)) { ctx->obj = child; ctx->selected = i; return; } } } // ───── LAZY MENU CREATION ───── static void ensure_menu_styles(void) { static bool styles_initialized = false; if (!styles_initialized) { lv_style_init(&_styleFocusedBtn); lv_style_init(&_styleUnfocusedBtn); lv_style_set_bg_color(&_styleUnfocusedBtn, lv_color_make(0x00,0x00,0x00)); // black bg lv_style_set_text_color(&_styleUnfocusedBtn, lv_color_white()); // white text //lv_style_set_text_font(&_styleUnfocusedBtn, &lv_font_unscii_16); // larger font lv_style_set_bg_color(&_styleFocusedBtn, lv_color_make(0xFF,0xFF,0x00)); // bright yellow bg lv_style_set_text_color(&_styleFocusedBtn, lv_color_black()); // black text //lv_style_set_text_font(&_styleFocusedBtn, &lv_font_unscii_16); // larger font styles_initialized = true; } } static lv_obj_t* create_menu_container(void) { if (_menu == NULL) { _menu = lv_menu_create(lv_scr_act()); menu_hide_headers(_menu); lv_obj_set_style_radius(_menu, 0, LV_PART_MAIN | LV_STATE_ANY); lv_obj_set_style_border_width(_menu, 0, LV_PART_MAIN | LV_STATE_ANY); lv_obj_set_size(_menu, lv_pct(100), lv_pct(100)); lv_obj_center(_menu); lv_obj_set_scrollbar_mode(_menu, LV_SCROLLBAR_MODE_AUTO); lv_obj_add_flag(_menu, LV_OBJ_FLAG_HIDDEN); // Hidden by default // Hide the sidebar/header lv_menu_set_sidebar_page(_menu, NULL); lv_obj_set_style_pad_all(_menu, 0, LV_PART_MAIN); // Disable the back button in header lv_menu_set_mode_root_back_button(_menu, LV_MENU_ROOT_BACK_BUTTON_DISABLED); // Find and hide the header container // The menu has 2 children: child 0 is header, child 1 is content area uint32_t menu_child_count = lv_obj_get_child_count(_menu); ESP_LOGI(TAG, "Menu container has %d children", (int)menu_child_count); if (menu_child_count >= 1) { lv_obj_t* header = lv_obj_get_child(_menu, 0); int32_t h = lv_obj_get_height(header); int32_t w = lv_obj_get_width(header); ESP_LOGI(TAG, " Header (child 0): %p, size=%dx%d", header, (int)w, (int)h); // Hide the header completely lv_obj_add_flag(header, LV_OBJ_FLAG_HIDDEN); lv_obj_set_height(header, 0); ESP_LOGI(TAG, " Hidden header"); } } return _menu; } // ───── BLUETOOTH DEVICE LIST PAGE ───── // Update the status item text (e.g., "Scanning...", "No devices found") static void update_bt_status(const char* status_text) { if (_bt_status_item) { ESP_LOGI(TAG, "Updating BT status to: %s", status_text); lv_label_set_text(_bt_status_item, status_text); lv_obj_invalidate(_bt_status_item); // Force redraw } else { ESP_LOGW(TAG, "Status item is NULL, cannot update"); } } // Clear all device items and action buttons from the container (keep Back only) static void clear_bt_device_list(void) { if (!_bt_device_container) return; // Delete all children except Back (last item) uint32_t child_count = lv_obj_get_child_count(_bt_device_container); ESP_LOGI(TAG, "Clearing BT device list, child_count=%d", (int)child_count); // Work backwards to avoid index shifting issues // Keep: Back (last) for (int i = child_count - 2; i >= 0; i--) { lv_obj_t* child = lv_obj_get_child(_bt_device_container, i); if (child) { ESP_LOGI(TAG, " Deleting child at index %d", i); lv_obj_del(child); } } ESP_LOGI(TAG, "Clear complete, remaining children: %d", (int)lv_obj_get_child_count(_bt_device_container)); } // Populate the device list with current devices and add action buttons // is_scanning: true if currently scanning (show Cancel instead of Back, hide Refresh) static void populate_bt_device_list(bool is_scanning) { if (!_bt_device_container) return; // Clear old device list items first (keeps Back button) clear_bt_device_list(); bt_device_list_t* device_list = bt_get_device_list(); if (!device_list) { update_bt_status("Error getting devices"); return; } ESP_LOGI(TAG, "Populating BT list with %d devices (is_scanning=%d)", device_list->count, is_scanning); // The container has: [Back/Cancel] // We'll add: [...devices..., Refresh?, Clear?, Back/Cancel] uint32_t insert_index = 0; // Start at beginning bool has_paired_devices = false; if (device_list->count == 0 && !is_scanning) { update_bt_status("No devices found"); } else if (!is_scanning) { update_bt_status("Available Devices:"); // Add device items (limit to MAX_BT_DEVICES to avoid memory issues) bool first = true; int max_items = (device_list->count < MAX_BT_DEVICES) ? device_list->count : MAX_BT_DEVICES; for (int i = 0; i < max_items; i++) { char device_text[64]; const char* paired_prefix = device_list->devices[i].is_paired ? "* " : ""; snprintf(device_text, sizeof(device_text), "%s%s", paired_prefix, device_list->devices[i].name); if (device_list->devices[i].is_paired) { has_paired_devices = true; } lv_obj_t* btn = addMenuItem(_bt_device_container, device_text); lv_obj_add_event_cb(btn, bt_device_click_cb, LV_EVENT_CLICKED, NULL); // Move to correct position (before action buttons) lv_obj_move_to_index(btn, insert_index++); if (first) { lv_obj_add_state(btn, LV_STATE_FOCUSED); first = false; } } } // Clear focus from all items first uint32_t child_count = lv_obj_get_child_count(_bt_device_container); for (uint32_t i = 0; i < child_count; i++) { lv_obj_t* child = lv_obj_get_child(_bt_device_container, i); if (child) { lv_obj_clear_state(child, LV_STATE_FOCUSED); } } lv_obj_t* focus_target = NULL; // Only add Refresh button if NOT scanning if (!is_scanning) { lv_obj_t* refresh_btn = addMenuItem(_bt_device_container, "Refresh"); lv_obj_add_event_cb(refresh_btn, bt_device_click_cb, LV_EVENT_CLICKED, NULL); lv_obj_move_to_index(refresh_btn, insert_index++); if (device_list->count == 0) { focus_target = refresh_btn; // Focus on Refresh if no devices } } // Only add Clear Paired button if there are actually paired devices and NOT scanning if (has_paired_devices && !is_scanning) { lv_obj_t* clear_btn = addMenuItem(_bt_device_container, "Clear Paired"); lv_obj_add_event_cb(clear_btn, bt_device_click_cb, LV_EVENT_CLICKED, NULL); lv_obj_move_to_index(clear_btn, insert_index++); } // Set focus on appropriate item if (!focus_target) { if (device_list->count > 0) { // Devices found - focus on first device focus_target = lv_obj_get_child(_bt_device_container, 0); } else { // Scanning with no devices yet, or no devices and no refresh - focus on Back/Cancel focus_target = lv_obj_get_child(_bt_device_container, -1); // Last item (Back/Cancel) } } if (focus_target) { lv_obj_add_state(focus_target, LV_STATE_FOCUSED); // Start scrolling on initially focused item update_label_scroll(focus_target, true); } // Update menu context to track the newly focused item currentFocusIndex(&_menuContext); } static void bt_device_click_cb(lv_event_t * e) { if (!e) { ESP_LOGE(TAG, "Null event in bt_device_click_cb"); return; } lv_obj_t * btn = lv_event_get_target(e); if (!btn) { ESP_LOGE(TAG, "Null button in bt_device_click_cb"); return; } lv_obj_t * child = lv_obj_get_child(btn, 0); if (!child) { ESP_LOGE(TAG, "Null child in bt_device_click_cb"); return; } const char * txt = lv_label_get_text(child); if (!txt) { ESP_LOGE(TAG, "Null text in bt_device_click_cb"); return; } // Handle special buttons if (strcmp(txt, "Back") == 0 || strcmp(txt, "Cancel") == 0) { LOCK(); bt_stop_discovery(); if (strcmp(txt, "Cancel") == 0) { // Cancelled scanning - repopulate with current devices (not scanning) populate_bt_device_list(false); } else { // Back button - go back to previous menu menu_go_back(); } UNLOCK(); return; } else if (strcmp(txt, "Refresh") == 0) { LOCK(); // Update status first update_bt_status("Scanning..."); _bt_scan_start_time = xTaskGetTickCount(); // Change Back button to Cancel and hide Refresh // Find and update the Back button text uint32_t child_count = lv_obj_get_child_count(_bt_device_container); for (uint32_t i = 0; i < child_count; i++) { lv_obj_t* child = lv_obj_get_child(_bt_device_container, i); if (child) { lv_obj_t* child_label = lv_obj_get_child(child, 0); if (child_label) { const char* child_text = lv_label_get_text(child_label); if (child_text && strcmp(child_text, "Back") == 0) { lv_label_set_text(child_label, "Cancel"); ESP_LOGI(TAG, "Changed Back button to Cancel"); break; } } } } // Repopulate the list in scanning mode (hides Refresh, shows Cancel) populate_bt_device_list(true); bt_start_discovery(); UNLOCK(); return; } else if (strcmp(txt, "Clear Paired") == 0) { LOCK(); // Disconnect from any connected device first bt_disconnect_current_device(); // Clear all paired devices from NVS and device list esp_err_t ret = system_clearAllPairedDevices(); if (ret == ESP_OK) { ESP_LOGI(TAG, "Successfully cleared all paired devices from NVS"); // Also clear the in-memory device list used by GUI bt_clear_all_devices(); // Repopulate with empty list (not scanning) populate_bt_device_list(false); } else { ESP_LOGE(TAG, "Failed to clear paired devices: %s", esp_err_to_name(ret)); } UNLOCK(); return; } // Find which device was clicked by matching the device name bt_device_list_t* device_list = bt_get_device_list(); if (!device_list) { ESP_LOGE(TAG, "Null device list in bt_device_click_cb"); return; } // Extract device name from button text (remove "* " prefix if present) char device_name[64]; const char* name_start = txt; // Skip "* " prefix if present if (txt[0] == '*' && txt[1] == ' ') { name_start = txt + 2; } strncpy(device_name, name_start, sizeof(device_name) - 1); device_name[sizeof(device_name) - 1] = '\0'; // Find the device index by name for (int i = 0; i < device_list->count; i++) { if (strcmp(device_list->devices[i].name, device_name) == 0) { ESP_LOGI(TAG, "Requesting connection to device %d: %s", i, device_name); system_requestBtConnect(i); // Return to bubble mode after selection _mode = GUI_BUBBLE; show_bubble(); return; } } ESP_LOGW(TAG, "Device not found in list: %s", device_name); } static lv_obj_t* create_bt_device_page(bool start_discovery) { ESP_LOGI(TAG, "Creating Bluetooth device page (start_discovery=%d)", start_discovery); lv_obj_t* menu = create_menu_container(); if (!menu) { ESP_LOGE(TAG, "Failed to create menu container"); return NULL; } // Only create the page structure once, or recreate if it was deleted if (_bt_page == NULL) { ESP_LOGI(TAG, "Creating new Bluetooth page structure"); _bt_page = lv_menu_page_create(menu, NULL); if (!_bt_page) { ESP_LOGE(TAG, "Failed to create Bluetooth page"); return NULL; } lv_obj_set_style_radius(_bt_page, 0, LV_PART_MAIN | LV_STATE_ANY); lv_obj_set_style_border_width(_bt_page, 0, LV_PART_MAIN | LV_STATE_ANY); lv_obj_set_style_pad_all(_bt_page, 0, LV_PART_MAIN); lv_obj_set_style_pad_row(_bt_page, 0, LV_PART_MAIN); lv_obj_set_style_pad_column(_bt_page, 0, LV_PART_MAIN); lv_obj_set_scrollbar_mode(_bt_page, LV_SCROLLBAR_MODE_OFF); lv_obj_set_size(_bt_page, lv_pct(100), lv_pct(100)); lv_obj_set_flex_flow(_bt_page, LV_FLEX_FLOW_COLUMN); // Create fixed status label (header, non-scrolling) _bt_status_item = lv_label_create(_bt_page); lv_label_set_text(_bt_status_item, "Devices:"); lv_obj_set_width(_bt_status_item, lv_pct(100)); lv_obj_set_height(_bt_status_item, 16); // Thinner header lv_obj_set_style_bg_color(_bt_status_item, lv_color_make(0x00, 0x00, 0x00), 0); lv_obj_set_style_bg_opa(_bt_status_item, LV_OPA_COVER, 0); lv_obj_set_style_text_color(_bt_status_item, lv_color_white(), 0); lv_obj_set_style_pad_left(_bt_status_item, 5, 0); lv_obj_set_style_pad_top(_bt_status_item, 2, 0); lv_obj_set_style_text_align(_bt_status_item, LV_TEXT_ALIGN_LEFT, 0); lv_obj_set_style_radius(_bt_status_item, 0, LV_PART_MAIN); lv_obj_clear_flag(_bt_status_item, LV_OBJ_FLAG_SCROLLABLE); // Create scrollable container for menu items (below header) _bt_device_container = lv_obj_create(_bt_page); lv_obj_set_width(_bt_device_container, lv_pct(100)); lv_obj_set_flex_grow(_bt_device_container, 1); // Take remaining space lv_obj_set_style_radius(_bt_device_container, 0, LV_PART_MAIN); lv_obj_set_style_radius(_bt_device_container, 0, LV_STATE_DEFAULT); lv_obj_set_style_radius(_bt_device_container, 0, 0); lv_obj_set_style_border_width(_bt_device_container, 0, LV_PART_MAIN); lv_obj_set_style_outline_width(_bt_device_container, 0, LV_PART_MAIN); lv_obj_set_style_pad_all(_bt_device_container, 0, LV_PART_MAIN); lv_obj_set_style_pad_row(_bt_device_container, 0, LV_PART_MAIN); lv_obj_set_style_bg_color(_bt_device_container, lv_color_black(), LV_PART_MAIN); lv_obj_set_style_bg_opa(_bt_device_container, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_flex_flow(_bt_device_container, LV_FLEX_FLOW_COLUMN); lv_obj_set_scrollbar_mode(_bt_device_container, LV_SCROLLBAR_MODE_AUTO); // Create Back button (always visible, will change to Cancel during scanning) lv_obj_t* back_btn = addMenuItem(_bt_device_container, "Back"); lv_obj_add_event_cb(back_btn, bt_device_click_cb, LV_EVENT_CLICKED, NULL); // Set initial focus on Back button (will be moved to first device when they appear) lv_obj_add_state(back_btn, LV_STATE_FOCUSED); } // Get device list first to decide if we should scan bt_device_list_t* device_list = bt_get_device_list(); if (!device_list) { ESP_LOGE(TAG, "Failed to get device list"); update_bt_status("Error"); return _bt_page; } // Only auto-start discovery if requested AND there are no paired devices bool discovery_started = false; if (start_discovery && device_list->count == 0) { ESP_LOGI(TAG, "Starting Bluetooth discovery (no paired devices)"); update_bt_status("Scanning..."); _bt_scan_start_time = xTaskGetTickCount(); // Record when scan started discovery_started = bt_start_discovery(); if (!discovery_started) { ESP_LOGW(TAG, "Discovery not started"); update_bt_status("Scan failed"); _bt_scan_start_time = 0; } } // Populate with current device list (pass scanning state) if (discovery_started) { // Change Back to Cancel lv_obj_t* back_btn = lv_obj_get_child(_bt_device_container, 0); if (back_btn) { lv_obj_t* label = lv_obj_get_child(back_btn, 0); if (label) { lv_label_set_text(label, "Cancel"); } } populate_bt_device_list(true); // Scanning mode } else { populate_bt_device_list(false); // Not scanning } return _bt_page; } static void show_bt_device_list(void) { ESP_LOGI(TAG, "Showing Bluetooth device list with discovery"); // Start discovery when explicitly showing the BT device list bool should_start_discovery = true; lv_obj_t* menu = create_menu_container(); if (!menu) { ESP_LOGE(TAG, "Failed to create menu container for Bluetooth list"); return; } lv_obj_t* bt_page = create_bt_device_page(should_start_discovery); if (!bt_page) { ESP_LOGE(TAG, "Failed to create Bluetooth device page"); return; } lv_menu_set_page(menu, bt_page); // Store the page in _bt_page for later reference, but use container for navigation _currentPage = _bt_device_container; // Point to the scrollable container for navigation _mode = GUI_MENU; // Keep in menu mode // Initialize menu context for this page currentFocusIndex(&_menuContext); menu_hide_headers(menu); lv_obj_remove_flag(menu, LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(_bubble, LV_OBJ_FLAG_HIDDEN); ESP_LOGI(TAG, "Bluetooth device list displayed"); } static void refresh_bt_device_list(void) { ESP_LOGI(TAG, "Refreshing Bluetooth device list with discovered devices"); // Change Cancel back to Back uint32_t child_count = lv_obj_get_child_count(_bt_device_container); for (uint32_t i = 0; i < child_count; i++) { lv_obj_t* child = lv_obj_get_child(_bt_device_container, i); if (child) { lv_obj_t* child_label = lv_obj_get_child(child, 0); if (child_label) { const char* child_text = lv_label_get_text(child_label); if (child_text && strcmp(child_text, "Cancel") == 0) { lv_label_set_text(child_label, "Back"); ESP_LOGI(TAG, "Changed Cancel button back to Back"); break; } } } } // Repopulate with discovered devices (not scanning anymore) populate_bt_device_list(false); ESP_LOGI(TAG, "Bluetooth device list refreshed"); } // ───── VOLUME CONTROL PAGE ───── static lv_obj_t* create_volume_page(void) { ESP_LOGI(TAG, "Creating volume control page"); lv_obj_t* menu = create_menu_container(); if (!menu) { ESP_LOGE(TAG, "Failed to create menu container for volume control"); return NULL; } _volume_page = lv_menu_page_create(menu, NULL); if (!_volume_page) { ESP_LOGE(TAG, "Failed to create volume page"); return NULL; } lv_obj_set_style_radius(_volume_page, 0, LV_PART_MAIN | LV_STATE_ANY); lv_obj_set_style_border_width(_volume_page, 0, LV_PART_MAIN | LV_STATE_ANY); lv_obj_set_scrollbar_mode(_volume_page, LV_SCROLLBAR_MODE_OFF); lv_obj_set_size(_volume_page, lv_pct(100), lv_pct(100)); lv_obj_set_style_pad_all(_volume_page, 0, LV_PART_MAIN); // Hide header uint32_t child_count = lv_obj_get_child_count(_volume_page); if (child_count >= 1) { lv_obj_t* header = lv_obj_get_child(_volume_page, 0); lv_obj_add_flag(header, LV_OBJ_FLAG_HIDDEN); lv_obj_set_height(header, 0); } // Create a container to center everything vertically lv_obj_t* container = lv_obj_create(_volume_page); lv_obj_set_size(container, lv_pct(100), lv_pct(100)); lv_obj_set_style_bg_color(container, lv_color_black(), 0); lv_obj_set_style_bg_opa(container, LV_OPA_COVER, 0); lv_obj_set_style_border_width(container, 0, 0); lv_obj_set_style_radius(container, 0, 0); lv_obj_set_style_pad_all(container, 0, 0); lv_obj_set_flex_flow(container, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_align(container, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_center(container); // Create title label lv_obj_t* title = lv_label_create(container); lv_label_set_text(title, "Volume Control"); lv_obj_set_style_text_align(title, LV_TEXT_ALIGN_CENTER, 0); lv_obj_set_style_text_color(title, lv_color_white(), 0); lv_obj_set_style_pad_bottom(title, 8, 0); // Create volume bar (progress bar) _volume_bar = lv_bar_create(container); lv_obj_set_size(_volume_bar, 120, 20); lv_bar_set_range(_volume_bar, 0, 100); lv_bar_set_value(_volume_bar, _current_volume, LV_ANIM_OFF); lv_obj_set_style_bg_color(_volume_bar, lv_color_make(0x40, 0x40, 0x40), LV_PART_MAIN); // Dark gray background lv_obj_set_style_bg_color(_volume_bar, lv_color_make(0xFF, 0xFF, 0x00), LV_PART_INDICATOR); // Yellow indicator lv_obj_set_style_pad_top(_volume_bar, 5, 0); lv_obj_set_style_pad_bottom(_volume_bar, 5, 0); // Create volume percentage label _volume_label = lv_label_create(container); char volume_text[16]; snprintf(volume_text, sizeof(volume_text), "%d%%", _current_volume); lv_label_set_text(_volume_label, volume_text); lv_obj_set_style_text_align(_volume_label, LV_TEXT_ALIGN_CENTER, 0); lv_obj_set_style_text_color(_volume_label, lv_color_white(), 0); lv_obj_set_style_pad_top(_volume_label, 5, 0); return _volume_page; } static void show_volume_control(void) { ESP_LOGI(TAG, "Showing volume control"); lv_obj_t* menu = create_menu_container(); if (!menu) { ESP_LOGE(TAG, "Failed to create menu container for volume control"); return; } lv_obj_t* volume_page = create_volume_page(); if (!volume_page) { ESP_LOGE(TAG, "Failed to create volume page"); return; } lv_menu_set_page(menu, volume_page); _currentPage = volume_page; _mode = GUI_MENU; // Keep in menu mode menu_hide_headers(menu); lv_obj_remove_flag(menu, LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(_bubble, LV_OBJ_FLAG_HIDDEN); ESP_LOGI(TAG, "Volume control displayed"); } // Menu stack management functions static void menu_stack_push(lv_obj_t *page) { if (_menuStack.count < MAX_MENU_STACK_SIZE && page != NULL) { _menuStack.pages[_menuStack.count++] = page; ESP_LOGI(TAG, "Menu stack push: page=%p, count=%d", page, _menuStack.count); } else if (_menuStack.count >= MAX_MENU_STACK_SIZE) { ESP_LOGW(TAG, "Menu stack overflow, cannot push more pages"); } } static lv_obj_t* menu_stack_pop(void) { if (_menuStack.count > 0) { lv_obj_t *page = _menuStack.pages[--_menuStack.count]; ESP_LOGI(TAG, "Menu stack pop: page=%p, count=%d", page, _menuStack.count); return page; } ESP_LOGI(TAG, "Menu stack is empty, cannot pop"); return NULL; } static void menu_stack_clear(void) { _menuStack.count = 0; ESP_LOGI(TAG, "Menu stack cleared"); } static bool menu_stack_is_empty(void) { return (_menuStack.count == 0); } static void menu_go_back(void) { ESP_LOGI(TAG, "Menu go back requested"); if (menu_stack_is_empty()) { ESP_LOGI(TAG, "Menu stack empty, returning to bubble mode"); _mode = GUI_BUBBLE; show_bubble(); return; } lv_obj_t *previous_page = menu_stack_pop(); if (previous_page != NULL) { ESP_LOGI(TAG, "Returning to previous menu page"); lv_obj_t* menu = create_menu_container(); if (menu) { // Stop any ongoing Bluetooth discovery when leaving BT page if (_currentPage == _bt_device_container) { bt_stop_discovery(); } lv_menu_set_page(menu, previous_page); _currentPage = previous_page; // For main menu, page and currentPage are the same _mode = GUI_MENU; // Re-initialize menu context for the restored page currentFocusIndex(&_menuContext); lv_obj_remove_flag(menu, LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(_bubble, LV_OBJ_FLAG_HIDDEN); } } else { ESP_LOGI(TAG, "No previous page found, returning to bubble mode"); _mode = GUI_BUBBLE; show_bubble(); } } static void update_volume_display(int volume) { if (_volume_bar && _volume_label) { LOCK(); lv_bar_set_value(_volume_bar, volume, LV_ANIM_ON); char volume_text[16]; snprintf(volume_text, sizeof(volume_text), "%d%%", volume); lv_label_set_text(_volume_label, volume_text); UNLOCK(); _current_volume = volume; ESP_LOGI(TAG, "Volume display updated to %d%%", volume); } } // ───── CALIBRATION PAGE ───── static void calibration_timer_cb(lv_timer_t * timer) { // Return to bubble mode after showing message LOCK(); _mode = GUI_BUBBLE; show_bubble(); if (_calibration_timer) { lv_timer_del(_calibration_timer); _calibration_timer = NULL; } UNLOCK(); } static void calibration_click_cb(lv_event_t * e) { if (!e) { ESP_LOGE(TAG, "Null event in calibration_click_cb"); return; } lv_obj_t * btn = lv_event_get_target(e); if (!btn) { ESP_LOGE(TAG, "Null button in calibration_click_cb"); return; } lv_obj_t * child = lv_obj_get_child(btn, 0); if (!child) { ESP_LOGE(TAG, "Null child in calibration_click_cb"); return; } const char * txt = lv_label_get_text(child); if (!txt) { ESP_LOGE(TAG, "Null text in calibration_click_cb"); return; } ESP_LOGI(TAG, "Calibration menu item clicked: %s", txt); if (strcmp(txt, "Cancel") == 0) { LOCK(); menu_go_back(); UNLOCK(); } else if (strcmp(txt, "Calibrate") == 0) { // Run calibration system_setZeroAngle(); // Delete all menu items if (_calibration_page) { lv_obj_clean(_calibration_page); // Create a container to help with centering lv_obj_t* container = lv_obj_create(_calibration_page); lv_obj_set_size(container, lv_pct(100), lv_pct(100)); lv_obj_set_style_bg_opa(container, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(container, 0, 0); lv_obj_set_style_pad_all(container, 0, 0); // Show "Calibrated" message centered lv_obj_t* label = lv_label_create(container); lv_label_set_text(label, "Calibrated"); lv_obj_set_style_text_color(label, lv_color_white(), 0); lv_obj_set_style_text_font(label, &lv_font_montserrat_14, 0); lv_obj_center(label); } // Create a one-shot timer to return to bubble mode after 1 second if (_calibration_timer) { lv_timer_del(_calibration_timer); } _calibration_timer = lv_timer_create(calibration_timer_cb, 2000, NULL); lv_timer_set_repeat_count(_calibration_timer, 1); } else if (strcmp(txt, "Clear") == 0) { // Clear calibration system_clearZeroAngle(); // Delete all menu items if (_calibration_page) { lv_obj_clean(_calibration_page); // Create a container to help with centering lv_obj_t* container = lv_obj_create(_calibration_page); lv_obj_set_size(container, lv_pct(100), lv_pct(100)); lv_obj_set_style_bg_opa(container, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(container, 0, 0); lv_obj_set_style_pad_all(container, 0, 0); // Show "Cleared" message centered lv_obj_t* label = lv_label_create(container); lv_label_set_text(label, "Cleared"); lv_obj_set_style_text_color(label, lv_color_white(), 0); lv_obj_set_style_text_font(label, &lv_font_montserrat_14, 0); lv_obj_center(label); } // Create a one-shot timer to return to bubble mode after 1 second if (_calibration_timer) { lv_timer_del(_calibration_timer); } _calibration_timer = lv_timer_create(calibration_timer_cb, 2000, NULL); lv_timer_set_repeat_count(_calibration_timer, 1); } } static lv_obj_t* create_calibration_page(void) { ESP_LOGI(TAG, "Creating calibration page"); lv_obj_t* menu = create_menu_container(); if (!menu) { ESP_LOGE(TAG, "Failed to create menu container for calibration"); return NULL; } _calibration_page = lv_menu_page_create(menu, NULL); if (!_calibration_page) { ESP_LOGE(TAG, "Failed to create calibration page"); return NULL; } lv_obj_set_style_radius(_calibration_page, 0, LV_PART_MAIN | LV_STATE_ANY); lv_obj_set_style_border_width(_calibration_page, 0, LV_PART_MAIN | LV_STATE_ANY); lv_obj_set_style_pad_all(_calibration_page, 0, LV_PART_MAIN); lv_obj_set_style_pad_row(_calibration_page, 0, LV_PART_MAIN); lv_obj_set_style_pad_column(_calibration_page, 0, LV_PART_MAIN); lv_obj_set_scrollbar_mode(_calibration_page, LV_SCROLLBAR_MODE_AUTO); lv_obj_set_size(_calibration_page, lv_pct(100), lv_pct(100)); // Add menu items in order: Cancel, Calibrate, Clear lv_obj_t* cancel_btn = addMenuItem(_calibration_page, "Cancel"); lv_obj_add_event_cb(cancel_btn, calibration_click_cb, LV_EVENT_CLICKED, NULL); lv_obj_add_state(cancel_btn, LV_STATE_FOCUSED); // First item focused update_label_scroll(cancel_btn, true); // Start scrolling on initially focused item lv_obj_t* calibrate_btn = addMenuItem(_calibration_page, "Calibrate"); lv_obj_add_event_cb(calibrate_btn, calibration_click_cb, LV_EVENT_CLICKED, NULL); lv_obj_t* clear_btn = addMenuItem(_calibration_page, "Clear"); lv_obj_add_event_cb(clear_btn, calibration_click_cb, LV_EVENT_CLICKED, NULL); return _calibration_page; } static void show_calibration_menu(void) { ESP_LOGI(TAG, "Showing calibration menu"); lv_obj_t* menu = create_menu_container(); if (!menu) { ESP_LOGE(TAG, "Failed to create menu container for calibration"); return; } lv_obj_t* calibration_page = create_calibration_page(); if (!calibration_page) { ESP_LOGE(TAG, "Failed to create calibration page"); return; } lv_menu_set_page(menu, calibration_page); _currentPage = calibration_page; _mode = GUI_MENU; // Keep in menu mode // Initialize menu context to track the focused item currentFocusIndex(&_menuContext); menu_hide_headers(menu); lv_obj_remove_flag(menu, LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(_bubble, LV_OBJ_FLAG_HIDDEN); ESP_LOGI(TAG, "Calibration menu displayed"); } // ───── BUILD THE MENU ───── static void build_scrollable_menu(void) { lv_style_init(&_styleFocusedBtn); lv_style_init(&_styleUnfocusedBtn); lv_style_set_bg_color(&_styleUnfocusedBtn, lv_color_make(0x00,0x00,0x00)); // black bg lv_style_set_text_color(&_styleUnfocusedBtn, lv_color_white()); lv_style_set_bg_color(&_styleFocusedBtn, lv_color_make(0xFF,0xFF,0x00)); // bright yellow bg lv_style_set_text_color(&_styleFocusedBtn, lv_color_black()); // 2) Inside it, create the lv_menu and hide its sidebar/header lv_obj_t *menu = lv_menu_create(lv_scr_act()); _menu = menu; lv_obj_set_style_radius(menu, 0, LV_PART_MAIN | LV_STATE_ANY); lv_obj_set_style_border_width(menu, 0, LV_PART_MAIN | LV_STATE_ANY); lv_obj_set_size(menu, lv_pct(100), lv_pct(100)); lv_obj_center(menu); lv_obj_set_scrollbar_mode(menu, LV_SCROLLBAR_MODE_AUTO); lv_obj_t * main = lv_menu_page_create(menu, NULL); lv_obj_set_style_radius(main, 0, LV_PART_MAIN | LV_STATE_ANY); lv_obj_set_style_border_width(main, 0, LV_PART_MAIN | LV_STATE_ANY); lv_obj_set_scrollbar_mode(main, LV_SCROLLBAR_MODE_AUTO); lv_obj_set_size(main, lv_pct(100), lv_pct(100)); // Hide header uint32_t child_count = lv_obj_get_child_count(main); if (child_count >= 1) { lv_obj_t* header = lv_obj_get_child(main, 0); lv_obj_add_flag(header, LV_OBJ_FLAG_HIDDEN); lv_obj_set_height(header, 0); } lv_menu_set_page(menu, main); lv_obj_t * tmpObj; lv_obj_t * calMenu = lv_menu_page_create(menu, NULL); lv_obj_set_style_radius(calMenu, 0, LV_PART_MAIN | LV_STATE_ANY); lv_obj_set_style_border_width(calMenu, 0, LV_PART_MAIN | LV_STATE_ANY); lv_obj_set_scrollbar_mode(calMenu, LV_SCROLLBAR_MODE_AUTO); // Hide header child_count = lv_obj_get_child_count(calMenu); if (child_count >= 1) { lv_obj_t* header = lv_obj_get_child(calMenu, 0); lv_obj_add_flag(header, LV_OBJ_FLAG_HIDDEN); lv_obj_set_height(header, 0); } tmpObj = addMenuItem(main, "Bluetooth"); lv_obj_add_state(tmpObj, LV_STATE_FOCUSED); //lv_obj_add_flag(tmpObj, LV_OBJ_FLAG_USER_1); tmpObj = addMenuItem(main, "Calibration"); lv_menu_set_load_page_event(menu, tmpObj, calMenu); tmpObj = addMenuItem(main, "Volume"); addMenuItem(main, "About"); addMenuItem(main, "Exit"); addMenuItem(calMenu, "Calibrate Level"); addMenuItem(calMenu, "Reset Calibration"); addMenuItem(calMenu, "Exit"); _currentPage = main; // 6) Initial highlight selected_idx = 0; refresh_highlight(); } static void gui_task(void *pvParameters) { (void)pvParameters; // Unused parameter system_subscribe(xTaskGetCurrentTaskHandle()); // Grab queue handle QueueHandle_t q = keypad_getQueue(); uint32_t ev = 0; LOCK(); _mode = GUI_BUBBLE; lv_obj_remove_flag(_bubble, LV_OBJ_FLAG_HIDDEN); UNLOCK(); // Initialize backlight timeout _lastButtonPress = xTaskGetTickCount(); _backlightOn = true; ESP_LOGI(TAG, "Start GUI Task..."); while (1) { // Check backlight timeout if (_backlightOn) { TickType_t elapsed = xTaskGetTickCount() - _lastButtonPress; if (elapsed > pdMS_TO_TICKS(BACKLIGHT_TIMEOUT_MS)) { gpio_set_level(PIN_NUM_BK_LIGHT, 0); _backlightOn = false; ESP_LOGI(TAG, "Backlight off due to timeout"); } } if (xQueueReceive(q, &ev, pdMS_TO_TICKS(10)) == pdTRUE) { // Reset backlight timeout on any button press backlight_reset_timeout(); switch (ev) { #if 0 case (KEY_UP << KEY_LONG_PRESS): { system_setZeroAngle(); break; } case (KEY_DOWN << KEY_LONG_PRESS): { system_clearZeroAngle(); break; } #endif case (KEY_UP << KEY_SHORT_PRESS): if (_mode == GUI_MENU) { // Check if we're on the volume control page if (_currentPage == _volume_page) { // Volume up if (_current_volume < 100) { _current_volume += 5; if (_current_volume > 100) _current_volume = 100; update_volume_display(_current_volume); system_requestVolumeUp(); } } else { menuPrevious(); } } ESP_LOGI(TAG, "MAIN: Button UP SHORT"); break; case (KEY_DOWN << KEY_SHORT_PRESS): { if (_mode == GUI_MENU) { // Check if we're on the volume control page if (_currentPage == _volume_page) { // Volume down if (_current_volume > 0) { _current_volume -= 5; if (_current_volume < 0) _current_volume = 0; update_volume_display(_current_volume); system_requestVolumeDown(); } } else { menuNext(); } } ESP_LOGI(TAG, "MAIN: Button DOWN SHORT"); break; } case (KEY0 << KEY_LONG_PRESS): { ESP_LOGI(TAG, "MAIN: Button 0 LONG - Enter"); LOCK(); if (_mode != GUI_MENU) { _mode = GUI_MENU; show_menu(); // Use lazy loading } else if (_mode == GUI_MENU) { activate_selected(); // After activation, check if we should stay in menu or exit // (activation may have changed _currentPage or _mode) if (_mode == GUI_BUBBLE) { // Already handled by the menu item (e.g., "Exit" was selected) } else if (_currentPage == _bt_page || _currentPage == _volume_page) { // Stay in menu mode on these pages } // Note: For main menu items that navigate to subpages, // the btn_click_cb handles the navigation and keeps _mode = GUI_MENU } UNLOCK(); ESP_LOGI(TAG, "MAIN: Button 0 LONG - Exit"); break; } case (KEY1 << KEY_LONG_PRESS): { LOCK(); if (_mode == GUI_MENU) { menu_go_back(); // Use menu stack navigation } else { // Power off on long press from bubble mode gpio_set_level(PIN_NUM_nON, 0); } UNLOCK(); ESP_LOGI(TAG, "MAIN: Button 1 LONG - Back/Power"); break; } default: break; } } // Check for system events uint32_t notifiedBits = 0; xTaskNotifyWait( 0, // don't clear on entry 0xFFFFFFFF, // clear bits on exit ¬ifiedBits, pdMS_TO_TICKS(10)); if (notifiedBits & EM_EVENT_BT_DISCOVERY_COMPLETE) { // Discovery completed - refresh the BT page if we're on it and scan has been running long enough // Ignore spurious early "complete" events from stopping old discovery (must be > 500ms since start) if (_mode == GUI_MENU && _currentPage == _bt_device_container && _bt_scan_start_time > 0) { TickType_t elapsed = xTaskGetTickCount() - _bt_scan_start_time; if (elapsed > pdMS_TO_TICKS(500)) { ESP_LOGI(TAG, "Discovery complete after %dms, refreshing Bluetooth page", (int)pdTICKS_TO_MS(elapsed)); LOCK(); _bt_scan_start_time = 0; // Clear scan timestamp // Recreate the BT page with updated device list (without starting new discovery) refresh_bt_device_list(); UNLOCK(); } else { ESP_LOGI(TAG, "Ignoring spurious discovery complete event (only %dms elapsed)", (int)pdTICKS_TO_MS(elapsed)); } } } if (_mode == GUI_BUBBLE) { if (notifiedBits & EM_EVENT_NEW_DATA) { ImuData_t d = system_getImuData(); bubble_setValue(_bubble, -d.angle); } } } }