Files
soundshot/main/gui.c
Brent Perteet a272a15bcf Improve calibration and volume menu UX
- Add calibration submenu with three options: Cancel (default), Calibrate, and Clear
- Cancel returns to main menu to prevent accidental calibration
- Calibrate runs system_setZeroAngle() and displays "Calibrated" confirmation
- Clear runs system_clearZeroAngle() and displays "Cleared" confirmation
- Use LVGL timer for non-blocking 1-second confirmation messages
- Center confirmation messages both horizontally and vertically
- Use larger font (montserrat_16) for better readability
- Remove "Back" button from volume control page
- Center volume control elements vertically on screen
- Improve overall menu navigation and visual consistency

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 21:53:32 -06:00

1781 lines
57 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include <stdio.h>
#include <math.h>
#include <string.h>
#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* 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);
// 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;
// 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;
// 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
addMenuItem(main_page, "Calibration");
addMenuItem(main_page, "Volume");
//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;
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();
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 keypadderived 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, "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(0xFF8800), 0);
lv_obj_set_style_text_color(lv_obj_get_child(child,0),
lv_color_white(), 0);
selected = child;
ESP_LOGI(TAG, "Selected");
}
else
{
lv_obj_set_style_bg_color(child, lv_color_white(), 0);
lv_obj_set_style_text_color(lv_obj_get_child(child,0),
lv_color_black(), 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) {
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);
_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();
}
// 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);
lv_obj_t * lbl = lv_obj_get_child(btn, 0); // Get the label child
if (!lbl) return;
if (code == LV_EVENT_FOCUSED) {
// Start scrolling when focused
lv_label_set_long_mode(lbl, LV_LABEL_LONG_SCROLL_CIRCULAR);
}
else if (code == LV_EVENT_DEFOCUSED) {
// Stop scrolling and reset position when defocused
lv_label_set_long_mode(lbl, LV_LABEL_LONG_CLIP);
}
}
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, 3000, LV_PART_MAIN); // Set scrolling duration (3 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
lv_obj_add_event_cb(btn, label_scroll_focus_cb, LV_EVENT_FOCUSED, NULL);
lv_obj_add_event_cb(btn, label_scroll_focus_cb, LV_EVENT_DEFOCUSED, NULL);
// 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(0xff,0xff,0xff)); // white bg
lv_style_set_text_color(&_styleUnfocusedBtn, lv_color_black()); // black text
//lv_style_set_text_font(&_styleUnfocusedBtn, &lv_font_unscii_16); // larger font
lv_style_set_bg_color(&_styleFocusedBtn, lv_color_make(0x33,0x99,0xFF)); // blue bg
lv_style_set_text_color(&_styleFocusedBtn, lv_color_white()); // white 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);
}
// 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(0xE0, 0xE0, 0xE0), 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_black(), 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_white(), 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_SIZE_CONTENT);
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);
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_black(), 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_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_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_black(), 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_black(), 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
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(0xff,0xff,0xff)); // gray bg
lv_style_set_text_color(&_styleUnfocusedBtn, lv_color_hex(0xFF8800));
lv_style_set_bg_color(&_styleFocusedBtn, lv_color_make(0x33,0x99,0xFF)); // blue bg
lv_style_set_text_color(&_styleUnfocusedBtn,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();
ESP_LOGI(TAG, "Start GUI Task...");
while (1)
{
if (xQueueReceive(q, &ev, pdMS_TO_TICKS(10)) == pdTRUE)
{
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
&notifiedBits,
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);
}
}
}
}