Refactor RootLayout and UIHandler for improved structure and functionality

- Updated RootLayout to manage layout initialization and deinitialization more effectively.
- Removed unnecessary dependencies and streamlined event handling for keyboard events.
- Enhanced UIHandler to utilize shared pointers for app descriptors, improving memory management.
- Added methods for showing and hiding navigation elements in RootLayout.
- Introduced textarea widget with instant response by disabling animations.
- Improved error handling and logging throughout the UI components.
This commit is contained in:
GW_MC
2026-02-01 13:03:56 +08:00
parent 237a3a96c5
commit 06e81301b2
22 changed files with 880 additions and 2198 deletions

View File

@@ -18,10 +18,7 @@
#include "display/eink_display_handler.h"
#include "display/lvgl_handler.h"
#include "ui/ui_handler.h"
#include "ui/app_registry.h"
#include "ui/apps/shutdown_app.h"
#include "ui/apps/discord_app.h"
#include "ui/apps/mtr_app.h"
#include "ui/apps/registry.h"
#include <tick/lv_tick.h>
#include "esp_lvgl_port.h"
#include "lvgl.h"
@@ -103,7 +100,7 @@ void app_main(void) {
);
ESP_LOGI(TAG, "System is ready. Starting main application...\n");
DiscordAppDescriptor::instance();
// DiscordAppDescriptor::instance();
UIHandler ui_handler;
err = ui_handler.init();
if (err != ESP_OK) {
@@ -116,61 +113,6 @@ void app_main(void) {
// Allow LVGL system to stabilize before creating objects
vTaskDelay(pdMS_TO_TICKS(100));
// Create main screen and button for random rectangle demo
// lv_obj_t* scr = lv_scr_act();
// // Create a button
// lv_obj_t* btn = lv_btn_create(scr);
// lv_obj_set_size(btn, 200, 60);
// lv_obj_align(btn, LV_ALIGN_TOP_MID, 0, 20);
// lv_obj_set_style_border_width(btn, 2, 0);
// lv_obj_set_style_border_color(btn, lv_color_make(0, 0, 0), 0);
// // Add label to button
// lv_obj_t* label = lv_label_create(btn);
// lv_label_set_text(label, "Create Random Rect");
// lv_obj_center(label);
// lv_obj_set_style_text_color(label, lv_color_make(0, 0, 0), 0);
// // Event handler for button - creates random rectangles
// auto btn_event_cb = [](lv_event_t* e) {
// lv_obj_t* scr = lv_scr_act();
// // Create a random rectangle
// lv_obj_t* rect = lv_obj_create(scr);
// // Random size (30-100 pixels)
// lv_coord_t width = 30 + (esp_random() % 70);
// lv_coord_t height = 30 + (esp_random() % 70);
// lv_obj_set_size(rect, width, height);
// // Random position (avoid top 100px where button is)
// lv_coord_t x = esp_random() % (LV_HOR_RES - width);
// lv_coord_t y = 100 + (esp_random() % (LV_VER_RES - 100 - height));
// lv_obj_set_pos(rect, x, y);
// lv_obj_set_style_bg_color(rect, lv_color_make(0, 0, 0), 0);
// lv_obj_set_style_bg_opa(rect, LV_OPA_COVER, 0);
// // Make rectangle clickable
// lv_obj_add_flag(rect, LV_OBJ_FLAG_CLICKABLE);
// // Event handler to delete rectangle when clicked
// auto rect_event_cb = [](lv_event_t* e) {
// lv_obj_t* rect = static_cast<lv_obj_t*>(lv_event_get_target(e));
// lv_obj_del(rect);
// ESP_LOGI(TAG, "Rectangle deleted");
// };
// lv_obj_add_event_cb(rect, rect_event_cb, LV_EVENT_CLICKED, NULL);
// ESP_LOGI(TAG, "Created rectangle at (%d, %d) with size %dx%d", x, y, width, height);
// };
// lv_obj_add_event_cb(btn, btn_event_cb, LV_EVENT_CLICKED, NULL);
// ESP_LOGI(TAG, "Random rectangle demo initialized. Tap button to create rectangles.\n");
// wait for shutdown signal
ESP_LOGI(TAG, "Waiting for shutdown signal...\n");
EventBits_t bits = xEventGroupWaitBits(

View File

@@ -1,39 +0,0 @@
#pragma once
#include "ui/ui_app.h"
#include <vector>
/**
* @brief Registry for all available apps
*
* This singleton class maintains a list of all registered
* AppDescriptor instances, allowing the UIHandler or other
* components to query available apps.
*/
class AppRegistry {
public:
static AppRegistry& instance() {
static AppRegistry registry;
return registry;
}
AppRegistry(const AppRegistry&) = delete;
void operator=(const AppRegistry&) = delete;
AppRegistry(AppRegistry&&) = delete;
void operator=(AppRegistry&&) = delete;
// Register a new app descriptor
// The registry takes ownership of the descriptor pointer.
void register_app(AppDescriptor* app_descriptor) {
_app_descriptors.push_back(app_descriptor);
}
const std::vector<AppDescriptor*>& get_app_descriptors() const {
return _app_descriptors;
}
private:
AppRegistry() = default;
~AppRegistry() = default;
std::vector<AppDescriptor*> _app_descriptors = {};
};

View File

@@ -3,6 +3,7 @@
#include "lvgl.h"
#include "esp_err.h"
#include <string>
#include <memory>
/**
* @brief Base class for all UI applications
@@ -48,51 +49,39 @@ public:
virtual std::string get_name(void) const = 0;
/**
* @brief Handle system events passed from UIHandler
* @brief Handle back button press
*
* System events include network status changes, storage ready,
* display refresh, and other system-level events.
* Called when the back button is pressed.
* The app can choose to handle it (e.g., close a dialog)
* or return false to let UIHandler handle it (e.g., return to main screen).
*
* @param event_type Type/ID of the event
* @param event_data Optional event data payload
* @return true if the event was handled, false otherwise
*/
virtual void handle_event(uint32_t event_type, void* event_data = nullptr) { }
virtual bool on_back_button_pressed(void) {
return false; // default: not handled
}
/**
* @brief Get the app's root container
*
* @return lv_obj_t* pointer to the app's container
*/
lv_obj_t* get_container(void) const {
return _container;
}
protected:
lv_obj_t* _container = nullptr; ///< LVGL container provided by UIHandler
lv_obj_t* container_ = nullptr; ///< LVGL container provided by UIHandler
};
class AppDescriptor {
public:
virtual ~AppDescriptor() = default;
virtual void draw_icon(lv_obj_t* parent) = 0;
std::string get_name() const {
return _name;
return name_;
}
UIApp* get_app_instance() const {
return _app_instance;
return app_instance_.get();
}
protected:
AppDescriptor(std::string name, UIApp* app_instance)
: _name(name), _app_instance(app_instance) { }
AppDescriptor(std::string name, std::unique_ptr<UIApp> app_instance)
: name_(name), app_instance_(std::move(app_instance)) { }
std::string _name;
UIApp* _app_instance;
};
std::string name_;
std::unique_ptr<UIApp> app_instance_;
};

View File

@@ -1,652 +0,0 @@
#include "discord_app.h"
#include "esp_log.h"
#include "network/network.h"
#include <sstream>
static const char* TAG = "DiscordApp";
// ============================================================================
// DiscordApp Implementation
// ============================================================================
DiscordApp::DiscordApp()
: page_stack_(nullptr)
, status_icon_label_(nullptr)
, status_text_label_(nullptr)
, mute_button_(nullptr)
, error_notification_(nullptr)
, ip_textarea_(nullptr)
, port_textarea_(nullptr)
, test_result_label_(nullptr)
, remote_port_(0)
, settings_configured_(false)
, current_state_(VoiceState::UNKNOWN)
, state_mutex_(nullptr)
, poll_task_handle_(nullptr)
, stop_polling_(false)
, consecutive_failures_(0)
, storage_(nullptr) {
// Create mutex for thread-safe state access
state_mutex_ = xSemaphoreCreateMutex();
// Initialize storage
storage_ = new NVSStorageHandler(NVS_NAMESPACE);
}
DiscordApp::~DiscordApp() {
stop_polling_task();
if (state_mutex_) {
vSemaphoreDelete(state_mutex_);
}
if (storage_) {
delete storage_;
}
}
esp_err_t DiscordApp::init(lv_obj_t* container) {
ESP_LOGI(TAG, "Initializing Discord app");
_container = container;
// Initialize storage
storage_->init(nullptr);
// Load saved settings
load_settings();
// Initialize UDP client
udp_client_.init();
// Configure UDP if settings are available
if (settings_configured_) {
udp_client_.configure(remote_ip_, remote_port_);
}
// Create page stack
page_stack_ = new PageStack(container);
// Build main page
page_stack_->push([this](lv_obj_t* page) {
build_main_page(page);
});
// Start polling task
start_polling_task();
return ESP_OK;
}
esp_err_t DiscordApp::deinit() {
ESP_LOGI(TAG, "Deinitializing Discord app");
// Stop polling
stop_polling_task();
// Clean up page stack
if (page_stack_) {
delete page_stack_;
page_stack_ = nullptr;
}
// Close UDP client
udp_client_.close();
// Reset widget pointers
status_icon_label_ = nullptr;
status_text_label_ = nullptr;
mute_button_ = nullptr;
error_notification_ = nullptr;
ip_textarea_ = nullptr;
port_textarea_ = nullptr;
test_result_label_ = nullptr;
return ESP_OK;
}
void DiscordApp::handle_event(uint32_t event_type, void* event_data) {
// Handle system events if needed
}
bool DiscordApp::on_back_button_pressed() {
// If on settings page, go back to main page
if (page_stack_ && page_stack_->depth() > 1) {
page_stack_->pop();
return true;
}
// Let system handle back (return to app icons)
return false;
}
// ============================================================================
// Main Page UI
// ============================================================================
void DiscordApp::build_main_page(lv_obj_t* page) {
// Set up main page with flex column layout
lv_obj_set_flex_flow(page, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(page, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_all(page, 10, 0);
// === Top Section: Error Notification ===
error_notification_ = lv_obj_create(page);
lv_obj_set_width(error_notification_, LV_PCT(90));
lv_obj_set_height(error_notification_, LV_SIZE_CONTENT);
lv_obj_set_style_bg_color(error_notification_, lv_color_hex(0xFF0000), 0);
lv_obj_set_style_bg_opa(error_notification_, LV_OPA_70, 0);
lv_obj_set_style_pad_all(error_notification_, 10, 0);
lv_obj_set_style_radius(error_notification_, 8, 0);
lv_obj_add_flag(error_notification_, LV_OBJ_FLAG_HIDDEN);
lv_obj_set_flex_flow(error_notification_, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(error_notification_, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_t* error_label = lv_label_create(error_notification_);
lv_label_set_text(error_label, LV_SYMBOL_WARNING " Connection Lost");
lv_obj_set_style_text_color(error_label, lv_color_white(), 0);
// === Center Section: Main Content ===
lv_obj_t* center_container = lv_obj_create(page);
lv_obj_set_size(center_container, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_bg_opa(center_container, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(center_container, 0, 0);
lv_obj_set_style_pad_all(center_container, 0, 0);
lv_obj_set_flex_flow(center_container, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(center_container, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_row(center_container, 15, 0);
lv_obj_set_flex_grow(center_container, 1);
// Status icon (large, centered)
status_icon_label_ = lv_label_create(center_container);
lv_label_set_text(status_icon_label_, LV_SYMBOL_MUTE);
// Status text
status_text_label_ = lv_label_create(center_container);
lv_label_set_text(status_text_label_, "Unknown Status");
// Mute button
mute_button_ = lv_btn_create(center_container);
lv_obj_set_size(mute_button_, 200, 60);
lv_obj_add_event_cb(mute_button_, on_mute_button_clicked, LV_EVENT_CLICKED, this);
lv_obj_t* mute_label = lv_label_create(mute_button_);
lv_label_set_text(mute_label, "MUTE");
lv_obj_center(mute_label);
// === Bottom Section: Settings and Config Prompt ===
lv_obj_t* bottom_container = lv_obj_create(page);
lv_obj_set_size(bottom_container, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_bg_opa(bottom_container, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(bottom_container, 0, 0);
lv_obj_set_style_pad_all(bottom_container, 0, 0);
lv_obj_set_flex_flow(bottom_container, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(bottom_container, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
// Config prompt (left side)
if (!settings_configured_) {
lv_obj_t* config_prompt = lv_label_create(bottom_container);
lv_label_set_text(config_prompt, "Tap " LV_SYMBOL_SETTINGS " to configure");
lv_obj_set_style_text_color(config_prompt, lv_color_hex(0x888888), 0);
} else {
// Empty spacer if configured
lv_obj_t* spacer = lv_obj_create(bottom_container);
lv_obj_set_size(spacer, 0, 0);
lv_obj_set_style_bg_opa(spacer, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(spacer, 0, 0);
}
// Settings button (right side)
lv_obj_t* settings_btn = lv_btn_create(bottom_container);
lv_obj_set_size(settings_btn, 60, 60);
lv_obj_add_event_cb(settings_btn, on_settings_button_clicked, LV_EVENT_CLICKED, this);
lv_obj_t* settings_icon = lv_label_create(settings_btn);
lv_label_set_text(settings_icon, LV_SYMBOL_SETTINGS);
lv_obj_center(settings_icon);
// Update display with current state
update_status_display();
}
// ============================================================================
// Settings Page UI
// ============================================================================
void DiscordApp::build_settings_page(lv_obj_t* page) {
// Title
lv_obj_t* title = lv_label_create(page);
lv_label_set_text(title, "Discord Bridge Settings");
// Using default font
lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 20);
// IP address label
lv_obj_t* ip_label = lv_label_create(page);
lv_label_set_text(ip_label, "Bridge IP Address:");
lv_obj_align(ip_label, LV_ALIGN_TOP_LEFT, 20, 70);
// IP address textarea
ip_textarea_ = lv_textarea_create(page);
lv_obj_set_size(ip_textarea_, 300, 50);
lv_obj_align(ip_textarea_, LV_ALIGN_TOP_LEFT, 20, 100);
lv_textarea_set_one_line(ip_textarea_, true);
lv_textarea_set_placeholder_text(ip_textarea_, "e.g., 192.168.1.100");
if (!remote_ip_.empty()) {
lv_textarea_set_text(ip_textarea_, remote_ip_.c_str());
}
// Port label
lv_obj_t* port_label = lv_label_create(page);
lv_label_set_text(port_label, "Bridge Port:");
lv_obj_align(port_label, LV_ALIGN_TOP_LEFT, 20, 170);
// Port textarea
port_textarea_ = lv_textarea_create(page);
lv_obj_set_size(port_textarea_, 150, 50);
lv_obj_align(port_textarea_, LV_ALIGN_TOP_LEFT, 20, 200);
lv_textarea_set_one_line(port_textarea_, true);
lv_textarea_set_placeholder_text(port_textarea_, "e.g., 4211");
lv_textarea_set_accepted_chars(port_textarea_, "0123456789");
lv_textarea_set_max_length(port_textarea_, 5);
if (remote_port_ > 0) {
char port_str[8];
snprintf(port_str, sizeof(port_str), "%u", remote_port_);
lv_textarea_set_text(port_textarea_, port_str);
}
// Test connection button
lv_obj_t* test_btn = lv_btn_create(page);
lv_obj_set_size(test_btn, 200, 50);
lv_obj_align(test_btn, LV_ALIGN_TOP_MID, 0, 270);
lv_obj_add_event_cb(test_btn, on_test_connection_clicked, LV_EVENT_CLICKED, this);
lv_obj_t* test_label = lv_label_create(test_btn);
lv_label_set_text(test_label, "Test Connection");
lv_obj_center(test_label);
// Test result label
test_result_label_ = lv_label_create(page);
lv_label_set_text(test_result_label_, "");
lv_obj_align(test_result_label_, LV_ALIGN_TOP_MID, 0, 330);
// Save button
lv_obj_t* save_btn = lv_btn_create(page);
lv_obj_set_size(save_btn, 150, 50);
lv_obj_align(save_btn, LV_ALIGN_BOTTOM_MID, 0, -20);
lv_obj_add_event_cb(save_btn, on_save_settings_clicked, LV_EVENT_CLICKED, this);
lv_obj_set_style_bg_color(save_btn, lv_color_hex(0x00AA00), 0);
lv_obj_t* save_label = lv_label_create(save_btn);
lv_label_set_text(save_label, LV_SYMBOL_SAVE " Save");
lv_obj_set_style_text_color(save_label, lv_color_white(), 0);
lv_obj_center(save_label);
}
void DiscordApp::show_settings_page() {
page_stack_->push([this](lv_obj_t* page) {
build_settings_page(page);
});
}
// ============================================================================
// Event Callbacks
// ============================================================================
void DiscordApp::on_mute_button_clicked(lv_event_t* e) {
DiscordApp* app = static_cast<DiscordApp*>(lv_event_get_user_data(e));
if (app) {
app->send_mute_command();
}
}
void DiscordApp::on_settings_button_clicked(lv_event_t* e) {
DiscordApp* app = static_cast<DiscordApp*>(lv_event_get_user_data(e));
if (app) {
app->show_settings_page();
}
}
void DiscordApp::on_save_settings_clicked(lv_event_t* e) {
DiscordApp* app = static_cast<DiscordApp*>(lv_event_get_user_data(e));
if (app) {
app->save_settings();
// Go back to main page
if (app->page_stack_->depth() > 1) {
app->page_stack_->pop();
}
}
}
void DiscordApp::on_test_connection_clicked(lv_event_t* e) {
DiscordApp* app = static_cast<DiscordApp*>(lv_event_get_user_data(e));
if (!app || !app->test_result_label_) return;
// Get values from textareas
const char* ip = lv_textarea_get_text(app->ip_textarea_);
const char* port_str = lv_textarea_get_text(app->port_textarea_);
if (strlen(ip) == 0 || strlen(port_str) == 0) {
lv_label_set_text(app->test_result_label_, LV_SYMBOL_CLOSE " Please fill all fields");
lv_obj_set_style_text_color(app->test_result_label_, lv_color_hex(0xFF0000), 0);
return;
}
uint16_t port = atoi(port_str);
if (port == 0) {
lv_label_set_text(app->test_result_label_, LV_SYMBOL_CLOSE " Invalid port");
lv_obj_set_style_text_color(app->test_result_label_, lv_color_hex(0xFF0000), 0);
return;
}
// Configure UDP temporarily
UDPClient test_client;
test_client.init();
esp_err_t err = test_client.configure(ip, port);
if (err != ESP_OK) {
lv_label_set_text(app->test_result_label_, LV_SYMBOL_CLOSE " Invalid IP address");
lv_obj_set_style_text_color(app->test_result_label_, lv_color_hex(0xFF0000), 0);
return;
}
lv_label_set_text(app->test_result_label_, "Testing...");
lv_obj_set_style_text_color(app->test_result_label_, lv_color_hex(0x0000FF), 0);
// Send STATUS command
err = test_client.send_command("STATUS");
if (err != ESP_OK) {
lv_label_set_text(app->test_result_label_, LV_SYMBOL_CLOSE " Failed to send");
lv_obj_set_style_text_color(app->test_result_label_, lv_color_hex(0xFF0000), 0);
return;
}
// Wait for response
std::string response;
err = test_client.receive_response(response, 3000);
if (err == ESP_OK && (response == "MUTED" || response == "UNMUTED")) {
lv_label_set_text(app->test_result_label_, LV_SYMBOL_OK " Connection successful!");
lv_obj_set_style_text_color(app->test_result_label_, lv_color_hex(0x00AA00), 0);
} else {
lv_label_set_text(app->test_result_label_, LV_SYMBOL_CLOSE " No response from bridge");
lv_obj_set_style_text_color(app->test_result_label_, lv_color_hex(0xFF0000), 0);
}
}
// ============================================================================
// UDP Communication
// ============================================================================
void DiscordApp::send_mute_command() {
if (!settings_configured_) {
ESP_LOGW(TAG, "Cannot send command: not configured");
return;
}
esp_err_t err = udp_client_.send_command("MUTE");
if (err != ESP_OK) {
ESP_LOGE(TAG, "Failed to send MUTE command");
}
}
bool DiscordApp::test_connection() {
if (!settings_configured_) {
return false;
}
esp_err_t err = udp_client_.send_command("STATUS");
if (err != ESP_OK) {
return false;
}
std::string response;
err = udp_client_.receive_response(response, RESPONSE_TIMEOUT_MS);
return (err == ESP_OK && (response == "MUTED" || response == "UNMUTED"));
}
void DiscordApp::update_status_display() {
if (!status_icon_label_ || !status_text_label_) {
return;
}
// Thread-safe state access
VoiceState state;
if (xSemaphoreTake(state_mutex_, pdMS_TO_TICKS(100)) == pdTRUE) {
state = current_state_;
xSemaphoreGive(state_mutex_);
} else {
return;
}
switch (state) {
case VoiceState::MUTED:
lv_label_set_text(status_icon_label_, LV_SYMBOL_MUTE);
lv_label_set_text(status_text_label_, "Muted");
lv_obj_set_style_text_color(status_icon_label_, lv_color_hex(0xFF0000), 0);
break;
case VoiceState::UNMUTED:
lv_label_set_text(status_icon_label_, LV_SYMBOL_VOLUME_MAX);
lv_label_set_text(status_text_label_, "Unmuted");
lv_obj_set_style_text_color(status_icon_label_, lv_color_hex(0x00AA00), 0);
break;
case VoiceState::ERROR:
lv_label_set_text(status_icon_label_, LV_SYMBOL_WARNING);
lv_label_set_text(status_text_label_, "Connection Error");
lv_obj_set_style_text_color(status_icon_label_, lv_color_hex(0xFF8800), 0);
break;
case VoiceState::UNKNOWN:
default:
lv_label_set_text(status_icon_label_, LV_SYMBOL_BLUETOOTH);
lv_label_set_text(status_text_label_, "Unknown Status");
lv_obj_set_style_text_color(status_icon_label_, lv_color_hex(0x888888), 0);
break;
}
}
void DiscordApp::show_error_notification(bool show) {
if (error_notification_) {
if (show) {
lv_obj_clear_flag(error_notification_, LV_OBJ_FLAG_HIDDEN);
} else {
lv_obj_add_flag(error_notification_, LV_OBJ_FLAG_HIDDEN);
}
}
}
// ============================================================================
// Settings Management
// ============================================================================
void DiscordApp::load_settings() {
remote_ip_ = storage_->get(NVS_KEY_IP);
std::string port_str = storage_->get(NVS_KEY_PORT);
if (!remote_ip_.empty() && !port_str.empty()) {
remote_port_ = atoi(port_str.c_str());
settings_configured_ = (remote_port_ > 0);
ESP_LOGI(TAG, "Loaded settings: %s:%u", remote_ip_.c_str(), remote_port_);
} else {
settings_configured_ = false;
ESP_LOGI(TAG, "No settings found, user setup required");
}
}
void DiscordApp::save_settings() {
if (!ip_textarea_ || !port_textarea_) {
return;
}
const char* ip = lv_textarea_get_text(ip_textarea_);
const char* port_str = lv_textarea_get_text(port_textarea_);
if (strlen(ip) == 0 || strlen(port_str) == 0) {
ESP_LOGW(TAG, "Cannot save: empty fields");
return;
}
uint16_t port = atoi(port_str);
if (port == 0) {
ESP_LOGW(TAG, "Cannot save: invalid port");
return;
}
// Save to NVS
storage_->put(NVS_KEY_IP, ip);
storage_->put(NVS_KEY_PORT, port_str);
// Update local config
remote_ip_ = ip;
remote_port_ = port;
settings_configured_ = true;
// Reconfigure UDP client
udp_client_.configure(remote_ip_, remote_port_);
// Reset failure counter
consecutive_failures_ = 0;
ESP_LOGI(TAG, "Settings saved: %s:%u", remote_ip_.c_str(), remote_port_);
}
// ============================================================================
// Polling Task
// ============================================================================
void DiscordApp::poll_task(void* param) {
DiscordApp* app = static_cast<DiscordApp*>(param);
ESP_LOGI(TAG, "Polling task started");
while (!app->stop_polling_) {
app->poll_status();
// Use longer interval if in error state
int interval = (app->consecutive_failures_ >= MAX_FAILURES_BEFORE_ERROR)
? ERROR_POLL_INTERVAL_MS
: POLL_INTERVAL_MS;
vTaskDelay(pdMS_TO_TICKS(interval));
}
ESP_LOGI(TAG, "Polling task stopped");
app->poll_task_handle_ = nullptr;
vTaskDelete(nullptr);
}
void DiscordApp::start_polling_task() {
if (poll_task_handle_) {
ESP_LOGW(TAG, "Polling task already running");
return;
}
stop_polling_ = false;
xTaskCreate(poll_task, "discord_poll", 4096, this, 5, &poll_task_handle_);
}
void DiscordApp::stop_polling_task() {
if (!poll_task_handle_) {
return;
}
ESP_LOGI(TAG, "Stopping polling task");
stop_polling_ = true;
// Wait for task to finish (max 2 seconds)
int wait_count = 0;
while (poll_task_handle_ && wait_count < 20) {
vTaskDelay(pdMS_TO_TICKS(100));
wait_count++;
}
if (poll_task_handle_) {
ESP_LOGW(TAG, "Force deleting polling task");
vTaskDelete(poll_task_handle_);
poll_task_handle_ = nullptr;
}
}
void DiscordApp::poll_status() {
if (!settings_configured_) {
// Don't poll if not configured
return;
}
// Send STATUS command
esp_err_t err = udp_client_.send_command("STATUS");
if (err != ESP_OK) {
ESP_LOGW(TAG, "Failed to send STATUS command");
consecutive_failures_++;
if (consecutive_failures_ >= MAX_FAILURES_BEFORE_ERROR) {
if (xSemaphoreTake(state_mutex_, pdMS_TO_TICKS(100)) == pdTRUE) {
current_state_ = VoiceState::ERROR;
xSemaphoreGive(state_mutex_);
}
show_error_notification(true);
}
return;
}
// Wait for response
std::string response;
err = udp_client_.receive_response(response, RESPONSE_TIMEOUT_MS);
if (err == ESP_OK) {
// Success - reset failure counter
consecutive_failures_ = 0;
show_error_notification(false);
// Update state
VoiceState new_state = VoiceState::UNKNOWN;
if (response == "MUTED") {
new_state = VoiceState::MUTED;
} else if (response == "UNMUTED") {
new_state = VoiceState::UNMUTED;
}
if (xSemaphoreTake(state_mutex_, pdMS_TO_TICKS(100)) == pdTRUE) {
current_state_ = new_state;
xSemaphoreGive(state_mutex_);
}
update_status_display();
} else {
// Timeout or error
consecutive_failures_++;
ESP_LOGW(TAG, "No response (failures: %d)", consecutive_failures_);
if (consecutive_failures_ >= MAX_FAILURES_BEFORE_ERROR) {
if (xSemaphoreTake(state_mutex_, pdMS_TO_TICKS(100)) == pdTRUE) {
current_state_ = VoiceState::ERROR;
xSemaphoreGive(state_mutex_);
}
update_status_display();
show_error_notification(true);
}
}
}
// ============================================================================
// DiscordAppDescriptor Implementation
// ============================================================================
DiscordAppDescriptor::DiscordAppDescriptor()
: AppDescriptor("Discord", new DiscordApp()) {
// Auto-register on construction
AppRegistry::instance().register_app(this);
}
void DiscordAppDescriptor::draw_icon(lv_obj_t* parent) {
lv_obj_t* icon = lv_label_create(parent);
lv_label_set_text(icon, LV_SYMBOL_CALL);
lv_obj_center(icon);
}

View File

@@ -1,123 +0,0 @@
#pragma once
#include "ui/ui_app.h"
#include "ui/page_stack.h"
#include "ui/app_registry.h"
#include "network/udp_client.h"
#include "io/nvs_handler.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/semphr.h"
#include <string>
/**
* @brief Discord voice control app
*
* Allows control of Discord voice settings (mute/unmute) via UDP communication
* with the IotDis Node.js bridge. Features:
* - Main page: Status icon + mute button
* - Settings page: IP/port configuration with connection test
* - Periodic status polling with automatic retry
* - Error notification when remote is unreachable
*/
class DiscordApp : public UIApp {
public:
DiscordApp();
~DiscordApp() override;
// UIApp interface
esp_err_t init(lv_obj_t* container) override;
esp_err_t deinit() override;
std::string get_name() const override { return "Discord"; }
void handle_event(uint32_t event_type, void* event_data = nullptr) override;
bool on_back_button_pressed() override;
private:
// Voice state enum
enum class VoiceState {
UNKNOWN,
MUTED,
UNMUTED,
ERROR
};
// Page management
PageStack* page_stack_;
void build_main_page(lv_obj_t* page);
void build_settings_page(lv_obj_t* page);
void show_settings_page();
// Main page widgets
lv_obj_t* status_icon_label_;
lv_obj_t* status_text_label_;
lv_obj_t* mute_button_;
lv_obj_t* error_notification_;
// Settings page widgets
lv_obj_t* ip_textarea_;
lv_obj_t* port_textarea_;
lv_obj_t* test_result_label_;
// UDP client and configuration
UDPClient udp_client_;
std::string remote_ip_;
uint16_t remote_port_;
bool settings_configured_;
// Voice state
VoiceState current_state_;
SemaphoreHandle_t state_mutex_;
// Polling task
TaskHandle_t poll_task_handle_;
bool stop_polling_;
int consecutive_failures_;
static constexpr int MAX_FAILURES_BEFORE_ERROR = 3;
static constexpr int POLL_INTERVAL_MS = 5000;
static constexpr int ERROR_POLL_INTERVAL_MS = 15000;
static constexpr int RESPONSE_TIMEOUT_MS = 2000;
// NVS storage
NVSStorageHandler* storage_;
static constexpr const char* NVS_NAMESPACE = "discord";
static constexpr const char* NVS_KEY_IP = "remote_ip";
static constexpr const char* NVS_KEY_PORT = "remote_port";
// Event callbacks
static void on_mute_button_clicked(lv_event_t* e);
static void on_settings_button_clicked(lv_event_t* e);
static void on_save_settings_clicked(lv_event_t* e);
static void on_test_connection_clicked(lv_event_t* e);
// UDP communication
void send_mute_command();
bool test_connection();
void update_status_display();
void show_error_notification(bool show);
// Settings management
void load_settings();
void save_settings();
// Polling task
static void poll_task(void* param);
void start_polling_task();
void stop_polling_task();
void poll_status();
};
/**
* @brief Discord app descriptor for registration
*/
class DiscordAppDescriptor : public AppDescriptor {
public:
static DiscordAppDescriptor& instance() {
static DiscordAppDescriptor instance;
return instance;
}
void draw_icon(lv_obj_t* parent) override;
private:
DiscordAppDescriptor();
};

View File

@@ -1,399 +0,0 @@
#include "apps/mtr_app.h"
#include "external/mtr/arrival.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#define TAG "MtrApp"
// Event type for network ready
#define EVENT_NETWORK_READY 1
MtrApp::MtrApp() {
_mtr_handler = std::make_unique<MTRNextTrainHandler>();
}
esp_err_t MtrApp::init(lv_obj_t* container) {
if (!container) {
ESP_LOGE(TAG, "Container is null");
return ESP_ERR_INVALID_ARG;
}
_container = container;
ESP_LOGI(TAG, "Initializing MTR app...");
// Create page stack
_page_stack = std::make_unique<PageStack>(container);
// Load all lines
_all_lines = _mtr_handler->get_lines();
ESP_LOGI(TAG, "Loaded %zu MTR lines", _all_lines.size());
// Build initial line selection page
_page_stack->push([this](lv_obj_t* page) {
this->build_line_selection_page(page);
});
ESP_LOGI(TAG, "MTR app initialized successfully");
return ESP_OK;
}
esp_err_t MtrApp::deinit(void) {
ESP_LOGI(TAG, "Deinitializing MTR app");
// Clear page stack
if (_page_stack) {
_page_stack->clear();
_page_stack.reset();
}
// Clear state
_selected_line_code.clear();
_selected_station_code.clear();
_selected_line_info = nullptr;
_all_lines.clear();
return ESP_OK;
}
std::string MtrApp::get_name(void) const {
return "MTR";
}
bool MtrApp::on_back_button_pressed(void) {
if (_page_stack && _page_stack->depth() > 1) {
_page_stack->pop();
return true; // Handled
}
return false; // Not handled, go back to main menu
}
void MtrApp::handle_event(uint32_t event_type, void* event_data) {
if (event_type == EVENT_NETWORK_READY) {
ESP_LOGI(TAG, "Network ready event received");
}
}
void MtrApp::build_line_selection_page(lv_obj_t* page_container) {
ESP_LOGI(TAG, "Building line selection page");
// Title
lv_obj_t* title = lv_label_create(page_container);
lv_label_set_text(title, "選擇路綫 Select Line");
lv_obj_set_style_text_color(title, lv_color_black(), 0);
lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 10);
// Scrollable container for line buttons
lv_obj_t* scroll_container = lv_obj_create(page_container);
lv_obj_set_size(scroll_container, lv_pct(95), lv_pct(85));
lv_obj_align(scroll_container, LV_ALIGN_TOP_MID, 0, 40);
lv_obj_set_flex_flow(scroll_container, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(scroll_container, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_all(scroll_container, 5, 0);
lv_obj_set_style_pad_row(scroll_container, 8, 0);
// Create button for each line
for (size_t i = 0; i < _all_lines.size(); i++) {
LineInfo* line = &_all_lines[i];
lv_obj_t* btn = lv_btn_create(scroll_container);
lv_obj_set_size(btn, lv_pct(95), 60);
// Set button color based on line color
uint32_t color = parse_color_hex(line->color());
lv_obj_set_style_bg_color(btn, lv_color_hex(color), 0);
// Button label
lv_obj_t* label = lv_label_create(btn);
lv_label_set_text_fmt(label, "%s", line->code());
lv_obj_set_style_text_color(label, lv_color_white(), 0);
lv_obj_center(label);
// Store line pointer in user data
lv_obj_add_event_cb(btn, line_button_event_cb, LV_EVENT_CLICKED, this);
lv_obj_set_user_data(btn, (void*)line);
}
ESP_LOGI(TAG, "Created %zu line buttons", _all_lines.size());
}
void MtrApp::build_station_selection_page(lv_obj_t* page_container) {
ESP_LOGI(TAG, "Building station selection page for line: %s", _selected_line_code.c_str());
if (!_selected_line_info) {
ESP_LOGE(TAG, "No line info selected");
return;
}
// Title with line code
lv_obj_t* title = lv_label_create(page_container);
lv_label_set_text_fmt(title, "%s 路綫車站", _selected_line_code.c_str());
lv_obj_set_style_text_color(title, lv_color_black(), 0);
lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 10);
// Scrollable container for station buttons
lv_obj_t* scroll_container = lv_obj_create(page_container);
lv_obj_set_size(scroll_container, lv_pct(95), lv_pct(85));
lv_obj_align(scroll_container, LV_ALIGN_TOP_MID, 0, 40);
lv_obj_set_flex_flow(scroll_container, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(scroll_container, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_all(scroll_container, 5, 0);
lv_obj_set_style_pad_row(scroll_container, 6, 0);
// Create button for each station
const std::vector<StationInfo>* stations = _selected_line_info->stations();
for (size_t i = 0; i < stations->size(); i++) {
const StationInfo* station = &(*stations)[i];
lv_obj_t* btn = lv_btn_create(scroll_container);
lv_obj_set_size(btn, lv_pct(95), 50);
lv_obj_set_style_bg_color(btn, lv_color_hex(0x4CAF50), 0);
// Button label with station name and code
lv_obj_t* label = lv_label_create(btn);
lv_label_set_text_fmt(label, "%s (%s)", station->name(), station->code());
lv_obj_set_style_text_color(label, lv_color_white(), 0);
lv_obj_center(label);
// Store station pointer in user data
lv_obj_add_event_cb(btn, station_button_event_cb, LV_EVENT_CLICKED, this);
lv_obj_set_user_data(btn, (void*)station);
}
ESP_LOGI(TAG, "Created %zu station buttons", stations->size());
}
void MtrApp::build_arrival_page(lv_obj_t* page_container) {
ESP_LOGI(TAG, "Building arrival page");
// Title
lv_obj_t* title = lv_label_create(page_container);
lv_label_set_text_fmt(title, "%s - %s", _selected_line_code.c_str(), _selected_station_code.c_str());
lv_obj_set_style_text_color(title, lv_color_black(), 0);
lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 10);
// Loading message
lv_obj_t* loading_label = lv_label_create(page_container);
lv_label_set_text(loading_label, "載入中... Loading...");
lv_obj_set_style_text_color(loading_label, lv_color_black(), 0);
lv_obj_center(loading_label);
// Refresh button
lv_obj_t* refresh_btn = lv_btn_create(page_container);
lv_obj_set_size(refresh_btn, 120, 50);
lv_obj_align(refresh_btn, LV_ALIGN_BOTTOM_MID, 0, -10);
lv_obj_add_event_cb(refresh_btn, refresh_button_event_cb, LV_EVENT_CLICKED, this);
lv_obj_t* refresh_label = lv_label_create(refresh_btn);
lv_label_set_text(refresh_label, LV_SYMBOL_REFRESH " 重新整理");
lv_obj_set_style_text_color(refresh_label, lv_color_white(), 0);
lv_obj_center(refresh_label);
// Load arrival data asynchronously
load_arrival_data(page_container);
}
void MtrApp::load_arrival_data(lv_obj_t* page_container) {
if (!_network_handler) {
ESP_LOGW(TAG, "Network handler not set, cannot fetch arrival data");
// Update UI to show error
lv_obj_t* error_label = lv_label_create(page_container);
lv_label_set_text(error_label, "網絡未就緒\nNetwork not ready");
lv_obj_set_style_text_color(error_label, lv_color_black(), 0);
lv_obj_align(error_label, LV_ALIGN_CENTER, 0, -30);
return;
}
ESP_LOGI(TAG, "Fetching arrival data for %s/%s", _selected_line_code.c_str(), _selected_station_code.c_str());
StationArrivalInfo* arrival_info = nullptr;
MtrArrivalErrorCode error_code = _mtr_handler->get_next_arrival_info(
_network_handler,
_selected_line_code,
_selected_station_code,
arrival_info,
Language::TC
);
// Clear loading message
lv_obj_clean(page_container);
// Recreate title
lv_obj_t* title = lv_label_create(page_container);
lv_label_set_text_fmt(title, "%s - %s", _selected_line_code.c_str(), _selected_station_code.c_str());
lv_obj_set_style_text_color(title, lv_color_black(), 0);
lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 10);
if (error_code != MtrArrivalErrorCode::NONE || !arrival_info) {
ESP_LOGE(TAG, "Failed to fetch arrival info, error code: %d", (int)error_code);
lv_obj_t* error_label = lv_label_create(page_container);
lv_label_set_text(error_label, "無法取得班次資料\nFailed to fetch arrival data");
lv_obj_set_style_text_color(error_label, lv_color_black(), 0);
lv_obj_center(error_label);
return;
}
// Create scrollable container for arrivals
lv_obj_t* scroll_container = lv_obj_create(page_container);
lv_obj_set_size(scroll_container, lv_pct(95), lv_pct(75));
lv_obj_align(scroll_container, LV_ALIGN_TOP_MID, 0, 45);
lv_obj_set_style_pad_all(scroll_container, 10, 0);
int y_offset = 0;
// Display UP direction trains
lv_obj_t* up_header = lv_label_create(scroll_container);
lv_label_set_text(up_header, "上行 UP:");
lv_obj_set_style_text_color(up_header, lv_color_black(), 0);
lv_obj_set_pos(up_header, 0, y_offset);
y_offset += 30;
const std::vector<ArrivalInfo>* up_arrivals = arrival_info->up_arrivals();
if (up_arrivals->empty()) {
lv_obj_t* no_train = lv_label_create(scroll_container);
lv_label_set_text(no_train, " 暫無班次 No trains");
lv_obj_set_style_text_color(no_train, lv_color_hex(0x666666), 0);
lv_obj_set_pos(no_train, 10, y_offset);
y_offset += 25;
} else {
for (const auto& arrival : *up_arrivals) {
lv_obj_t* arrival_label = lv_label_create(scroll_container);
lv_label_set_text_fmt(arrival_label, " %s → %s", arrival.arrival_time(), arrival.destination());
lv_obj_set_style_text_color(arrival_label, lv_color_black(), 0);
lv_obj_set_pos(arrival_label, 10, y_offset);
y_offset += 25;
}
}
y_offset += 10;
// Display DOWN direction trains
lv_obj_t* down_header = lv_label_create(scroll_container);
lv_label_set_text(down_header, "下行 DOWN:");
lv_obj_set_style_text_color(down_header, lv_color_black(), 0);
lv_obj_set_pos(down_header, 0, y_offset);
y_offset += 30;
const std::vector<ArrivalInfo>* down_arrivals = arrival_info->down_arrivals();
if (down_arrivals->empty()) {
lv_obj_t* no_train = lv_label_create(scroll_container);
lv_label_set_text(no_train, " 暫無班次 No trains");
lv_obj_set_style_text_color(no_train, lv_color_hex(0x666666), 0);
lv_obj_set_pos(no_train, 10, y_offset);
y_offset += 25;
} else {
for (const auto& arrival : *down_arrivals) {
lv_obj_t* arrival_label = lv_label_create(scroll_container);
lv_label_set_text_fmt(arrival_label, " %s → %s", arrival.arrival_time(), arrival.destination());
lv_obj_set_style_text_color(arrival_label, lv_color_black(), 0);
lv_obj_set_pos(arrival_label, 10, y_offset);
y_offset += 25;
}
}
// Clean up
if (arrival_info != nullptr) {
delete arrival_info;
}
// Refresh button
lv_obj_t* refresh_btn = lv_btn_create(page_container);
lv_obj_set_size(refresh_btn, 120, 50);
lv_obj_align(refresh_btn, LV_ALIGN_BOTTOM_MID, 0, -10);
lv_obj_add_event_cb(refresh_btn, refresh_button_event_cb, LV_EVENT_CLICKED, this);
lv_obj_t* refresh_label = lv_label_create(refresh_btn);
lv_label_set_text(refresh_label, LV_SYMBOL_REFRESH " 重新整理");
lv_obj_set_style_text_color(refresh_label, lv_color_white(), 0);
lv_obj_center(refresh_label);
ESP_LOGI(TAG, "Arrival data displayed successfully");
}
uint32_t MtrApp::parse_color_hex(const char* hex_str) {
if (!hex_str || hex_str[0] != '#') {
return 0x808080; // Default gray
}
// Skip the '#' character
hex_str++;
uint32_t color = 0;
sscanf(hex_str, "%" SCNx32, &color);
return color;
}
void MtrApp::line_button_event_cb(lv_event_t* e) {
lv_event_code_t code = lv_event_get_code(e);
if (code == LV_EVENT_CLICKED) {
MtrApp* app = (MtrApp*)lv_event_get_user_data(e);
lv_obj_t* btn = (lv_obj_t*)lv_event_get_target(e);
LineInfo* line = (LineInfo*)lv_obj_get_user_data(btn);
if (app && line) {
ESP_LOGI(TAG, "Line selected: %s", line->code());
app->_selected_line_code = line->code();
app->_selected_line_info = line;
// Push station selection page
app->_page_stack->push([app](lv_obj_t* page) {
app->build_station_selection_page(page);
});
}
}
}
void MtrApp::station_button_event_cb(lv_event_t* e) {
lv_event_code_t code = lv_event_get_code(e);
if (code == LV_EVENT_CLICKED) {
MtrApp* app = (MtrApp*)lv_event_get_user_data(e);
lv_obj_t* btn = (lv_obj_t*)lv_event_get_target(e);
const StationInfo* station = (const StationInfo*)lv_obj_get_user_data(btn);
if (app && station) {
ESP_LOGI(TAG, "Station selected: %s (%s)", station->name(), station->code());
app->_selected_station_code = station->code();
// Push arrival page
app->_page_stack->push([app](lv_obj_t* page) {
app->build_arrival_page(page);
});
}
}
}
void MtrApp::refresh_button_event_cb(lv_event_t* e) {
lv_event_code_t code = lv_event_get_code(e);
if (code == LV_EVENT_CLICKED) {
MtrApp* app = (MtrApp*)lv_event_get_user_data(e);
if (app && app->_page_stack && app->_page_stack->current_page()) {
ESP_LOGI(TAG, "Refresh button clicked");
app->load_arrival_data(app->_page_stack->current_page());
}
}
}
// MtrAppDescriptor implementation
MtrApp* MtrAppDescriptor::_app_instance = nullptr;
MtrAppDescriptor::MtrAppDescriptor()
: AppDescriptor("MTR", []() -> UIApp* {
if (!MtrAppDescriptor::_app_instance) {
MtrAppDescriptor::_app_instance = new MtrApp();
}
return MtrAppDescriptor::_app_instance;
}()) {
// Register with AppRegistry
AppRegistry::instance().register_app(this);
ESP_LOGI(TAG, "MtrApp registered with AppRegistry");
}
void MtrAppDescriptor::draw_icon(lv_obj_t* parent) {
// Create MTR icon with train symbol
lv_obj_t* icon_label = lv_label_create(parent);
lv_label_set_text(icon_label, LV_SYMBOL_GPS "\nMTR");
lv_obj_set_style_text_color(icon_label, lv_color_white(), 0);
lv_obj_set_style_text_align(icon_label, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_center(icon_label);
}

View File

@@ -1,71 +0,0 @@
#pragma once
#include "ui/ui_app.h"
#include "ui/app_registry.h"
#include "ui/page_stack.h"
#include "external/mtr/mtr.h"
#include "external/mtr/line_info.h"
#include "external/mtr/station_info.h"
#include "network/network.h"
#include <memory>
#include <string>
/**
* @brief MTR Next Train application
*
* Provides multi-page navigation for:
* 1. Line selection - choose MTR line
* 2. Station selection - choose station within selected line
* 3. Arrival display - show real-time train arrival information
*/
class MtrApp : public UIApp {
public:
MtrApp();
virtual ~MtrApp() = default;
esp_err_t init(lv_obj_t* container) override;
esp_err_t deinit(void) override;
std::string get_name(void) const override;
bool on_back_button_pressed(void) override;
void handle_event(uint32_t event_type, void* event_data) override;
// Set network handler (must be called before using app)
void set_network_handler(NetworkHandler* handler) { _network_handler = handler; }
private:
std::unique_ptr<MTRNextTrainHandler> _mtr_handler;
std::unique_ptr<PageStack> _page_stack;
NetworkHandler* _network_handler = nullptr;
// Current selection state
std::string _selected_line_code;
std::string _selected_station_code;
LineInfo* _selected_line_info = nullptr;
std::vector<LineInfo> _all_lines;
// Page builders
void build_line_selection_page(lv_obj_t* page_container);
void build_station_selection_page(lv_obj_t* page_container);
void build_arrival_page(lv_obj_t* page_container);
// Event handlers
static void line_button_event_cb(lv_event_t* e);
static void station_button_event_cb(lv_event_t* e);
static void refresh_button_event_cb(lv_event_t* e);
// Helper functions
void load_arrival_data(lv_obj_t* page_container);
uint32_t parse_color_hex(const char* hex_str);
};
/**
* @brief AppDescriptor for MtrApp
*/
class MtrAppDescriptor : public AppDescriptor {
public:
MtrAppDescriptor();
void draw_icon(lv_obj_t* parent) override;
private:
static MtrApp* _app_instance;
};

53
main/ui/apps/registry.h Normal file
View File

@@ -0,0 +1,53 @@
#pragma once
#include "ui/apps/app.h"
#include <map>
#include <string>
#include "esp_log.h"
#include <memory>
class AppRegistry {
public:
static AppRegistry& instance() {
static AppRegistry registry;
return registry;
}
void register_app(std::unique_ptr<AppDescriptor> app_descriptor) {
if (app_descriptors_.find(app_descriptor->get_name()) != app_descriptors_.end()) {
// App already registered
ESP_LOGW("AppRegistry", "App '%s' is already registered", app_descriptor->get_name().c_str());
return;
}
app_descriptors_.emplace(app_descriptor->get_name(), std::move(app_descriptor));
}
size_t size() const {
return app_descriptors_.size();
}
// iterators to access registered apps
auto begin() { return app_descriptors_.begin(); }
auto begin() const { return app_descriptors_.begin(); }
auto end() { return app_descriptors_.end(); }
auto end() const { return app_descriptors_.end(); }
// [] operator to get app by name
AppDescriptor* operator[](const std::string& name) {
auto it = app_descriptors_.find(name);
if (it != app_descriptors_.end()) {
return it->second.get();
}
return nullptr;
}
private:
std::map<std::string, std::unique_ptr<AppDescriptor>> app_descriptors_ = {};
AppRegistry() = default;
// Disable copy and move semantics
AppRegistry(const AppRegistry&) = delete;
AppRegistry& operator=(const AppRegistry&) = delete;
AppRegistry(AppRegistry&&) = delete;
AppRegistry& operator=(AppRegistry&&) = delete;
};

View File

@@ -1,64 +0,0 @@
#include "apps/shutdown_app.h"
#include "esp_log.h"
#define TAG "ShutdownApp"
ShutdownApp::ShutdownApp(std::string message)
: _message(message.empty() ? "System Shutting Down..." : message) { }
esp_err_t ShutdownApp::init(lv_obj_t* container) {
if (!container) {
ESP_LOGE(TAG, "Container is null");
return ESP_ERR_INVALID_ARG;
}
_container = container;
ESP_LOGI(TAG, "Initializing shutdown app with message: %s", _message.c_str());
// Main message label
_label_message = lv_label_create(_container);
lv_label_set_text(_label_message, _message.c_str());
lv_obj_set_style_text_color(_label_message, lv_color_white(), 0);
lv_obj_align(_label_message, LV_ALIGN_CENTER, 0, 0);
// Optional: Add spinner animation
lv_obj_t* spinner = lv_spinner_create(_container);
lv_obj_set_size(spinner, 80, 80);
lv_obj_align(spinner, LV_ALIGN_CENTER, 0, 80);
lv_obj_set_style_arc_color(spinner, lv_color_white(), LV_PART_INDICATOR);
ESP_LOGI(TAG, "Shutdown app initialized successfully");
return ESP_OK;
}
esp_err_t ShutdownApp::deinit(void) {
ESP_LOGI(TAG, "Deinitializing shutdown app");
_label_message = nullptr;
return ESP_OK;
}
std::string ShutdownApp::get_name(void) const {
return "Shutdown";
}
// ShutdownAppDescriptor implementation
ShutdownApp* ShutdownAppDescriptor::_app_instance = nullptr;
ShutdownAppDescriptor::ShutdownAppDescriptor()
: AppDescriptor("Shutdown", nullptr) {
// Create singleton app instance with default message
if (!_app_instance) {
_app_instance = new ShutdownApp();
}
// it's only used during system shutdown, not as a user-launchable app
}
void ShutdownAppDescriptor::draw_icon(lv_obj_t* parent) {
// Create a simple icon (not normally shown in nav bar)
lv_obj_t* icon_label = lv_label_create(parent);
lv_label_set_text(icon_label, LV_SYMBOL_POWER "\nShutdown");
lv_obj_set_style_text_color(icon_label, lv_color_white(), 0);
lv_obj_set_style_text_align(icon_label, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_center(icon_label);
}

View File

@@ -1,39 +0,0 @@
#pragma once
#include "ui/ui_app.h"
#include "ui/app_registry.h"
/**
* @brief Shutdown application - displays shutdown message
*
* Shown when the system is about to enter deep sleep or power off.
* Displays a message and optionally a spinner animation.
*/
class ShutdownApp : public UIApp {
public:
ShutdownApp(std::string message = "");
virtual ~ShutdownApp() = default;
esp_err_t init(lv_obj_t* container) override;
esp_err_t deinit(void) override;
std::string get_name(void) const override;
private:
std::string _message;
lv_obj_t* _label_message = nullptr;
};
/**
* @brief AppDescriptor for ShutdownApp
*
* Note: Shutdown app is typically not shown in the navigation bar
* as it's only used during system shutdown.
*/
class ShutdownAppDescriptor : public AppDescriptor {
public:
ShutdownAppDescriptor();
void draw_icon(lv_obj_t* parent) override;
private:
static ShutdownApp* _app_instance;
};

4
main/ui/events.cpp Normal file
View File

@@ -0,0 +1,4 @@
#include "events.h"
// Define the event base
ESP_EVENT_DEFINE_BASE(UI_EVENT_BASE);

15
main/ui/events.h Normal file
View File

@@ -0,0 +1,15 @@
#pragma once
#include "esp_event.h"
#include "lvgl.h"
ESP_EVENT_DECLARE_BASE(UI_EVENT_BASE);
struct KeyboardEventData {
lv_obj_t* textarea; ///< The textarea that triggered the keyboard event, nullptr if not applicable or for hide event
};
enum EventId {
UI_EVENT_KEYBOARD_SHOWN = 1, ///< Event ID for keyboard shown event
UI_EVENT_KEYBOARD_HIDDEN = 2 ///< Event ID for keyboard hidden event
};

View File

@@ -0,0 +1,159 @@
#include "ui/interaction_handler.h"
#include "ui/events.h"
#include "esp_err.h"
#include "esp_log.h"
#define TAG "InteractionHandler"
InteractionHandler::~InteractionHandler() {
esp_err_t err = deinit();
if (err != ESP_OK) {
ESP_LOGE(TAG, "Error during InteractionHandler deinit: %s", esp_err_to_name(err));
}
}
esp_err_t InteractionHandler::init(lv_obj_t* app_container) {
if (!app_container) {
ESP_LOGE(TAG, "Invalid argument: app_container is nullptr");
return ESP_ERR_INVALID_ARG;
}
app_container_ = app_container;
keyboard_ = lv_keyboard_create(app_container_);
lv_obj_add_flag(keyboard_, LV_OBJ_FLAG_HIDDEN); // start hidden
lv_obj_add_event_cb(
keyboard_,
[](lv_event_t* e) {
InteractionHandler* handler = static_cast<InteractionHandler*>(lv_event_get_user_data(e));
handler->on_keyboard_event_(e);
}
, LV_EVENT_ALL, this);
return ESP_OK;
}
esp_err_t InteractionHandler::deinit(void) {
if (keyboard_) {
lv_obj_del(keyboard_);
keyboard_ = nullptr;
}
return ESP_OK;
}
esp_err_t InteractionHandler::register_text_area_keyboard_support(lv_obj_t* text_area) {
if (!text_area) {
ESP_LOGE(TAG, "Invalid argument: text_area is nullptr");
return ESP_ERR_INVALID_ARG;
}
lv_obj_add_event_cb(
text_area,
[](lv_event_t* e) {
lv_event_code_t code = lv_event_get_code(e);
if (code != LV_EVENT_FOCUSED) {
return;
}
InteractionHandler* handler = static_cast<InteractionHandler*>(lv_event_get_user_data(e));
esp_err_t err = handler->show_keyboard_for_textarea_(static_cast<lv_obj_t*>(lv_event_get_target(e)));
if (err != ESP_OK) {
ESP_LOGE(TAG, "Failed to show keyboard: %s", esp_err_to_name(err));
}
}
, LV_EVENT_FOCUSED, this);
lv_obj_add_event_cb(
text_area,
[](lv_event_t* e) {
lv_event_code_t code = lv_event_get_code(e);
if (code != LV_EVENT_DEFOCUSED) {
return;
}
InteractionHandler* handler = static_cast<InteractionHandler*>(lv_event_get_user_data(e));
esp_err_t err = handler->hide_keyboard_();
if (err != ESP_OK) {
ESP_LOGE(TAG, "Failed to hide keyboard: %s", esp_err_to_name(err));
}
}
, LV_EVENT_DEFOCUSED, this);
return ESP_OK;
}
//
// Private methods
//
void InteractionHandler::on_keyboard_event_(lv_event_t* e) {
lv_event_code_t code = lv_event_get_code(e);
if (code == LV_EVENT_READY || code == LV_EVENT_CANCEL) {
// Keyboard is cancelled
esp_err_t err = hide_keyboard_();
if (err != ESP_OK) {
ESP_LOGE(TAG, "Failed to hide keyboard: %s", esp_err_to_name(err));
}
if (focused_textarea_) {
lv_obj_clear_state(focused_textarea_, LV_STATE_FOCUSED);
lv_keyboard_set_textarea(keyboard_, nullptr);
focused_textarea_ = nullptr;
}
}
}
esp_err_t InteractionHandler::show_keyboard_for_textarea_(lv_obj_t* textarea) {
if (!keyboard_ || !textarea) {
ESP_LOGE(TAG, "Invalid state or argument in show_keyboard_for_textarea_");
return ESP_ERR_INVALID_ARG;
}
focused_textarea_ = textarea;
lv_keyboard_set_textarea(keyboard_, textarea);
lv_obj_clear_flag(keyboard_, LV_OBJ_FLAG_HIDDEN);
// emit keyboard shown event
KeyboardEventData event_data = {
.textarea = textarea
};
esp_err_t err = esp_event_post_to(
NULL,
UI_EVENT_BASE,
UI_EVENT_KEYBOARD_SHOWN,
&event_data,
sizeof(event_data),
portMAX_DELAY
);
if (err != ESP_OK) {
ESP_LOGE(TAG, "Failed to post keyboard shown event: %s", esp_err_to_name(err));
}
return ESP_OK;
}
esp_err_t InteractionHandler::hide_keyboard_(void) {
if (!keyboard_) {
return ESP_ERR_INVALID_STATE;
}
lv_obj_add_flag(keyboard_, LV_OBJ_FLAG_HIDDEN);
// emit keyboard hidden event
KeyboardEventData event_data = {
.textarea = nullptr
};
esp_err_t err = esp_event_post_to(
NULL,
UI_EVENT_BASE,
UI_EVENT_KEYBOARD_HIDDEN,
&event_data,
sizeof(event_data),
portMAX_DELAY
);
if (err != ESP_OK) {
ESP_LOGE(TAG, "Failed to post keyboard hidden event: %s", esp_err_to_name(err));
}
return ESP_OK;
}

View File

@@ -0,0 +1,70 @@
#pragma once
#include "esp_err.h"
#include "lvgl.h"
#include "ui/events.h"
/**
* @brief Interaction Handler - manages user interactions
*
* This class is responsible for handling user inputs
* such as touch events, button presses, and gestures.
* It routes these interactions to the appropriate UI components
* or apps based on the current context. And it also handles the respective UI widgets.
*
* For example, it manages:
* Textarea focus and display of the on-screen keyboard
*/
class InteractionHandler {
public:
InteractionHandler() = default;
~InteractionHandler();
/**
* @brief Initialize the Interaction Handler
*
* Sets up necessary event listeners and state.
*
* @return ESP_OK on success, error code otherwise
*/
esp_err_t init(lv_obj_t* app_container);
/**
* @brief Deinitialize the Interaction Handler
*
* Cleans up resources and event listeners.
*
* @return ESP_OK on success, error code otherwise
*/
esp_err_t deinit(void);
/**
* @brief Add keyboard support to a textarea widget
*
* @param text_area Pointer to the textarea lvgl object
* @return esp_err_t ESP_OK on success, error code otherwise
*/
esp_err_t register_text_area_keyboard_support(lv_obj_t* text_area);
private:
// Event handler for keyboard show/hide events
// It should be registered with event callbacks of the keyboard object
void on_keyboard_event_(lv_event_t* e);
esp_err_t show_keyboard_for_textarea_(lv_obj_t* textarea);
esp_err_t hide_keyboard_(void);
// Pointers to key UI objects, owned by UIHandler
lv_obj_t* app_container_ = nullptr;
// owned keyboard object
lv_obj_t* keyboard_ = nullptr;
// Currently focused textarea, reference only
lv_obj_t* focused_textarea_ = nullptr;
InteractionHandler(const InteractionHandler&) = delete;
InteractionHandler& operator=(const InteractionHandler&) = delete;
};

View File

@@ -1,115 +0,0 @@
#include "page_stack.h"
#include "esp_log.h"
static const char* TAG = "PageStack";
PageStack::PageStack(lv_obj_t* parent_container)
: parent_container_(parent_container) {
if (!parent_container_) {
ESP_LOGE(TAG, "Parent container is null");
}
}
PageStack::~PageStack() {
clear();
}
lv_obj_t* PageStack::create_page_container() {
lv_obj_t* page = lv_obj_create(parent_container_);
// Fill parent container
lv_obj_set_size(page, LV_PCT(100), LV_PCT(100));
lv_obj_set_pos(page, 0, 0);
// Remove padding and scrollbars
lv_obj_set_style_pad_all(page, 0, 0);
lv_obj_set_scrollbar_mode(page, LV_SCROLLBAR_MODE_OFF);
// White background
lv_obj_set_style_bg_color(page, lv_color_white(), 0);
lv_obj_set_style_bg_opa(page, LV_OPA_COVER, 0);
// Remove border
lv_obj_set_style_border_width(page, 0, 0);
return page;
}
lv_obj_t* PageStack::push(PageBuilder builder, PageCleanup cleanup) {
if (!parent_container_) {
ESP_LOGE(TAG, "Cannot push page: parent container is null");
return nullptr;
}
if (!builder) {
ESP_LOGE(TAG, "Cannot push page: builder is null");
return nullptr;
}
// Hide current page if any
if (!pages_.empty()) {
lv_obj_add_flag(pages_.back().container, LV_OBJ_FLAG_HIDDEN);
}
// Create new page container
lv_obj_t* page = create_page_container();
// Build page content
builder(page);
// Add to stack
pages_.push_back({page, cleanup});
ESP_LOGD(TAG, "Pushed page (depth: %d)", pages_.size());
return page;
}
bool PageStack::pop() {
if (pages_.empty()) {
ESP_LOGW(TAG, "Cannot pop: stack is empty");
return false;
}
// Get and remove current page
Page current = pages_.back();
pages_.pop_back();
// Call cleanup callback if provided
if (current.cleanup) {
current.cleanup(current.container);
}
// Delete page container
lv_obj_del(current.container);
// Show previous page if any
if (!pages_.empty()) {
lv_obj_clear_flag(pages_.back().container, LV_OBJ_FLAG_HIDDEN);
}
ESP_LOGD(TAG, "Popped page (depth: %d)", pages_.size());
return true;
}
void PageStack::clear() {
ESP_LOGD(TAG, "Clearing all pages (depth: %d)", pages_.size());
// Pop all pages (calls cleanup callbacks)
while (!pages_.empty()) {
Page current = pages_.back();
pages_.pop_back();
if (current.cleanup) {
current.cleanup(current.container);
}
lv_obj_del(current.container);
}
}
lv_obj_t* PageStack::current_page() const {
if (pages_.empty()) {
return nullptr;
}
return pages_.back().container;
}

View File

@@ -1,86 +0,0 @@
#pragma once
#include "lvgl.h"
#include <vector>
#include <functional>
/**
* @brief Reusable page stack for multi-page navigation within LVGL apps
*
* Manages a stack of LVGL containers, allowing apps to push/pop pages
* and implement hierarchical navigation. Thread-safe for use with LVGL.
*/
class PageStack {
public:
/**
* @brief Page builder callback
* @param page_container The LVGL container to build the page in
*/
using PageBuilder = std::function<void(lv_obj_t* page_container)>;
/**
* @brief Page cleanup callback
* @param page_container The LVGL container being destroyed
*/
using PageCleanup = std::function<void(lv_obj_t* page_container)>;
/**
* @brief Construct page stack with parent container
* @param parent_container Parent LVGL container for pages
*/
explicit PageStack(lv_obj_t* parent_container);
/**
* @brief Destructor - clears all pages
*/
~PageStack();
/**
* @brief Push a new page onto the stack
* @param builder Function to build page content
* @param cleanup Optional cleanup function called when page is popped
* @return The created page container
*/
lv_obj_t* push(PageBuilder builder, PageCleanup cleanup = nullptr);
/**
* @brief Pop the current page and return to previous
* @return true if page was popped, false if stack is empty
*/
bool pop();
/**
* @brief Clear all pages from the stack
*/
void clear();
/**
* @brief Get the current (top) page container
* @return Current page or nullptr if stack is empty
*/
lv_obj_t* current_page() const;
/**
* @brief Get the number of pages in the stack
*/
size_t depth() const { return pages_.size(); }
/**
* @brief Check if stack is empty
*/
bool empty() const { return pages_.empty(); }
private:
struct Page {
lv_obj_t* container;
PageCleanup cleanup;
};
lv_obj_t* parent_container_;
std::vector<Page> pages_;
/**
* @brief Create a page container
*/
lv_obj_t* create_page_container();
};

View File

@@ -1,123 +1,93 @@
#include "ui/root_layout.h"
#include "ui/ui_handler.h"
#include "ui/app_registry.h"
#include "ui/events.h"
#include "esp_log.h"
#include "esp_event.h"
#define TAG "RootLayout"
// Display dimensions
#define DISPLAY_WIDTH 800
#define DISPLAY_HEIGHT 480
// Layout dimensions
#define HEADER_HEIGHT 40
#define NAV_BAR_HEIGHT 50
#define APP_CONTAINER_HEIGHT (DISPLAY_HEIGHT - HEADER_HEIGHT - NAV_BAR_HEIGHT)
// forward-declare local event callback
static void on_home_button_clicked(lv_event_t* event);
RootLayout::RootLayout(UIHandler* ui_handler)
: _ui_handler(ui_handler) { }
esp_err_t RootLayout::init(lv_obj_t* parent) {
if (!parent) {
ESP_LOGE(TAG, "Parent object is null");
return ESP_ERR_INVALID_ARG;
}
ESP_LOGI(TAG, "Initializing RootLayout");
if (create_layout(parent) != ESP_OK) {
ESP_LOGE(TAG, "Failed to create layout");
return ESP_FAIL;
}
ESP_LOGI(TAG, "RootLayout initialized successfully");
return ESP_OK;
RootLayout::~RootLayout() {
deinit();
}
esp_err_t RootLayout::deinit(void) {
ESP_LOGI(TAG, "Deinitializing RootLayout");
esp_err_t RootLayout::init(lv_obj_t* parent, UIHandler* ui_handler) {
// LVGL will handle cleanup when parent is destroyed
_header = nullptr;
_header_label = nullptr;
_app_container = nullptr;
_nav_bar = nullptr;
_back_button = nullptr;
return ESP_OK;
}
esp_err_t RootLayout::create_layout(lv_obj_t* parent) {
// Configure parent as flexbox column layout
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(parent, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START);
lv_obj_set_style_pad_all(parent, 0, 0);
lv_obj_set_style_pad_gap(parent, 0, 0);
//
// Create header (top, fixed height)
_header = lv_obj_create(parent);
lv_obj_set_width(_header, lv_pct(100));
lv_obj_set_height(_header, HEADER_HEIGHT);
lv_obj_set_style_bg_color(_header, lv_color_hex(0xFFFFFF), 0);
lv_obj_set_style_border_width(_header, 0, 0);
lv_obj_set_style_border_color(_header, lv_color_hex(0x000000), 0);
lv_obj_set_style_border_width(_header, 1, LV_BORDER_SIDE_BOTTOM);
lv_obj_set_style_pad_all(_header, 0, 0);
lv_obj_set_style_radius(_header, 0, 0);
_header_label = lv_label_create(_header);
lv_label_set_text(_header_label, "App");
lv_obj_set_style_text_color(_header_label, lv_color_black(), 0);
lv_obj_align(_header_label, LV_ALIGN_LEFT_MID, 10, 0);
// Create app container (middle, flexible - grows to fill available space)
_app_container = lv_obj_create(parent);
lv_obj_set_width(_app_container, lv_pct(100));
lv_obj_set_flex_grow(_app_container, 1);
lv_obj_set_style_bg_color(_app_container, lv_color_white(), 0);
lv_obj_set_style_border_width(_app_container, 0, 0);
lv_obj_set_style_pad_all(_app_container, 0, 0);
lv_obj_set_style_radius(_app_container, 0, 0);
header_obj_ = lv_obj_create(parent);
lv_obj_set_width(header_obj_, lv_pct(100));
lv_obj_set_height(header_obj_, HEADER_HEIGHT);
lv_obj_set_style_bg_color(header_obj_, lv_color_white(), 0);
lv_obj_set_style_border_width(header_obj_, 0, 0);
lv_obj_set_style_border_color(header_obj_, lv_color_black(), 0);
lv_obj_set_style_border_width(header_obj_, 1, LV_BORDER_SIDE_BOTTOM);
lv_obj_set_style_pad_all(header_obj_, 0, 0);
lv_obj_set_style_radius(header_obj_, 0, 0);
//
header_label_ = lv_label_create(header_obj_);
lv_label_set_text(header_label_, "App");
lv_obj_set_style_text_color(header_label_, lv_color_black(), 0);
lv_obj_align(header_label_, LV_ALIGN_LEFT_MID, 10, 0);
//
// Create app container (middle, flexible height)
app_container_ = lv_obj_create(parent);
lv_obj_set_width(app_container_, lv_pct(100));
lv_obj_set_flex_grow(app_container_, 1);
lv_obj_set_style_bg_color(app_container_, lv_color_white(), 0);
lv_obj_set_style_border_width(app_container_, 0, 0);
lv_obj_set_style_pad_all(app_container_, 0, 0);
lv_obj_set_style_radius(app_container_, 0, 0);
//
// Create navigation bar (bottom, fixed height)
_nav_bar = lv_obj_create(parent);
lv_obj_set_width(_nav_bar, lv_pct(100));
lv_obj_set_height(_nav_bar, NAV_BAR_HEIGHT);
lv_obj_set_style_bg_color(_nav_bar, lv_color_hex(0xFFFFFF), 0);
lv_obj_set_style_border_color(_nav_bar, lv_color_hex(0x000000), 0);
lv_obj_set_style_border_width(_nav_bar, 1, LV_BORDER_SIDE_TOP);
lv_obj_set_style_pad_all(_nav_bar, 5, 0);
lv_obj_set_style_radius(_nav_bar, 0, 0);
nav_bar_obj_ = lv_obj_create(parent);
lv_obj_set_width(nav_bar_obj_, lv_pct(100));
lv_obj_set_height(nav_bar_obj_, NAV_BAR_HEIGHT);
lv_obj_set_style_bg_color(nav_bar_obj_, lv_color_white(), 0);
lv_obj_set_style_border_color(nav_bar_obj_, lv_color_black(), 0);
lv_obj_set_style_border_width(nav_bar_obj_, 1, LV_BORDER_SIDE_TOP);
lv_obj_set_style_pad_all(nav_bar_obj_, 5, 0);
lv_obj_set_style_radius(nav_bar_obj_, 0, 0);
// Configure nav bar as flexbox row layout with space-between
lv_obj_set_flex_flow(_nav_bar, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(_nav_bar, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
// Create back button (aligned to start by flex layout)
_back_button = lv_btn_create(_nav_bar);
lv_obj_set_size(_back_button, 60, NAV_BAR_HEIGHT - 10);
lv_obj_set_style_bg_color(_back_button, lv_color_hex(0x555555), 0);
lv_obj_add_event_cb(_back_button, on_back_button_clicked, LV_EVENT_CLICKED, _ui_handler);
lv_obj_add_flag(_back_button, LV_OBJ_FLAG_HIDDEN);
// Add back arrow label
lv_obj_t* back_label = lv_label_create(_back_button);
back_button_ = lv_btn_create(nav_bar_obj_);
lv_obj_set_size(back_button_, 60, NAV_BAR_HEIGHT - 10);
lv_obj_set_style_bg_color(back_button_, lv_color_white(), 0);
lv_obj_add_flag(back_button_, LV_OBJ_FLAG_HIDDEN);
lv_obj_t* back_label = lv_label_create(back_button_);
lv_label_set_text(back_label, LV_SYMBOL_LEFT);
lv_obj_set_style_text_color(back_label, lv_color_black(), 0);
lv_obj_align(back_label, LV_ALIGN_CENTER, 0, 0);
// Create home button (aligned to end by flex layout)
lv_obj_t* home_button = lv_btn_create(_nav_bar);
lv_obj_set_size(home_button, 60, NAV_BAR_HEIGHT - 10);
lv_obj_set_style_bg_color(home_button, lv_color_hex(0x555555), 0);
lv_obj_t* home_label = lv_label_create(home_button);
home_button_ = lv_btn_create(nav_bar_obj_);
lv_obj_set_size(home_button_, 60, NAV_BAR_HEIGHT - 10);
lv_obj_set_style_bg_color(home_button_, lv_color_white(), 0);
lv_obj_t* home_label = lv_label_create(home_button_);
lv_label_set_text(home_label, LV_SYMBOL_HOME);
lv_obj_set_style_text_color(home_label, lv_color_white(), 0);
lv_obj_set_style_text_color(home_label, lv_color_black(), 0);
lv_obj_align(home_label, LV_ALIGN_CENTER, 0, 0);
lv_obj_add_event_cb(home_button, on_home_button_clicked, LV_EVENT_CLICKED, _ui_handler);
// Register keyboard event handler
esp_err_t err = esp_event_handler_instance_register(
UI_EVENT_BASE,
ESP_EVENT_ANY_ID,
[](void* handler_args, esp_event_base_t base, int32_t id, void* event_data) {
RootLayout* root_layout = static_cast<RootLayout*>(handler_args);
root_layout->on_keyboard_event_(handler_args, base, id, event_data);
},
this,
&keyboard_event_handler_instance_
);
if (err != ESP_OK) {
ESP_LOGE(TAG, "Failed to register keyboard event handler: %s", esp_err_to_name(err));
}
ESP_LOGI(TAG, "Layout created with flexible design: Header=%d, NavBar=%d",
HEADER_HEIGHT, NAV_BAR_HEIGHT);
@@ -125,140 +95,155 @@ esp_err_t RootLayout::create_layout(lv_obj_t* parent) {
return ESP_OK;
}
void RootLayout::update_header(std::string app_name) {
if (!_header_label) {
return;
esp_err_t RootLayout::deinit(void) {
// Unregister keyboard event handler
if (keyboard_event_handler_instance_) {
esp_event_handler_instance_unregister(
UI_EVENT_BASE,
ESP_EVENT_ANY_ID,
keyboard_event_handler_instance_
);
keyboard_event_handler_instance_ = nullptr;
}
if (app_name.empty() == false) {
lv_label_set_text(_header_label, app_name.c_str());
header_obj_ = nullptr;
header_label_ = nullptr;
//
app_container_ = nullptr;
//
nav_bar_obj_ = nullptr;
back_button_ = nullptr;
home_button_ = nullptr;
return ESP_OK;
}
void RootLayout::hide_nav_bar(void) const {
if (nav_bar_obj_) {
lv_obj_add_flag(nav_bar_obj_, LV_OBJ_FLAG_HIDDEN);
} else {
lv_label_set_text(_header_label, "App");
ESP_LOGW(TAG, "Navigation bar not initialized");
}
}
esp_err_t RootLayout::render_app_icons(void) {
if (!_nav_bar) {
ESP_LOGE(TAG, "Navigation bar not initialized");
void RootLayout::show_nav_bar(void) const {
if (nav_bar_obj_) {
lv_obj_clear_flag(nav_bar_obj_, LV_OBJ_FLAG_HIDDEN);
} else {
ESP_LOGW(TAG, "Navigation bar not initialized");
}
}
void RootLayout::show_back_button(void) const {
if (back_button_) {
lv_obj_clear_flag(back_button_, LV_OBJ_FLAG_HIDDEN);
} else {
ESP_LOGW(TAG, "Back button not initialized");
}
}
void RootLayout::show_home_button(void) const {
if (home_button_) {
lv_obj_clear_flag(home_button_, LV_OBJ_FLAG_HIDDEN);
} else {
ESP_LOGW(TAG, "Home button not found in navigation bar");
}
}
void RootLayout::hide_back_button(void) const {
if (back_button_) {
lv_obj_add_flag(back_button_, LV_OBJ_FLAG_HIDDEN);
} else {
ESP_LOGW(TAG, "Back button not initialized");
}
}
void RootLayout::hide_home_button(void) const {
if (home_button_) {
lv_obj_add_flag(home_button_, LV_OBJ_FLAG_HIDDEN);
} else {
ESP_LOGW(TAG, "Home button not found in navigation bar");
}
}
esp_err_t RootLayout::register_back_button_callback(lv_event_cb_t callback, void* user_data, lv_event_dsc_t** out_event_dsc) const {
if (!back_button_) {
ESP_LOGE(TAG, "Back button not initialized");
return ESP_ERR_INVALID_STATE;
}
if (!callback) {
ESP_LOGE(TAG, "Invalid argument: callback is nullptr");
return ESP_ERR_INVALID_ARG;
}
if (out_event_dsc == nullptr) {
ESP_LOGE(TAG, "Invalid argument: out_event_dsc is nullptr");
return ESP_ERR_INVALID_ARG;
}
*out_event_dsc = lv_obj_add_event_cb(back_button_, callback, LV_EVENT_CLICKED, user_data);
if (*out_event_dsc == nullptr) {
ESP_LOGE(TAG, "Failed to register back button callback");
return ESP_FAIL;
}
// Clear existing app container content (icons are rendered in the app area)
if (!_app_container) {
ESP_LOGE(TAG, "App container not initialized");
return ESP_FAIL;
}
lv_obj_clean(_app_container);
// Get all registered apps from registry
const auto& app_descriptors = AppRegistry::instance().get_app_descriptors();
if (app_descriptors.empty()) {
ESP_LOGW(TAG, "No apps registered in AppRegistry");
lv_obj_t* nav_label = lv_label_create(_nav_bar);
lv_label_set_text(nav_label, "No apps available");
lv_obj_set_style_text_color(nav_label, lv_color_white(), 0);
lv_obj_align(nav_label, LV_ALIGN_CENTER, 0, 0);
return ESP_OK;
}
ESP_LOGI(TAG, "Rendering %d app icons", (int)app_descriptors.size());
// Calculate icon spacing inside the app container
int icon_count = app_descriptors.size();
int icon_width = 96;
int icon_height = 96;
int icon_spacing = DISPLAY_WIDTH / (icon_count + 1);
int x_offset = icon_spacing;
int y_offset = (APP_CONTAINER_HEIGHT - icon_height) / 2;
// Render each app icon into the app container
for (size_t i = 0; i < app_descriptors.size(); i++) {
AppDescriptor* descriptor = app_descriptors[i];
lv_obj_t* icon_container = lv_obj_create(_app_container);
lv_obj_set_size(icon_container, icon_width, icon_height);
lv_obj_set_pos(icon_container, x_offset - icon_width / 2, y_offset);
lv_obj_set_style_bg_opa(icon_container, LV_OPA_TRANSP, 0);
lv_obj_set_style_pad_all(icon_container, 0, 0);
// add a border for debugging
lv_obj_set_style_border_color(icon_container, lv_color_hex(0x000000), 0);
lv_obj_set_style_border_width(icon_container, 1, 0);
lv_obj_set_user_data(icon_container, descriptor);
descriptor->draw_icon(icon_container);
lv_obj_add_flag(icon_container, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_event_cb(icon_container, on_app_icon_clicked, LV_EVENT_CLICKED, _ui_handler);
x_offset += icon_spacing;
}
return ESP_OK;
}
void RootLayout::show_back_button(void) {
if (_back_button) {
lv_obj_clear_flag(_back_button, LV_OBJ_FLAG_HIDDEN);
esp_err_t RootLayout::register_home_button_callback(lv_event_cb_t callback, void* user_data, lv_event_dsc_t** out_event_dsc) const {
if (!home_button_) {
ESP_LOGE(TAG, "Home button not found in navigation bar");
return ESP_ERR_NOT_FOUND;
}
if (!callback) {
ESP_LOGE(TAG, "Invalid argument: callback is nullptr");
return ESP_ERR_INVALID_ARG;
}
if (out_event_dsc == nullptr) {
ESP_LOGE(TAG, "Invalid argument: out_event_dsc is nullptr");
return ESP_ERR_INVALID_ARG;
}
*out_event_dsc = lv_obj_add_event_cb(home_button_, callback, LV_EVENT_CLICKED, user_data);
if (*out_event_dsc == nullptr) {
ESP_LOGE(TAG, "Failed to register home button callback");
return ESP_FAIL;
}
return ESP_OK;
}
void RootLayout::hide_back_button(void) {
if (_back_button) {
lv_obj_add_flag(_back_button, LV_OBJ_FLAG_HIDDEN);
}
}
void RootLayout::on_app_icon_clicked(lv_event_t* event) {
// Use the current target (the object the callback was attached to)
// instead of the event target, which may be a child (like a label).
lv_obj_t* icon_container = static_cast<lv_obj_t*>(lv_event_get_current_target(event));
UIHandler* handler = static_cast<UIHandler*>(lv_event_get_user_data(event));
AppDescriptor* descriptor = static_cast<AppDescriptor*>(lv_obj_get_user_data(icon_container));
if (!handler || !descriptor) {
ESP_LOGE(TAG, "Invalid event data in app icon click");
return;
esp_err_t RootLayout::update_header(const std::string& title) const {
if (!header_label_) {
return ESP_ERR_INVALID_STATE;
}
ESP_LOGI(TAG, "App icon clicked: %s", descriptor->get_name().c_str());
handler->switch_app(descriptor);
}
void RootLayout::on_back_button_clicked(lv_event_t* event) {
UIHandler* handler = static_cast<UIHandler*>(lv_event_get_user_data(event));
if (!handler) {
ESP_LOGE(TAG, "Invalid handler in back button click");
return;
}
// Get the active app
UIApp* active_app = handler->get_active_app();
if (!active_app) {
ESP_LOGW(TAG, "Back button pressed but no active app");
return;
}
// Let the app handle the back button press
bool handled = active_app->on_back_button_pressed();
if (handled) {
ESP_LOGI(TAG, "Back button handled by app: %s", active_app->get_name());
if (title.empty() == false) {
lv_label_set_text(header_label_, title.c_str());
} else {
ESP_LOGI(TAG, "Back button not handled by app, returning to main screen");
handler->return_to_main_screen();
lv_label_set_text(header_label_, "App");
}
return ESP_OK;
}
static void on_home_button_clicked(lv_event_t* event) {
UIHandler* handler = static_cast<UIHandler*>(lv_event_get_user_data(event));
if (!handler) {
ESP_LOGE(TAG, "Invalid handler in home button click");
void RootLayout::on_keyboard_event_(void* handler_args, esp_event_base_t base, int32_t id, void* event_data) {
if (base != UI_EVENT_BASE) {
return;
}
handler->return_to_main_screen();
switch (id) {
case UI_EVENT_KEYBOARD_SHOWN:
hide_nav_bar();
break;
case UI_EVENT_KEYBOARD_HIDDEN:
show_nav_bar();
break;
default:
ESP_LOGW(TAG, "Unknown keyboard event ID: %ld", id);
break;
}
}

View File

@@ -1,138 +1,126 @@
#pragma once
#include "lvgl.h"
#include "esp_err.h"
#include "esp_event.h"
#include "lvgl.h"
#include <string>
// Forward declaration
// Forward declaration to avoid circular dependency
class UIHandler;
/**
* @brief Root Layout Manager - manages the main screen layout
*
* The RootLayout class is responsible for:
* - Creating and managing the main screen structure (header, app container, nav bar)
* - Rendering app icons from the AppRegistry
* - Managing the back button
* - Updating header content
*/
class RootLayout {
public:
/**
* @brief Construct a new RootLayout object
*
* @param ui_handler Pointer to the UIHandler (for callbacks)
*/
RootLayout(UIHandler* ui_handler);
RootLayout() = default;
~RootLayout();
/**
* @brief Initialize the layout
* @brief Initialize the root layout within the given parent object
*
* Creates the main screen with header, app container, and navigation bar.
* Sets up the header, app container, and navigation bar.
*
* @param parent Parent LVGL object to attach layout to
* @param parent Parent LVGL object to contain the layout
* @return ESP_OK on success, error code otherwise
*/
esp_err_t init(lv_obj_t* parent);
esp_err_t init(lv_obj_t* parent, UIHandler* ui_handler);
/**
* @brief Deinitialize the layout
* @brief Deinitialize the root layout
*
* Cleans up all layout widgets.
* Cleans up references to layout components.
*
* @return ESP_OK on success, error code otherwise
*/
esp_err_t deinit(void);
/**
* @brief Render app icons in the navigation bar
* @brief Show the back button in the navigation bar
*/
void show_back_button(void) const;
/**
* @brief Hide the back button in the navigation bar
*/
void hide_back_button(void) const;
/**
* @brief Show the home button in the navigation bar
*/
void show_home_button(void) const;
/**
* @brief Hide the home button in the navigation bar
*/
void hide_home_button(void) const;
/**
* @brief Show navigation bar
*
* Queries the AppRegistry for all registered apps and
* renders their icons in the navigation bar. Also creates
* the back button.
*/
void show_nav_bar(void) const;
/**
* @brief Hide navigation bar
*
*/
void hide_nav_bar(void) const;
/**
* @brief Register a callback for back button presses
*
*
* @param callback
* @param user_data
* @param out_event_dsc
* @return ESP_OK on success, error code otherwise
*/
esp_err_t render_app_icons(void);
esp_err_t register_back_button_callback(lv_event_cb_t callback, void* user_data, lv_event_dsc_t** out_event_dsc) const;
/**
* @brief Update header with app name
* @brief Register a callback for home button presses
*
* @param app_name Name to display in header (nullptr for default)
* @param callback
* @param user_data
* @param out_event_dsc
* @return ESP_OK on success, error code otherwise
*/
void update_header(std::string app_name);
esp_err_t register_home_button_callback(lv_event_cb_t callback, void* user_data, lv_event_dsc_t** out_event_dsc) const;
/**
* @brief Show the back button
*/
void show_back_button(void);
/**
* @brief Hide the back button
*/
void hide_back_button(void);
/**
* @brief Get the header object
* @brief Update the header title text
*
* @return lv_obj_t* pointer to the header container
* @param title New title text
* @return ESP_OK on success, error code otherwise
*/
lv_obj_t* get_header(void) const {
return _header;
}
esp_err_t update_header(const std::string& title) const;
/**
* @brief Get the app container (where apps render)
* @brief Get the app container object, which holds the active app's UI
* Caller can add/remove app UI elements to/from this container.
* Caller must not delete this object directly or edit its layout properties.
*
* @return lv_obj_t* pointer to the app container
* @return lv_obj_t*
*/
lv_obj_t* get_app_container(void) const {
return _app_container;
}
/**
* @brief Get the navigation bar object
*
* @return lv_obj_t* pointer to the navigation bar container
*/
lv_obj_t* get_nav_bar(void) const {
return _nav_bar;
lv_obj_t* get_app_container() const {
return app_container_;
}
private:
UIHandler* _ui_handler = nullptr; ///< Reference to UIHandler for callbacks
lv_obj_t* _header = nullptr; ///< Header area (top)
lv_obj_t* _header_label = nullptr; ///< Header text label
lv_obj_t* _app_container = nullptr; ///< Container for app widgets (middle)
lv_obj_t* _nav_bar = nullptr; ///< Navigation bar (bottom)
lv_obj_t* _back_button = nullptr; ///< Back button in navigation bar
/**
* @brief Create the layout structure
*
* Sets up header, app container, and navigation bar with
* appropriate dimensions and positioning.
*
* @param parent Parent object to attach layout to
* @return ESP_OK on success, error code otherwise
*/
esp_err_t create_layout(lv_obj_t* parent);
// Event handler for keyboard show/hide events
void on_keyboard_event_(void* handler_args, esp_event_base_t base, int32_t id, void* event_data);
/**
* @brief Handle app icon click event
*
* Static callback for LVGL event handling.
*
* @param event LVGL event object
*/
static void on_app_icon_clicked(lv_event_t* event);
// layout objects
// header
lv_obj_t* header_obj_ = nullptr; ///< Header area object
lv_obj_t* header_label_ = nullptr; ///< Header title label
// app container
lv_obj_t* app_container_ = nullptr; ///< App container object
// navigation bar
lv_obj_t* nav_bar_obj_ = nullptr; ///< Navigation bar object
lv_obj_t* back_button_ = nullptr; ///< Back button object
lv_obj_t* home_button_ = nullptr; ///< Home button object
/**
* @brief Handle back button click event
*
* Static callback for LVGL event handling.
*
* @param event LVGL event object
*/
static void on_back_button_clicked(lv_event_t* event);
esp_event_handler_instance_t keyboard_event_handler_instance_ = nullptr; ///< Event handler instance for keyboard events
};

View File

@@ -1,208 +1,288 @@
#include "ui/ui_handler.h"
#include "ui/root_layout.h"
#include "ui/app_registry.h"
#include "esp_log.h"
#include "lvgl.h"
#define TAG "UIHandler"
// Display dimensions from constants.h
#define DISPLAY_WIDTH 800
#define DISPLAY_HEIGHT 480
// Layout dimensions
#define HEADER_HEIGHT 40
#define NAV_BAR_HEIGHT 50
#define _APP_CONTAINERHEIGHT (DISPLAY_HEIGHT - HEADER_HEIGHT - NAV_BAR_HEIGHT)
UIHandler::~UIHandler() {
deinit();
}
esp_err_t UIHandler::init(void) {
ESP_LOGI(TAG, "Initializing UIHandler");
lv_obj_t* screen = lv_scr_act();
esp_err_t ret = ESP_OK;
// Create main screen
_main_screen = lv_obj_create(NULL);
if (!_main_screen) {
ESP_LOGE(TAG, "Failed to create main screen");
return ESP_FAIL;
}
lv_obj_set_style_bg_color(_main_screen, lv_color_black(), 0);
lv_obj_set_size(_main_screen, DISPLAY_WIDTH, DISPLAY_HEIGHT);
// Create root layout
_root_layout = new RootLayout(this);
if (!_root_layout) {
ESP_LOGE(TAG, "Failed to allocate RootLayout");
return ESP_FAIL;
// Create main screen layout
ret = create_main_screen_(screen);
if (ret != ESP_OK) {
ESP_LOGE(TAG, "Failed to create main screen layout");
return ret;
}
if (_root_layout->init(_main_screen) != ESP_OK) {
ESP_LOGE(TAG, "Failed to initialize root layout");
delete _root_layout;
_root_layout = nullptr;
return ESP_FAIL;
ret = interaction_handler_.init(root_layout_.get_app_container());
if (ret != ESP_OK) {
ESP_LOGE(TAG, "Failed to initialize InteractionHandler");
return ret;
}
// Render app icons from registry
if (_root_layout->render_app_icons() != ESP_OK) {
ESP_LOGW(TAG, "Failed to render app icons");
}
// Show the main screen
lv_scr_load(screen);
// Defer screen loading to prevent blocking during initialization
// Use LVGL timer to load screen after allowing watchdog reset
lv_timer_create([](lv_timer_t* timer) {
lv_obj_t* screen = static_cast<lv_obj_t*>(lv_timer_get_user_data(timer));
ESP_LOGI("UIHandler", "Loading main screen via timer");
lv_screen_load(screen);
lv_timer_del(timer);
}, 100, _main_screen); // 100ms delay to allow watchdog reset
ESP_LOGI(TAG, "UIHandler initialized successfully");
return ESP_OK;
return ret;
}
esp_err_t UIHandler::deinit(void) {
ESP_LOGI(TAG, "Deinitializing UIHandler");
// Deinit current app
if (_active_app) {
if (_active_app->deinit() != ESP_OK) {
ESP_LOGW(TAG, "Error deinitializing active app: %s", _active_app->get_name());
// Deinitialize current app if any
if (active_descriptor_) {
UIApp* app = active_descriptor_->get_app_instance();
if (app) {
esp_err_t ret = app->deinit();
if (ret != ESP_OK) {
ESP_LOGE("UIHandler", "Failed to deinitialize current app");
return ret;
}
}
_active_app = nullptr;
active_descriptor_ = nullptr;
}
// Delete shutdown app if cached
if (_shutdown_app) {
delete _shutdown_app;
_shutdown_app = nullptr;
// Destroy main screen layout
esp_err_t ret = destroy_main_screen_();
if (ret != ESP_OK) {
ESP_LOGE("UIHandler", "Failed to destroy main screen layout");
return ret;
}
// Clean up root layout
if (_root_layout) {
_root_layout->deinit();
delete _root_layout;
_root_layout = nullptr;
}
// Main screen will be cleaned up by LVGL
_main_screen = nullptr;
return ESP_OK;
}
esp_err_t UIHandler::switch_app(UIApp* app) {
if (!app) {
ESP_LOGE(TAG, "Cannot switch to null app");
return ESP_ERR_INVALID_ARG;
}
lv_obj_t* app_container = get_app_container();
if (!app_container) {
ESP_LOGE(TAG, "App container not initialized");
return ESP_FAIL;
}
ESP_LOGI(TAG, "Switching to app: %s", app->get_name());
// Deinit current app
if (_active_app) {
if (_active_app->deinit() != ESP_OK) {
ESP_LOGW(TAG, "Error deinitializing app: %s", _active_app->get_name());
}
}
// Clear the app container
lv_obj_clean(app_container);
// Initialize new app
if (app->init(app_container) != ESP_OK) {
ESP_LOGE(TAG, "Failed to initialize app: %s", app->get_name());
_active_app = nullptr;
return ESP_FAIL;
}
_active_app = app;
// Update header through RootLayout
if (_root_layout) {
_root_layout->update_header(_active_app->get_name());
_root_layout->show_back_button();
// Deinitialize interaction handler
ret = interaction_handler_.deinit();
if (ret != ESP_OK) {
ESP_LOGE("UIHandler", "Failed to deinitialize InteractionHandler");
return ret;
}
return ESP_OK;
}
esp_err_t UIHandler::switch_app(AppDescriptor* app_descriptor) {
esp_err_t UIHandler::switch_app(std::shared_ptr<AppDescriptor> app_descriptor) {
if (!app_descriptor) {
ESP_LOGE(TAG, "Cannot switch to null app descriptor");
ESP_LOGE(TAG, "Invalid app descriptor");
return ESP_ERR_INVALID_ARG;
}
UIApp* app = app_descriptor->get_app_instance();
if (!app) {
ESP_LOGE(TAG, "App descriptor has null app instance");
return ESP_ERR_INVALID_ARG;
esp_err_t ret = ESP_OK;
// Deinitialize current app if any
if (active_descriptor_) {
UIApp* current_app = active_descriptor_->get_app_instance();
if (current_app) {
ret = current_app->deinit();
if (ret != ESP_OK) {
ESP_LOGE(TAG, "Failed to deinitialize current app");
}
}
}
return switch_app(app);
}
void UIHandler::route_event(uint32_t event_type, void* event_data) {
if (_active_app) {
_active_app->handle_event(event_type, event_data);
}
}
esp_err_t UIHandler::show_shutdown_screen(std::string message) {
ESP_LOGI(TAG, "Showing shutdown screen");
lv_obj_t* app_container = get_app_container();
if (!app_container) {
ESP_LOGE(TAG, "App container not initialized");
return ESP_FAIL;
}
// Clear current app reference
_active_app = nullptr;
// Clear the app container
lv_obj_clean(app_container);
// Create shutdown message
lv_obj_t* shutdown_label = lv_label_create(app_container);
lv_label_set_text(shutdown_label, message.empty() ? "Shutting down..." : message.c_str());
lv_obj_set_style_text_color(shutdown_label, lv_color_white(), 0);
lv_obj_align(shutdown_label, LV_ALIGN_CENTER, 0, 0);
// Update header through RootLayout
if (_root_layout) {
_root_layout->update_header("System Shutdown");
lv_obj_t* app_container = root_layout_.get_app_container();
if (app_container) {
lv_obj_clean(app_container);
} else {
ESP_LOGE(TAG, "App container not available");
return ESP_ERR_INVALID_STATE;
}
// Set the new app as active
active_descriptor_ = app_descriptor;
// Initialize the new app
UIApp* new_app = active_descriptor_->get_app_instance();
if (!new_app) {
ESP_LOGE(TAG, "App instance not available");
active_descriptor_ = nullptr;
return ESP_ERR_INVALID_STATE;
}
ret = new_app->init(app_container);
if (ret != ESP_OK) {
ESP_LOGE(TAG, "Failed to initialize app: %s", new_app->get_name().c_str());
active_descriptor_ = nullptr;
return ret;
}
// Update header with app name
ret = update_header_title(new_app->get_name());
if (ret != ESP_OK) {
ESP_LOGW(TAG, "Failed to update header title");
}
// Show back button when in an app
root_layout_.show_back_button();
ESP_LOGI(TAG, "Switched to app: %s", new_app->get_name().c_str());
return ESP_OK;
}
esp_err_t UIHandler::show_shutdown_screen(const std::string& message) {
// Deinitialize current app if any
if (active_descriptor_) {
UIApp* app = active_descriptor_->get_app_instance();
if (app) {
app->deinit();
}
active_descriptor_ = nullptr;
}
// Clear the app container
lv_obj_t* app_container = root_layout_.get_app_container();
if (app_container) {
lv_obj_clean(app_container);
// Create a simple shutdown message screen
lv_obj_t* label = lv_label_create(app_container);
if (message.empty()) {
lv_label_set_text(label, "Shutting down...");
} else {
lv_label_set_text(label, message.c_str());
}
lv_obj_set_style_text_font(label, &lv_font_montserrat_14, 0);
lv_obj_center(label);
}
// Update header
update_header_title("System");
// Hide navigation buttons
root_layout_.hide_back_button();
root_layout_.hide_home_button();
ESP_LOGI(TAG, "Showing shutdown screen: %s", message.c_str());
return ESP_OK;
}
esp_err_t UIHandler::return_to_main_screen(void) {
ESP_LOGI(TAG, "Returning to main screen");
esp_err_t ret = ESP_OK;
// Deinit current app
if (_active_app) {
if (_active_app->deinit() != ESP_OK) {
ESP_LOGW(TAG, "Error deinitializing app: %s", _active_app->get_name());
// Deinitialize current app if any
if (active_descriptor_) {
UIApp* app = active_descriptor_->get_app_instance();
if (app) {
ret = app->deinit();
if (ret != ESP_OK) {
ESP_LOGE(TAG, "Failed to deinitialize app");
return ret;
}
}
_active_app = nullptr;
active_descriptor_ = nullptr;
}
// Clear the app container
lv_obj_t* app_container = get_app_container();
lv_obj_t* app_container = root_layout_.get_app_container();
if (app_container) {
lv_obj_clean(app_container);
// TODO: Display app launcher/home screen with app icons
// For now, just show a placeholder message
lv_obj_t* label = lv_label_create(app_container);
lv_label_set_text(label, "Home Screen\n\nApp icons will go here");
lv_obj_set_style_text_align(label, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_center(label);
} else {
ESP_LOGE(TAG, "App container not available");
return ESP_ERR_INVALID_STATE;
}
// Update header and hide back button through RootLayout
if (_root_layout) {
_root_layout->update_header("");
_root_layout->hide_back_button();
// Update header
ret = update_header_title("Home");
if (ret != ESP_OK) {
ESP_LOGW(TAG, "Failed to update header title");
}
// Hide back button on home screen
root_layout_.hide_back_button();
ESP_LOGI(TAG, "Returned to main screen");
return ESP_OK;
}
esp_err_t UIHandler::update_header_title(const std::string& title) {
return root_layout_.update_header(title);
}
//
// Private methods
//
void UIHandler::on_back_button_pressed_(void) {
if (active_descriptor_) {
UIApp* app = active_descriptor_->get_app_instance();
if (app) {
bool handled = app->on_back_button_pressed();
if (!handled) {
// App didn't handle it, return to main screen
return_to_main_screen();
}
}
} else {
ESP_LOGW(TAG, "Back button pressed but no active app");
}
}
esp_err_t UIHandler::create_main_screen_(lv_obj_t* parent) {
esp_err_t ret = ESP_OK;
// Initialize root layout
ret = root_layout_.init(parent, this);
if (ret != ESP_OK) {
ESP_LOGE(TAG, "Failed to initialize RootLayout");
return ret;
}
// Register back button callback
lv_event_dsc_t* back_event_dsc = nullptr;
ret = root_layout_.register_back_button_callback(
[](lv_event_t* e) {
UIHandler* ui_handler = static_cast<UIHandler*>(lv_event_get_user_data(e));
ui_handler->on_back_button_pressed_();
},
this,
&back_event_dsc
);
if (ret != ESP_OK) {
ESP_LOGE(TAG, "Failed to register back button callback");
return ret;
}
// Register home button callback
lv_event_dsc_t* home_event_dsc = nullptr;
ret = root_layout_.register_home_button_callback(
[](lv_event_t* e) {
UIHandler* ui_handler = static_cast<UIHandler*>(lv_event_get_user_data(e));
ui_handler->return_to_main_screen();
},
this,
&home_event_dsc
);
if (ret != ESP_OK) {
ESP_LOGE(TAG, "Failed to register home button callback");
return ret;
}
ESP_LOGI(TAG, "Main screen layout created successfully");
return ESP_OK;
}
esp_err_t UIHandler::destroy_main_screen_(void) {
esp_err_t ret = root_layout_.deinit();
if (ret != ESP_OK) {
ESP_LOGE(TAG, "Failed to deinitialize RootLayout");
return ret;
}
ESP_LOGI(TAG, "Main screen layout destroyed successfully");
return ESP_OK;
}

View File

@@ -1,12 +1,12 @@
#pragma once
#include "ui_app.h"
#include "app_registry.h"
#include "root_layout.h"
#include "esp_err.h"
// Forward declaration
class RootLayout;
#include "ui/apps/app.h"
#include "ui/events.h"
#include "ui/root_layout.h"
#include "ui/interaction_handler.h"
#include "lvgl.h"
#include <memory>
/**
* @brief UI Handler - manages app lifecycle and rendering
@@ -20,6 +20,10 @@ class RootLayout;
*/
class UIHandler {
public:
UIHandler() = default;
~UIHandler();
/**
* @brief Initialize the UI system with default layout
*
@@ -28,6 +32,10 @@ public:
* - App container (middle)
* - Navigation bar (bottom)
*
* And display the main screen.
*
* And initializes the InteractionHandler, callbacks, etc.
*
* @return ESP_OK on success, error code otherwise
*/
esp_err_t init(void);
@@ -42,45 +50,16 @@ public:
esp_err_t deinit(void);
/**
* @brief Switch to a new app
* @brief Switch to a new app by its descriptor
*
* Deinitializes the current app (if any), initializes the new app,
* and updates the display.
* and updates the display. Holds shared ownership of the descriptor
* to ensure the app remains valid while active.
*
* @param app Pointer to the new app to switch to
* @param app_descriptor Shared pointer to the app descriptor
* @return ESP_OK on success, error code otherwise
*/
esp_err_t switch_app(UIApp* app);
/**
* @brief Switch to an app by its descriptor
*
* Convenience method that extracts the UIApp from the descriptor
* and calls switch_app().
*
* @param app_descriptor Pointer to the app descriptor
* @return ESP_OK on success, error code otherwise
*/
esp_err_t switch_app(AppDescriptor* app_descriptor);
/**
* @brief Get the currently active app
*
* @return Pointer to the active UIApp, or nullptr if none
*/
UIApp* get_active_app(void) const {
return _active_app;
}
/**
* @brief Route a system event to the active app
*
* If an app is active, this forwards the event to it.
*
* @param event_type Type/ID of the event
* @param event_data Optional event data payload
*/
void route_event(uint32_t event_type, void* event_data = nullptr);
esp_err_t switch_app(std::shared_ptr<AppDescriptor> app_descriptor);
/**
* @brief Display shutdown screen
@@ -91,7 +70,7 @@ public:
* @param message Optional message to display (e.g., "Shutting down...")
* @return ESP_OK on success, error code otherwise
*/
esp_err_t show_shutdown_screen(std::string message = "");
esp_err_t show_shutdown_screen(const std::string& message = "");
/**
* @brief Get the main screen object
@@ -99,35 +78,10 @@ public:
* @return lv_obj_t* pointer to the main screen
*/
lv_obj_t* get_main_screen(void) const {
return _main_screen;
return main_screen_;
}
/**
* @brief Get the app container (where apps render)
*
* @return lv_obj_t* pointer to the app container
*/
lv_obj_t* get_app_container(void) const {
return _root_layout ? _root_layout->get_app_container() : nullptr;
}
/**
* @brief Get the header object
*
* @return lv_obj_t* pointer to the header container
*/
lv_obj_t* get_header(void) const {
return _root_layout ? _root_layout->get_header() : nullptr;
}
/**
* @brief Get the navigation bar object
*
* @return lv_obj_t* pointer to the navigation bar container
*/
lv_obj_t* get_nav_bar(void) const {
return _root_layout ? _root_layout->get_nav_bar() : nullptr;
}
esp_err_t update_header_title(const std::string& title);
/**
* @brief Return to main screen (deinit app and show app icons)
@@ -140,8 +94,25 @@ public:
esp_err_t return_to_main_screen(void);
private:
lv_obj_t* _main_screen = nullptr; ///< Root screen
RootLayout* _root_layout = nullptr; ///< Root layout manager
UIApp* _active_app = nullptr; ///< Currently active app
UIApp* _shutdown_app = nullptr; ///< Cached shutdown app
// Handle back button press, route to active app if any
void on_back_button_pressed_(void);
// Helper to create the main screen layout
esp_err_t create_main_screen_(lv_obj_t* parent);
// Helper to destroy the main screen layout
esp_err_t destroy_main_screen_(void);
// delete copy constructor and assignment operator
// to prevent copying of the UIHandler instance
UIHandler(const UIHandler&) = delete;
UIHandler& operator=(const UIHandler&) = delete;
InteractionHandler interaction_handler_; ///< Manages user interactions
lv_obj_t* main_screen_ = nullptr; ///< Root screen
RootLayout root_layout_; ///< Main screen layout manager
std::shared_ptr<AppDescriptor> active_descriptor_ = nullptr; ///< Currently active app descriptor (shared ownership)
};

View File

@@ -0,0 +1,10 @@
#include "ui/widgets/textarea.h"
lv_obj_t* textarea_create(lv_obj_t* parent) {
lv_obj_t* textarea = lv_textarea_create(parent);
// disable animations for cursor and selection for instant response
lv_obj_set_style_anim_time(textarea, 0, LV_PART_CURSOR | LV_STATE_FOCUSED);
return textarea;
}

View File

@@ -0,0 +1,4 @@
#pragma once
#include "lvgl.h"
lv_obj_t* textarea_create(lv_obj_t* parent);