From e467951b8c89531d26347f7240188af5178a1be3 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Mon, 2 Feb 2026 20:47:27 +0800 Subject: [PATCH] feat: Implement Discord app UI and settings management - Added MainUI class for displaying voice state, status icon, and buttons. - Introduced MainUIHandler to manage UI interactions and bridge communication. - Created SettingsUI for displaying QR code and configuration instructions. - Implemented SettingsUIHandler to manage settings and web server interactions. - Developed WebHandler for handling HTTP requests for settings configuration. - Updated AppRegistry to initialize with the new Discord app descriptor. - Enhanced InteractionHandler to support keyboard interactions across app switches. - Updated UIHandler to manage app switching and rendering of app icons. - Enabled QR code support in LVGL configuration. --- main/CMakeLists.txt | 4 +- main/main.cpp | 24 +- main/ui/apps/app.h | 6 +- main/ui/apps/iotdis/app.cpp | 131 +++++++ main/ui/apps/iotdis/app.h | 61 +++ main/ui/apps/iotdis/bridge/bridge.cpp | 256 +++++++++++++ main/ui/apps/iotdis/bridge/bridge.h | 67 ++++ main/ui/apps/iotdis/descriptor.cpp | 11 + main/ui/apps/iotdis/descriptor.h | 12 + .../apps/iotdis/settings/settings_handler.cpp | 52 +++ .../apps/iotdis/settings/settings_handler.h | 45 +++ main/ui/apps/iotdis/ui/main.cpp | 173 +++++++++ main/ui/apps/iotdis/ui/main.h | 85 ++++ main/ui/apps/iotdis/ui/main_handler.cpp | 170 ++++++++ main/ui/apps/iotdis/ui/main_handler.h | 52 +++ main/ui/apps/iotdis/ui/settings.cpp | 74 ++++ main/ui/apps/iotdis/ui/settings.h | 41 ++ main/ui/apps/iotdis/ui/settings_handler.cpp | 90 +++++ main/ui/apps/iotdis/ui/settings_handler.h | 33 ++ main/ui/apps/iotdis/web/web_handlers.cpp | 362 ++++++++++++++++++ main/ui/apps/iotdis/web/web_handlers.h | 81 ++++ main/ui/apps/registry.cpp | 9 + main/ui/apps/registry.h | 6 + main/ui/interaction_handler.cpp | 34 +- main/ui/interaction_handler.h | 7 +- main/ui/ui_handler.cpp | 53 ++- main/ui/ui_handler.h | 10 +- sdkconfig.default | 2 +- 28 files changed, 1927 insertions(+), 24 deletions(-) create mode 100644 main/ui/apps/iotdis/app.cpp create mode 100644 main/ui/apps/iotdis/app.h create mode 100644 main/ui/apps/iotdis/bridge/bridge.cpp create mode 100644 main/ui/apps/iotdis/bridge/bridge.h create mode 100644 main/ui/apps/iotdis/descriptor.cpp create mode 100644 main/ui/apps/iotdis/descriptor.h create mode 100644 main/ui/apps/iotdis/settings/settings_handler.cpp create mode 100644 main/ui/apps/iotdis/settings/settings_handler.h create mode 100644 main/ui/apps/iotdis/ui/main.cpp create mode 100644 main/ui/apps/iotdis/ui/main.h create mode 100644 main/ui/apps/iotdis/ui/main_handler.cpp create mode 100644 main/ui/apps/iotdis/ui/main_handler.h create mode 100644 main/ui/apps/iotdis/ui/settings.cpp create mode 100644 main/ui/apps/iotdis/ui/settings.h create mode 100644 main/ui/apps/iotdis/ui/settings_handler.cpp create mode 100644 main/ui/apps/iotdis/ui/settings_handler.h create mode 100644 main/ui/apps/iotdis/web/web_handlers.cpp create mode 100644 main/ui/apps/iotdis/web/web_handlers.h create mode 100644 main/ui/apps/registry.cpp diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 0683fb1..5dcea05 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -1,5 +1,5 @@ -set(requires esp-tls spi_flash nvs_flash esp_event esp_netif esp_http_client esp_wifi esp_psram esp_lvgl_port) -file(GLOB SRCS "main.cpp" "*.cpp" "*.c" "**/*.cpp" "**/*.cpp" "ui/**/*.cpp" "ui/**/*.c" "external/**/*.cpp" "external/**/*.c") +set(requires esp-tls spi_flash nvs_flash esp_event esp_netif esp_http_client esp_http_server esp_wifi esp_psram esp_lvgl_port) +file(GLOB_RECURSE SRCS "main.cpp" "*.cpp" "*.c" "ui/**/*.cpp" "ui/**/*.c" "external/**/*.cpp" "external/**/*.c") # Path to the source JSON in this component diff --git a/main/main.cpp b/main/main.cpp index bde5bd5..9d3e900 100644 --- a/main/main.cpp +++ b/main/main.cpp @@ -8,10 +8,12 @@ #include "esp_flash.h" #include "esp_system.h" #include "esp_log.h" +#include "esp_event.h" // #include "common/constants.h" #include "common/queue_defs.h" +#include "common/system_context.h" #include "io/nvs_handler.h" #include "io/fs_handler.h" #include "info/info.h" @@ -42,6 +44,15 @@ void init_queues( void app_main(void) { display_chip_info(); + // Initialize default event loop early - required for UI events + esp_err_t err = esp_event_loop_create_default(); + if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) { + ESP_LOGE(TAG, "Failed to create default event loop: %s", esp_err_to_name(err)); + vTaskDelay(5000 / portTICK_PERIOD_MS); + return esp_restart(); + } + ESP_LOGI(TAG, "Default event loop created.\n"); + QueueHandle_t touch_event_queue = NULL; EventGroupHandle_t system_event_group = NULL, system_lifecycle_event_group = NULL; @@ -76,7 +87,7 @@ void app_main(void) { // LVGL Handler std::unique_ptr display_uptr(display_handler); LVGLHandler lvgl_handler(std::move(display_uptr)); - esp_err_t err = lvgl_handler.initLVGL(system_event_group); + err = lvgl_handler.initLVGL(system_event_group); if (err != ESP_OK) { ESP_LOGE(TAG, "Failed to initialize LVGL handler: %s", esp_err_to_name(err)); vTaskDelay(5000 / portTICK_PERIOD_MS); @@ -87,6 +98,9 @@ void app_main(void) { kv_storage_handler->init(system_event_group); network_handler->init(system_event_group); + // Make network handler available to apps + SystemContext::instance().set_network_handler(network_handler); + // ESP_LOGI(TAG, "Waiting for system to be ready...\n"); xEventGroupWaitBits( @@ -100,7 +114,13 @@ void app_main(void) { ); ESP_LOGI(TAG, "System is ready. Starting main application...\n"); - // DiscordAppDescriptor::instance(); + AppRegistry& app_registry = AppRegistry::instance(); + err = app_registry.init(); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to initialize App Registry: %s", esp_err_to_name(err)); + vTaskDelay(5000 / portTICK_PERIOD_MS); + return esp_restart(); + } UIHandler ui_handler; err = ui_handler.init(); if (err != ESP_OK) { diff --git a/main/ui/apps/app.h b/main/ui/apps/app.h index 69fd64d..5009a58 100644 --- a/main/ui/apps/app.h +++ b/main/ui/apps/app.h @@ -5,6 +5,9 @@ #include #include +// Forward declaration +class InteractionHandler; + /** * @brief Base class for all UI applications * @@ -25,9 +28,10 @@ public: * between the header and navigation bar. * * @param container LVGL container object for this app + * @param interaction_handler Pointer to interaction handler for keyboard support * @return ESP_OK on success, error code otherwise */ - virtual esp_err_t init(lv_obj_t* container) = 0; + virtual esp_err_t init(lv_obj_t* container, InteractionHandler* interaction_handler) = 0; /** * @brief Deinitialize and clean up app resources diff --git a/main/ui/apps/iotdis/app.cpp b/main/ui/apps/iotdis/app.cpp new file mode 100644 index 0000000..071aa63 --- /dev/null +++ b/main/ui/apps/iotdis/app.cpp @@ -0,0 +1,131 @@ +#include "ui/apps/iotdis/app.h" +#include "ui/apps/iotdis/ui/main_handler.h" +#include "ui/apps/iotdis/ui/settings_handler.h" +#include "common/system_context.h" +#include "esp_log.h" + +static const char* TAG = "IotDisApp"; + +// ============================================================================ +// IotDisApp Implementation +// ============================================================================ + +IotDisApp::IotDisApp() + : main_ui_handler_(nullptr) + , settings_ui_handler_(nullptr) + , current_page_(Page::MAIN) + , setting_handler_(nullptr) + , interaction_handler_(nullptr) { + setting_handler_ = std::make_unique( + std::make_unique(IotDisApp::NVS_NAMESPACE) + ); +} + +IotDisApp::~IotDisApp() { } + +esp_err_t IotDisApp::init(lv_obj_t* container, InteractionHandler* interaction_handler) { + ESP_LOGI(TAG, "Initializing Discord app"); + + container_ = container; + interaction_handler_ = interaction_handler; + + // Initialize storage + setting_handler_->init(nullptr); + + // Load saved settings + setting_handler_->load_settings(); + + // Create main UI handler + main_ui_handler_ = std::make_unique(); + main_ui_handler_->init(container, interaction_handler_, setting_handler_.get()); + + // Register settings button callback + main_ui_handler_->register_on_settings_button_clicked(on_settings_button_clicked_static, this); + + current_page_ = Page::MAIN; + + return ESP_OK; +} + +esp_err_t IotDisApp::deinit() { + ESP_LOGI(TAG, "Deinitializing Discord app"); + + // Clean up UI handlers + if (settings_ui_handler_) { + settings_ui_handler_->deinit(); + settings_ui_handler_.reset(); + } + + if (main_ui_handler_) { + main_ui_handler_->deinit(); + main_ui_handler_.reset(); + } + + return ESP_OK; +} + +std::string IotDisApp::get_name() const { + return "Discord"; +} + +bool IotDisApp::on_back_button_pressed() { + // If on settings page, go back to main page + if (current_page_ == Page::SETTINGS) { + // Clean up settings handler + if (settings_ui_handler_) { + settings_ui_handler_->deinit(); + settings_ui_handler_.reset(); + } + + // Reload settings in case they were updated + setting_handler_->load_settings(); + + // Recreate main UI handler with updated settings + if (!main_ui_handler_) { + main_ui_handler_ = std::make_unique(); + main_ui_handler_->init(container_, interaction_handler_, setting_handler_.get()); + main_ui_handler_->register_on_settings_button_clicked(on_settings_button_clicked_static, this); + } + + // Update UI with configuration status + main_ui_handler_->update_config_prompt(setting_handler_->is_configured()); + + current_page_ = Page::MAIN; + return true; + } + + // Let system handle back (return to app icons) + return false; +} + +// ============================================================================ +// Private Methods +// ============================================================================ + +// Settings page with web server and QR code +void IotDisApp::show_settings_page() { + ESP_LOGI(TAG, "Showing settings page"); + + // Hide main UI handler + if (main_ui_handler_) { + main_ui_handler_->deinit(); + main_ui_handler_.reset(); + } + + // Create settings UI handler + settings_ui_handler_ = std::make_unique(); + settings_ui_handler_->init(container_, interaction_handler_, setting_handler_.get()); + + current_page_ = Page::SETTINGS; +} + +// ============================================================================ +// Static Callbacks +// ============================================================================ + +void IotDisApp::on_settings_button_clicked_static(lv_event_t* e) { + IotDisApp* app = static_cast(lv_event_get_user_data(e)); + if (app) { + app->show_settings_page(); + } +} \ No newline at end of file diff --git a/main/ui/apps/iotdis/app.h b/main/ui/apps/iotdis/app.h new file mode 100644 index 0000000..13b739a --- /dev/null +++ b/main/ui/apps/iotdis/app.h @@ -0,0 +1,61 @@ +#pragma once + +#include "ui/apps/app.h" +#include "ui/apps/iotdis/settings/settings_handler.h" +#include "ui/apps/iotdis/ui/main_handler.h" +#include "ui/apps/iotdis/ui/settings_handler.h" +#include "io/nvs_handler.h" +#include +#include + +// Forward declarations +class MainUIHandler; +class SettingsUIHandler; + +/** + * @brief IotDis (Discord Integration) App + * + * Manages Discord voice state monitoring and control via UDP bridge. + * Features: + * - Real-time voice state monitoring (muted/unmuted) + * - Manual mute/unmute control + * - Settings for bridge IP/port configuration + * - Connection error detection and notification + * - NVS storage for persistent settings + */ +class IotDisApp : public UIApp { +public: + IotDisApp(); + ~IotDisApp() override; + + esp_err_t init(lv_obj_t* container, InteractionHandler* interaction_handler) override; + esp_err_t deinit(void) override; + std::string get_name(void) const override; + bool on_back_button_pressed(void) override; + +private: + // UI handlers + std::unique_ptr main_ui_handler_; + std::unique_ptr settings_ui_handler_; + + // Current page tracking + enum class Page { + MAIN, + SETTINGS + }; + Page current_page_; + + // Settings handler (shared across handlers) + std::unique_ptr setting_handler_; + + // Interaction handler (not owned) + InteractionHandler* interaction_handler_; + + static constexpr const char* NVS_NAMESPACE = "discord_app"; + + // Private methods + void show_settings_page(); + + // UI callback forwarders + static void on_settings_button_clicked_static(lv_event_t* e); +}; diff --git a/main/ui/apps/iotdis/bridge/bridge.cpp b/main/ui/apps/iotdis/bridge/bridge.cpp new file mode 100644 index 0000000..c013261 --- /dev/null +++ b/main/ui/apps/iotdis/bridge/bridge.cpp @@ -0,0 +1,256 @@ +#include "ui/apps/iotdis/bridge/bridge.h" +#include "esp_err.h" +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "freertos/semphr.h" + +#define TAG "IotDisBridge" +#define MUTE_COMMAND "MUTE" +#define STATUS_COMMAND "STATUS" +#define MUTED_RESPONSE "MUTED" +#define UNMUTED_RESPONSE "UNMUTED" + +IotDisBridge::~IotDisBridge() { + stop_polling_task(); +} + +void IotDisBridge::start_polling_task() { + if (poll_task_handle_) { + ESP_LOGW(TAG, "Polling task already running"); + return; + } + + udp_client_.init(setting_handler_->get_local_port()); + udp_client_.configure( + setting_handler_->get_remote_ip(), + setting_handler_->get_remote_port() + ); + + stop_polling_ = false; + xTaskCreate(poll_task_, "discord_poll", 4096, this, 5, &poll_task_handle_); +} + +void IotDisBridge::stop_polling_task() { + if (!poll_task_handle_) { + if (udp_client_.is_configured()) { + udp_client_.close(); + } + 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; + } + + if (udp_client_.is_configured()) { + udp_client_.close(); + } + on_status_update_callback_ = nullptr; + status_event_user_data_ = nullptr; + consecutive_failures_ = 0; +} + + +esp_err_t IotDisBridge::send_mute_command() { + if (!setting_handler_->is_configured()) { + ESP_LOGW(TAG, "Cannot send command: not configured"); + return ESP_FAIL; + } + + ESP_LOGI(TAG, "Sending MUTE command"); + esp_err_t err = udp_client_.send_command(MUTE_COMMAND); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to send MUTE command"); + return err; + } + return ESP_OK; +} + +bool IotDisBridge::test_connection(const std::string& ip, uint16_t port, uint16_t local_port) { + ESP_LOGI(TAG, "Testing connection to %s:%u (local port: %u)", ip.c_str(), port, local_port); + + // Create temporary UDP client for testing + UDPClient test_client; + esp_err_t err = test_client.init(local_port); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to initialize test UDP client"); + return false; + } + + err = test_client.configure(ip, port); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to configure test UDP client"); + return false; + } + + err = test_client.send_command(STATUS_COMMAND); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to send STATUS command"); + return false; + } + + ESP_LOGI(TAG, "STATUS command sent, waiting for response (timeout: %dms)", RESPONSE_TIMEOUT_MS); + + std::string response; + err = test_client.receive_response(response, RESPONSE_TIMEOUT_MS); + + if (err == ESP_OK) { + ESP_LOGI(TAG, "Received response: %s", response.c_str()); + bool valid = (response == MUTED_RESPONSE || response == UNMUTED_RESPONSE); + if (!valid) { + ESP_LOGW(TAG, "Unexpected response (expected MUTED or UNMUTED)"); + } + test_client.close(); + return valid; + } else if (err == ESP_ERR_TIMEOUT) { + ESP_LOGW(TAG, "Timeout waiting for response"); + } else { + ESP_LOGE(TAG, "Error receiving response: %d", err); + } + + test_client.close(); + return false; +} + +// +// private methods +// + +void IotDisBridge::poll_task_(void* param) { + IotDisBridge* bridge = static_cast(param); + + ESP_LOGI(TAG, "Polling task started"); + + while (!bridge->stop_polling_) { + ESP_LOGI(TAG, "Polling for status update..."); + bridge->poll_status_(); + + // Yield to allow display updates to complete + taskYIELD(); + + // Use longer interval if in error state + int interval = (bridge->consecutive_failures_ >= MAX_FAILURES_BEFORE_ERROR) + ? ERROR_POLL_INTERVAL_MS + : POLL_INTERVAL_MS; + ESP_LOGI(TAG, "Next poll in %d ms", interval); + vTaskDelay(pdMS_TO_TICKS(interval)); + } + + ESP_LOGI(TAG, "Polling task stopped"); + vTaskDelete(nullptr); +} + +void IotDisBridge::poll_status_() { + if (!setting_handler_->is_configured()) { + // Don't poll if not configured + return; + } + + // First check for any unsolicited push messages (non-blocking) + std::string push_message; + esp_err_t err = udp_client_.receive_response(push_message, 0); // 0 = non-blocking + + if (err == ESP_OK && !push_message.empty()) { + // Received push update from remote + ESP_LOGI(TAG, "Received push update: %s", push_message.c_str()); + StatusUpdateEventData event_data { + .state = StatusUpdateEventData::VoiceState::UNKNOWN + }; + + if (push_message == MUTED_RESPONSE) { + event_data.state = StatusUpdateEventData::VoiceState::MUTED; + } else if (push_message == UNMUTED_RESPONSE) { + event_data.state = StatusUpdateEventData::VoiceState::UNMUTED; + } + + if (on_status_update_callback_) { + on_status_update_callback_(event_data, status_event_user_data_); + } + + // VoiceState new_state = VoiceState::UNKNOWN; + // if (push_message == MUTED_RESPONSE) { + // new_state = VoiceState::MUTED; + // } else if (push_message == UNMUTED_RESPONSE) { + // new_state = VoiceState::UNMUTED; + // } + + // if (new_state != VoiceState::UNKNOWN) { + // consecutive_failures_ = 0; + // if (main_ui_ && current_page_ == Page::MAIN) { + // main_ui_->show_error_notification(false); + // } + + // if (state_mutex_ && xSemaphoreTake(state_mutex_, pdMS_TO_TICKS(100)) == pdTRUE) { + // current_state_ = new_state; + // xSemaphoreGive(state_mutex_); + // } + + // update_main_ui(); + // return; // Got push update, skip polling + // } + } + + // Send STATUS command for polling + err = udp_client_.send_command(STATUS_COMMAND); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Failed to send STATUS command"); + consecutive_failures_++; + + if (consecutive_failures_ >= MAX_FAILURES_BEFORE_ERROR) { + if (on_status_update_callback_) { + on_status_update_callback_( + StatusUpdateEventData { .state = StatusUpdateEventData::VoiceState::ERROR }, + status_event_user_data_ + ); + } + } + return; + } + + // Wait for response to STATUS command + std::string response; + err = udp_client_.receive_response(response, RESPONSE_TIMEOUT_MS); + + if (err == ESP_OK) { + // Success - reset failure counter + consecutive_failures_ = 0; + + StatusUpdateEventData event_data { + .state = StatusUpdateEventData::VoiceState::UNKNOWN + }; + if (response == MUTED_RESPONSE) { + event_data.state = StatusUpdateEventData::VoiceState::MUTED; + } else if (response == UNMUTED_RESPONSE) { + event_data.state = StatusUpdateEventData::VoiceState::UNMUTED; + } + if (on_status_update_callback_) { + on_status_update_callback_(event_data, status_event_user_data_); + } + } else { + // Timeout or error + consecutive_failures_++; + ESP_LOGW(TAG, "No response to STATUS (failures: %d)", consecutive_failures_); + + if (consecutive_failures_ >= MAX_FAILURES_BEFORE_ERROR) { + if (on_status_update_callback_) { + on_status_update_callback_( + StatusUpdateEventData { .state = StatusUpdateEventData::VoiceState::ERROR }, + status_event_user_data_ + ); + } + } + } +} \ No newline at end of file diff --git a/main/ui/apps/iotdis/bridge/bridge.h b/main/ui/apps/iotdis/bridge/bridge.h new file mode 100644 index 0000000..eadb532 --- /dev/null +++ b/main/ui/apps/iotdis/bridge/bridge.h @@ -0,0 +1,67 @@ +#pragma once + +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "ui/apps/iotdis/settings/settings_handler.h" +#include +#include +#include +#include "esp_err.h" +#include "network/udp_client.h" + +struct StatusUpdateEventData { + enum class VoiceState { + UNKNOWN, + MUTED, + UNMUTED, + ERROR + } state; +}; + +using StatusEventCallback = void(*)(StatusUpdateEventData, void*); + + +class IotDisBridge { +public: + IotDisBridge( + SettingHandler* setting_handler + ) : setting_handler_(setting_handler) { } + ~IotDisBridge(); + + void start_polling_task(); + void stop_polling_task(); + + esp_err_t send_mute_command(); + bool test_connection(const std::string& ip, uint16_t port) { + return test_connection(ip, port, setting_handler_->get_local_port()); + } + bool test_connection(const std::string& ip, uint16_t port, uint16_t local_port); + void register_on_status_update_callback( + StatusEventCallback callback, + void* status_event_user_data + ) { + on_status_update_callback_ = callback; + status_event_user_data_ = status_event_user_data; + } + +private: + static constexpr int POLL_INTERVAL_MS = 2000; + static constexpr int ERROR_POLL_INTERVAL_MS = 5000; + static constexpr int RESPONSE_TIMEOUT_MS = 1000; + static constexpr int MAX_FAILURES_BEFORE_ERROR = 3; + + void poll_status_(); + + // Polling task + static void poll_task_(void* param); + + TaskHandle_t poll_task_handle_ = nullptr; + bool stop_polling_ = false; + int consecutive_failures_ = 0; + SettingHandler* setting_handler_ = nullptr; + UDPClient udp_client_; + + StatusEventCallback on_status_update_callback_ = nullptr; + void* status_event_user_data_ = nullptr; + +}; diff --git a/main/ui/apps/iotdis/descriptor.cpp b/main/ui/apps/iotdis/descriptor.cpp new file mode 100644 index 0000000..522e8ee --- /dev/null +++ b/main/ui/apps/iotdis/descriptor.cpp @@ -0,0 +1,11 @@ +#include "ui/apps/iotdis/descriptor.h" + +IotDisDescriptor::IotDisDescriptor() + : AppDescriptor("IotDis", std::make_unique()) { } + +void IotDisDescriptor::draw_icon(lv_obj_t* parent) { + // Draw Discord icon (call/phone symbol) + lv_obj_t* icon = lv_label_create(parent); + lv_label_set_text(icon, LV_SYMBOL_CALL); + lv_obj_center(icon); +} diff --git a/main/ui/apps/iotdis/descriptor.h b/main/ui/apps/iotdis/descriptor.h new file mode 100644 index 0000000..632bbe8 --- /dev/null +++ b/main/ui/apps/iotdis/descriptor.h @@ -0,0 +1,12 @@ +#pragma once + +#include "ui/apps/app.h" +#include "ui/apps/iotdis/app.h" + +class IotDisDescriptor : public AppDescriptor { +public: + IotDisDescriptor(); + ~IotDisDescriptor() override = default; + + void draw_icon(lv_obj_t* parent) override; +}; diff --git a/main/ui/apps/iotdis/settings/settings_handler.cpp b/main/ui/apps/iotdis/settings/settings_handler.cpp new file mode 100644 index 0000000..23593c2 --- /dev/null +++ b/main/ui/apps/iotdis/settings/settings_handler.cpp @@ -0,0 +1,52 @@ +#include "ui/apps/iotdis/settings/settings_handler.h" + +#include "esp_log.h" + +#define TAG "SettingHandler" + +void SettingHandler::load_settings() { + remote_ip_ = storage_->get(NVS_KEY_IP); + std::string port_str = storage_->get(NVS_KEY_PORT); + std::string local_port_str = storage_->get(NVS_KEY_LOCAL_PORT); + + if (!remote_ip_.empty() && !port_str.empty()) { + remote_port_ = static_cast(atoi(port_str.c_str())); + + // Load local port, default to DEFAULT_LOCAL_PORT if not configured + if (!local_port_str.empty()) { + local_port_ = static_cast(atoi(local_port_str.c_str())); + } else { + local_port_ = DEFAULT_LOCAL_PORT; + } + + ESP_LOGI(TAG, "Loaded settings: %s:%u (local port: %u)", remote_ip_.c_str(), remote_port_, local_port_); + } else { + local_port_ = DEFAULT_LOCAL_PORT; + ESP_LOGI(TAG, "No settings found, user setup required"); + } +} + + +void SettingHandler::save_settings(const std::string& ip, uint16_t port, uint16_t local_port) { + if (ip.empty() || port == 0 || local_port == 0) { + ESP_LOGW(TAG, "Cannot save: invalid settings"); + return; + } + + // Save to NVS + storage_->put(NVS_KEY_IP, ip); + char port_str[8]; + snprintf(port_str, sizeof(port_str), "%u", port); + storage_->put(NVS_KEY_PORT, port_str); + char local_port_str[8]; + snprintf(local_port_str, sizeof(local_port_str), "%u", local_port); + storage_->put(NVS_KEY_LOCAL_PORT, local_port_str); + + // Update local config + remote_ip_ = ip; + remote_port_ = port; + local_port_ = local_port; + + ESP_LOGI(TAG, "Settings saved: %s:%u (local port: %u)", remote_ip_.c_str(), remote_port_, local_port_); +} + diff --git a/main/ui/apps/iotdis/settings/settings_handler.h b/main/ui/apps/iotdis/settings/settings_handler.h new file mode 100644 index 0000000..d84b6c5 --- /dev/null +++ b/main/ui/apps/iotdis/settings/settings_handler.h @@ -0,0 +1,45 @@ +#pragma once + +#include "freertos/FreeRTOS.h" +#include +#include +#include "io/nvs_handler.h" + +class SettingHandler { +public: + SettingHandler(std::unique_ptr storage) : + remote_ip_(""), + remote_port_(0), + local_port_(0), + storage_(std::move(storage)) { } + ~SettingHandler() = default; + + esp_err_t init(const EventGroupHandle_t& system_event_group) { + storage_->init(system_event_group); + return ESP_OK; + } + + void load_settings(); + void save_settings(const std::string& ip, uint16_t port) { + save_settings(ip, port, local_port_); + } + void save_settings(const std::string& ip, uint16_t port, uint16_t local_port); + + bool is_configured() const { return !remote_ip_.empty() && remote_port_ != 0 && local_port_ != 0; } + + std::string get_remote_ip() const { return remote_ip_; } + uint16_t get_remote_port() const { return remote_port_; } + uint16_t get_local_port() const { return local_port_; } + +private: + static constexpr uint16_t DEFAULT_LOCAL_PORT = 4212; + + static constexpr const char* NVS_KEY_IP = "bridge_ip"; + static constexpr const char* NVS_KEY_PORT = "bridge_port"; + static constexpr const char* NVS_KEY_LOCAL_PORT = "local_port"; + + std::string remote_ip_; + uint16_t remote_port_; + uint16_t local_port_; + std::unique_ptr storage_; +}; diff --git a/main/ui/apps/iotdis/ui/main.cpp b/main/ui/apps/iotdis/ui/main.cpp new file mode 100644 index 0000000..e9d5f54 --- /dev/null +++ b/main/ui/apps/iotdis/ui/main.cpp @@ -0,0 +1,173 @@ +#include "ui/apps/iotdis/ui/main.h" +#include "ui/apps/iotdis/app.h" +#include "ui/interaction_handler.h" +#include "esp_log.h" + +static const char* TAG = "MainUI"; + +MainUI::~MainUI() { + deinit(); +} + +esp_err_t MainUI::init(lv_obj_t* parent, InteractionHandler* interaction_handler) { + container_ = parent; + create_ui_(parent); + return ESP_OK; +} + +esp_err_t MainUI::deinit(void) { + // LVGL will clean up children automatically when parent is deleted + error_notification_ = nullptr; + status_icon_label_ = nullptr; + status_text_label_ = nullptr; + mute_button_ = nullptr; + settings_button_ = nullptr; + config_prompt_ = nullptr; + container_ = nullptr; + return ESP_OK; +} + +void MainUI::create_ui_(lv_obj_t* parent) { + // Set up main page with flex column layout + lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(parent, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_all(parent, 10, 0); + + // === Top Section: Error Notification === + error_notification_ = lv_obj_create(parent); + 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_white(), 0); + lv_obj_set_style_bg_opa(error_notification_, LV_OPA_COVER, 0); + lv_obj_set_style_border_color(error_notification_, lv_color_black(), 0); + lv_obj_set_style_border_width(error_notification_, 2, 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_black(), 0); + + // === Center Section: Main Content === + lv_obj_t* center_container = lv_obj_create(parent); + 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_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(parent); + 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) + 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_black(), 0); + + // Settings button (right side) + settings_button_ = lv_btn_create(bottom_container); + lv_obj_set_size(settings_button_, 60, 60); + + lv_obj_t* settings_icon = lv_label_create(settings_button_); + lv_label_set_text(settings_icon, LV_SYMBOL_SETTINGS); + lv_obj_center(settings_icon); + + ESP_LOGI(TAG, "Main UI created"); +} + +esp_err_t MainUI::register_on_settings_button_clicked(lv_event_cb_t cb, void* user_data) { + if (!settings_button_) { + return ESP_ERR_INVALID_STATE; + } + lv_obj_add_event_cb(settings_button_, cb, LV_EVENT_CLICKED, user_data); + return ESP_OK; +} + +esp_err_t MainUI::register_on_mute_button_clicked(lv_event_cb_t cb, void* user_data) { + if (!mute_button_) { + return ESP_ERR_INVALID_STATE; + } + lv_obj_add_event_cb(mute_button_, cb, LV_EVENT_CLICKED, user_data); + return ESP_OK; +} + +void MainUI::update_status(VoiceState state) { + if (!status_icon_label_ || !status_text_label_) { + 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_black(), 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_black(), 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_black(), 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_black(), 0); + break; + } +} + +void MainUI::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); + } + } +} + +void MainUI::update_config_prompt(bool configured) { + if (config_prompt_) { + if (configured) { + lv_obj_add_flag(config_prompt_, LV_OBJ_FLAG_HIDDEN); + } else { + lv_obj_clear_flag(config_prompt_, LV_OBJ_FLAG_HIDDEN); + } + } +} diff --git a/main/ui/apps/iotdis/ui/main.h b/main/ui/apps/iotdis/ui/main.h new file mode 100644 index 0000000..c9dd50d --- /dev/null +++ b/main/ui/apps/iotdis/ui/main.h @@ -0,0 +1,85 @@ +#pragma once + +#include "lvgl.h" +#include "esp_err.h" +#include +#include "ui/events.h" +#include "ui/apps/iotdis/bridge/bridge.h" +#include "ui/interaction_handler.h" + +// Voice state enumeration +enum class VoiceState { + UNKNOWN, + MUTED, + UNMUTED, + ERROR +}; + +// Forward declarations +class InteractionHandler; + +/** + * @brief Main UI for Discord app + * + * Displays: + * - Current voice state (muted/unmuted/error/unknown) + * - Large status icon + * - Status text + * - Mute toggle button + * - Error notification banner (when connection lost) + * - Settings button + * - Configuration prompt (if not configured) + */ +class MainUI { +public: + MainUI() = default; + ~MainUI(); + + esp_err_t init(lv_obj_t* parent, InteractionHandler* interaction_handler); + esp_err_t deinit(void); + + /** + * @brief Register callback for settings button clicks + * @param cb Callback function + * @param user_data User data to pass to callback + */ + esp_err_t register_on_settings_button_clicked(lv_event_cb_t cb, void* user_data); + + /** + * @brief Register callback for mute button clicks + * @param cb Callback function + * @param user_data User data to pass to callback + */ + esp_err_t register_on_mute_button_clicked(lv_event_cb_t cb, void* user_data); + + /** + * @brief Update status display with current voice state + * @param state Current voice state + */ + void update_status(VoiceState state); + + /** + * @brief Show or hide error notification banner + * @param show true to show, false to hide + */ + void show_error_notification(bool show); + + /** + * @brief Update configuration prompt visibility + * @param configured true if settings are configured + */ + void update_config_prompt(bool configured); + +private: + void create_ui_(lv_obj_t* parent); + + lv_obj_t* container_ = nullptr; + + // UI elements + lv_obj_t* error_notification_ = nullptr; + lv_obj_t* status_icon_label_ = nullptr; + lv_obj_t* status_text_label_ = nullptr; + lv_obj_t* mute_button_ = nullptr; + lv_obj_t* settings_button_ = nullptr; + lv_obj_t* config_prompt_ = nullptr; +}; diff --git a/main/ui/apps/iotdis/ui/main_handler.cpp b/main/ui/apps/iotdis/ui/main_handler.cpp new file mode 100644 index 0000000..05ad218 --- /dev/null +++ b/main/ui/apps/iotdis/ui/main_handler.cpp @@ -0,0 +1,170 @@ +#include "ui/apps/iotdis/ui/main_handler.h" +#include "esp_log.h" + +static const char* TAG = "MainUIHandler"; + +MainUIHandler::MainUIHandler() { + state_mutex_ = xSemaphoreCreateMutex(); +} + +MainUIHandler::~MainUIHandler() { + deinit(); + + if (state_mutex_) { + vSemaphoreDelete(state_mutex_); + state_mutex_ = nullptr; + } +} + +esp_err_t MainUIHandler::init(lv_obj_t* parent, InteractionHandler* interaction_handler, SettingHandler* setting_handler) { + ESP_LOGI(TAG, "Initializing Main UI Handler"); + + setting_handler_ = setting_handler; + + // Create unique bridge instance for this handler + bridge_ = std::make_unique(setting_handler_); + + // Register status update callback + bridge_->register_on_status_update_callback(on_status_update_static_, this); + + // Create main UI + main_ui_ = std::make_unique(); + main_ui_->init(parent, interaction_handler); + + // Register mute button callback + main_ui_->register_on_mute_button_clicked(on_mute_button_clicked_static_, this); + + // Update UI with current configuration + main_ui_->update_config_prompt(setting_handler_->is_configured()); + update_ui_(); + + // Start polling task + bridge_->start_polling_task(); + + return ESP_OK; +} + +esp_err_t MainUIHandler::deinit(void) { + ESP_LOGI(TAG, "Deinitializing Main UI Handler"); + + // Stop polling + if (bridge_) { + bridge_->stop_polling_task(); + bridge_.reset(); + } + + // Clean up UI + if (main_ui_) { + main_ui_->deinit(); + main_ui_.reset(); + } + + return ESP_OK; +} + +esp_err_t MainUIHandler::register_on_settings_button_clicked(lv_event_cb_t cb, void* user_data) { + on_settings_callback_ = cb; + settings_callback_user_data_ = user_data; + + if (main_ui_) { + main_ui_->register_on_settings_button_clicked(cb, user_data); + } else { + ESP_LOGE(TAG, "Main UI not initialized"); + return ESP_ERR_INVALID_STATE; + } + + return ESP_OK; +} + +void MainUIHandler::update_config_prompt(bool is_configured) { + if (main_ui_) { + main_ui_->update_config_prompt(is_configured); + } else { + ESP_LOGE(TAG, "Main UI not initialized"); + } +} + +void MainUIHandler::update_status() { + update_ui_(); +} + +// ============================================================================ +// Private Methods +// ============================================================================ + +void MainUIHandler::send_mute_command_() { + if (!setting_handler_->is_configured()) { + ESP_LOGW(TAG, "Cannot send command: not configured"); + return; + } + + ESP_LOGI(TAG, "Sending MUTE command"); + esp_err_t err = bridge_->send_mute_command(); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to send MUTE command"); + } +} + +void MainUIHandler::on_status_update_(StatusUpdateEventData data) { + // Update state in thread-safe manner + if (state_mutex_ && xSemaphoreTake(state_mutex_, pdMS_TO_TICKS(100)) == pdTRUE) { + current_state_ = data.state; + xSemaphoreGive(state_mutex_); + } + + // Update UI + update_ui_(); +} + +void MainUIHandler::update_ui_() { + if (main_ui_) { + StatusUpdateEventData::VoiceState state; + if (state_mutex_ && xSemaphoreTake(state_mutex_, pdMS_TO_TICKS(100)) == pdTRUE) { + state = current_state_; + xSemaphoreGive(state_mutex_); + } else { + state = StatusUpdateEventData::VoiceState::UNKNOWN; + } + + // Convert to MainUI VoiceState + VoiceState ui_state; + switch (state) { + case StatusUpdateEventData::VoiceState::MUTED: + ui_state = VoiceState::MUTED; + break; + case StatusUpdateEventData::VoiceState::UNMUTED: + ui_state = VoiceState::UNMUTED; + break; + case StatusUpdateEventData::VoiceState::ERROR: + ui_state = VoiceState::ERROR; + break; + default: + ui_state = VoiceState::UNKNOWN; + break; + } + + main_ui_->update_status(ui_state); + } +} + +// ============================================================================ +// Static Callbacks +// ============================================================================ + +void MainUIHandler::on_mute_button_clicked_static_(lv_event_t* e) { + MainUIHandler* handler = static_cast(lv_event_get_user_data(e)); + if (handler) { + handler->on_mute_button_clicked_(); + } +} + +void MainUIHandler::on_mute_button_clicked_() { + send_mute_command_(); +} + +void MainUIHandler::on_status_update_static_(StatusUpdateEventData data, void* user_data) { + MainUIHandler* handler = static_cast(user_data); + if (handler) { + handler->on_status_update_(data); + } +} diff --git a/main/ui/apps/iotdis/ui/main_handler.h b/main/ui/apps/iotdis/ui/main_handler.h new file mode 100644 index 0000000..ef9f1af --- /dev/null +++ b/main/ui/apps/iotdis/ui/main_handler.h @@ -0,0 +1,52 @@ +#pragma once + +#include "ui/apps/iotdis/ui/main.h" +#include "ui/interaction_handler.h" +#include "ui/apps/iotdis/bridge/bridge.h" +#include "ui/apps/iotdis/settings/settings_handler.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" +#include "esp_err.h" +#include + +/** + * @brief Main UI Handler for Discord App + * + * Manages the MainUI instance and interaction with the InteractionHandler. + * Each handler instance has its own IotDisBridge to prevent conflicts. + */ +class MainUIHandler { +public: + + MainUIHandler(); + ~MainUIHandler(); + + esp_err_t init(lv_obj_t* parent, InteractionHandler* interaction_handler, SettingHandler* setting_handler); + esp_err_t deinit(void); + + esp_err_t register_on_settings_button_clicked(lv_event_cb_t cb, void* user_data); + void update_config_prompt(bool is_configured); + void update_status(); + +private: + static void on_mute_button_clicked_static_(lv_event_t* e); + static void on_status_update_static_(StatusUpdateEventData data, void* user_data); + + void on_mute_button_clicked_(); + void on_status_update_(StatusUpdateEventData data); + void send_mute_command_(); + void update_ui_(); + + std::unique_ptr main_ui_ = nullptr; + std::unique_ptr bridge_ = nullptr; + SettingHandler* setting_handler_ = nullptr; // Not owned + + // Voice state tracking + StatusUpdateEventData::VoiceState current_state_ = StatusUpdateEventData::VoiceState::UNKNOWN; + SemaphoreHandle_t state_mutex_ = nullptr; + + // Callback for settings button + lv_event_cb_t on_settings_callback_ = nullptr; + void* settings_callback_user_data_ = nullptr; +}; + diff --git a/main/ui/apps/iotdis/ui/settings.cpp b/main/ui/apps/iotdis/ui/settings.cpp new file mode 100644 index 0000000..a6fe2c9 --- /dev/null +++ b/main/ui/apps/iotdis/ui/settings.cpp @@ -0,0 +1,74 @@ +#include "ui/apps/iotdis/ui/settings.h" +#include "ui/interaction_handler.h" +#include "esp_log.h" +#include + +static const char* TAG = "SettingsUI"; + +SettingsUI::~SettingsUI() { + deinit(); +} + +esp_err_t SettingsUI::init(lv_obj_t* parent, InteractionHandler* interaction_handler) { + container_ = parent; + create_ui_(parent, interaction_handler); + return ESP_OK; +} + +esp_err_t SettingsUI::deinit(void) { + // LVGL will clean up children automatically when parent is deleted + qr_code_ = nullptr; + status_label_ = nullptr; + container_ = nullptr; + return ESP_OK; +} + +void SettingsUI::create_ui_(lv_obj_t* parent, InteractionHandler* interaction_handler) { + // Title + lv_obj_t* title = lv_label_create(parent); + lv_label_set_text(title, "Scan to Configure"); + lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 20); + lv_obj_set_style_text_font(title, &lv_font_montserrat_14, 0); + + // Instruction text + lv_obj_t* instruction = lv_label_create(parent); + lv_label_set_text(instruction, "Scan this QR code with your mobile\ndevice to configure settings"); + lv_obj_align(instruction, LV_ALIGN_TOP_MID, 0, 60); + lv_obj_set_style_text_align(instruction, LV_TEXT_ALIGN_CENTER, 0); + + // QR code (centered) + qr_code_ = lv_qrcode_create(parent); + lv_qrcode_set_size(qr_code_, 250); + lv_qrcode_set_dark_color(qr_code_, lv_color_black()); + lv_qrcode_set_light_color(qr_code_, lv_color_white()); + lv_obj_align(qr_code_, LV_ALIGN_CENTER, 0, 0); + + // Status label below QR code + status_label_ = lv_label_create(parent); + lv_label_set_text(status_label_, "Initializing..."); + lv_obj_align(status_label_, LV_ALIGN_BOTTOM_MID, 0, -40); + lv_obj_set_style_text_align(status_label_, LV_TEXT_ALIGN_CENTER, 0); + + ESP_LOGI(TAG, "Settings UI created with QR code display"); +} + +void SettingsUI::set_config_url(const std::string& url) { + if (!qr_code_ || url.empty()) { + ESP_LOGW(TAG, "Cannot set config URL: qr_code=%p, url=%s", qr_code_, url.c_str()); + return; + } + + lv_result_t result = lv_qrcode_update(qr_code_, url.c_str(), url.length()); + if (result != LV_RESULT_OK) { + ESP_LOGE(TAG, "Failed to update QR code"); + set_status_message("Error: Failed to generate QR code"); + } else { + ESP_LOGI(TAG, "QR code updated with URL: %s", url.c_str()); + } +} + +void SettingsUI::set_status_message(const std::string& message) { + if (status_label_) { + lv_label_set_text(status_label_, message.c_str()); + } +} diff --git a/main/ui/apps/iotdis/ui/settings.h b/main/ui/apps/iotdis/ui/settings.h new file mode 100644 index 0000000..2b22d13 --- /dev/null +++ b/main/ui/apps/iotdis/ui/settings.h @@ -0,0 +1,41 @@ +#pragma once + +#include "lvgl.h" +#include "esp_err.h" +#include + +// Forward declaration +class InteractionHandler; + +/** + * @brief Settings UI for Discord app + * + * Displays a QR code that links to a web-based configuration interface + */ +class SettingsUI { +public: + SettingsUI() = default; + ~SettingsUI(); + + esp_err_t init(lv_obj_t* parent, InteractionHandler* interaction_handler); + esp_err_t deinit(void); + + /** + * @brief Set the configuration URL to display in QR code + * @param url Full URL including IP, port, and auth key + */ + void set_config_url(const std::string& url); + + /** + * @brief Update status message below QR code + * @param message Status message to display + */ + void set_status_message(const std::string& message); + +private: + void create_ui_(lv_obj_t* parent, InteractionHandler* interaction_handler); + + lv_obj_t* container_ = nullptr; + lv_obj_t* qr_code_ = nullptr; + lv_obj_t* status_label_ = nullptr; +}; diff --git a/main/ui/apps/iotdis/ui/settings_handler.cpp b/main/ui/apps/iotdis/ui/settings_handler.cpp new file mode 100644 index 0000000..7ece3b5 --- /dev/null +++ b/main/ui/apps/iotdis/ui/settings_handler.cpp @@ -0,0 +1,90 @@ +#include "ui/apps/iotdis/ui/settings_handler.h" +#include "network/network.h" +#include "esp_log.h" +#include +#include + +static const char* TAG = "SettingsUIHandler"; + +SettingsUIHandler::SettingsUIHandler() { } + +SettingsUIHandler::~SettingsUIHandler() { + deinit(); +} + +esp_err_t SettingsUIHandler::init(lv_obj_t* parent, InteractionHandler* interaction_handler, SettingHandler* setting_handler) { + ESP_LOGI(TAG, "Initializing Settings UI Handler"); + + setting_handler_ = setting_handler; + + // Create unique bridge instance for this handler + bridge_ = std::make_unique(setting_handler_); + + // Create web handler with unique bridge + web_handler_ = std::make_unique(setting_handler_, bridge_.get()); + + // Create settings UI + settings_ui_ = std::make_unique(); + settings_ui_->init(parent, interaction_handler); + + // Start web server and setup + setup_web_server_(); + + return ESP_OK; +} + +esp_err_t SettingsUIHandler::deinit(void) { + ESP_LOGI(TAG, "Deinitializing Settings UI Handler"); + + // Stop web server + if (web_handler_) { + web_handler_->stop_web_server(); + web_handler_.reset(); + } + + // Stop bridge + if (bridge_) { + bridge_->stop_polling_task(); + bridge_.reset(); + } + + // Clean up UI + if (settings_ui_) { + settings_ui_->deinit(); + settings_ui_.reset(); + } + + return ESP_OK; +} + +// ============================================================================ +// Private Methods +// ============================================================================ + +void SettingsUIHandler::setup_web_server_() { + // Start web server + web_handler_->start_web_server(); + + if (web_handler_->is_running()) { + std::string device_ip = web_handler_->get_device_ip(); + uint16_t port = web_handler_->get_port(); + + if (!device_ip.empty()) { + std::string url = web_handler_->get_url(); + + settings_ui_->set_config_url(url); + + std::ostringstream status; + status << "Server running on " << device_ip << ":" << port; + settings_ui_->set_status_message(status.str()); + + ESP_LOGI(TAG, "QR code URL: %s", url.c_str()); + } else { + settings_ui_->set_status_message("Error: No IP address"); + ESP_LOGE(TAG, "Failed to get device IP address"); + } + } else { + settings_ui_->set_status_message("Error: Failed to start server"); + ESP_LOGE(TAG, "Web server failed to start"); + } +} diff --git a/main/ui/apps/iotdis/ui/settings_handler.h b/main/ui/apps/iotdis/ui/settings_handler.h new file mode 100644 index 0000000..82721f3 --- /dev/null +++ b/main/ui/apps/iotdis/ui/settings_handler.h @@ -0,0 +1,33 @@ +#pragma once + +#include "ui/apps/iotdis/ui/settings.h" +#include "ui/interaction_handler.h" +#include "ui/apps/iotdis/bridge/bridge.h" +#include "ui/apps/iotdis/settings/settings_handler.h" +#include "ui/apps/iotdis/web/web_handlers.h" +#include "esp_err.h" +#include + +/** + * @brief Settings UI Handler for Discord App + * + * Manages the SettingsUI instance, web server, and interaction with the InteractionHandler. + * Each handler instance has its own IotDisBridge to prevent conflicts. + */ +class SettingsUIHandler { +public: + + SettingsUIHandler(); + ~SettingsUIHandler(); + + esp_err_t init(lv_obj_t* parent, InteractionHandler* interaction_handler, SettingHandler* setting_handler); + esp_err_t deinit(void); + +private: + void setup_web_server_(); + + std::unique_ptr settings_ui_ = nullptr; + std::unique_ptr bridge_ = nullptr; + std::unique_ptr web_handler_ = nullptr; + SettingHandler* setting_handler_ = nullptr; // Not owned +}; diff --git a/main/ui/apps/iotdis/web/web_handlers.cpp b/main/ui/apps/iotdis/web/web_handlers.cpp new file mode 100644 index 0000000..70de44d --- /dev/null +++ b/main/ui/apps/iotdis/web/web_handlers.cpp @@ -0,0 +1,362 @@ +#include "web_handlers.h" +#include "../app.h" +#include "esp_log.h" +#include "network/network.h" +#include "common/system_context.h" +#include "esp_random.h" +#include +#include + +static const char* TAG = "DiscordWebHandler"; + +WebHandler::~WebHandler() { + stop_web_server(); +} + +esp_err_t WebHandler::start_web_server() { + if (web_server_ && web_server_->is_running()) { + ESP_LOGI(TAG, "Web server already running"); + return ESP_OK; + } + + auth_key_ = generate_auth_key_(); + + esp_err_t ret = web_server_->start( + auth_key_, + 8080 + ); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "Failed to start web server"); + return ret; + } + + ret = register_web_endpoints_(); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "Failed to register web endpoints"); + web_server_->stop(); + return ret; + } + + ESP_LOGI(TAG, "Web server started"); + return ESP_OK; +} + +esp_err_t WebHandler::stop_web_server() { + if (web_server_) { + web_server_->stop(); + ESP_LOGI(TAG, "Web server stopped"); + } + auth_key_.clear(); + return ESP_OK; +} + +std::string WebHandler::get_url() const { + if (web_server_ && web_server_->is_running()) { + NetworkHandler* network_handler = SystemContext::instance().get_network_handler(); + if (!network_handler) { + ESP_LOGE(TAG, "Network handler not available in system context"); + return ""; + } + WifiHandler& wifi_handler = network_handler->get_wifi_handler(); + std::string device_ip = wifi_handler.get_current_ip(); + if (device_ip.empty()) { + ESP_LOGW(TAG, "Device not connected to WiFi"); + return ""; + } + uint16_t port = web_server_->get_port(); + + std::ostringstream url; + url << "http://" << device_ip << ":" << port << "/?auth=" << auth_key_; + return url.str(); + } + return ""; +} + +std::string WebHandler::get_device_ip() const { + if (web_server_ && web_server_->is_running()) { + NetworkHandler* network_handler = SystemContext::instance().get_network_handler(); + if (!network_handler) { + ESP_LOGE(TAG, "Network handler not available in system context"); + return ""; + } + WifiHandler& wifi_handler = network_handler->get_wifi_handler(); + return wifi_handler.get_current_ip(); + } + return ""; +} + +uint16_t WebHandler::get_port() const { + if (web_server_ && web_server_->is_running()) { + return web_server_->get_port(); + } + return 0; +} + +// +// +// + + +std::string WebHandler::generate_auth_key_() { + // Generate 128-bit random key using ESP32 hardware RNG + uint32_t rand_values[4]; + for (int i = 0; i < 4; i++) { + rand_values[i] = esp_random(); + } + + // Convert to hex string + std::ostringstream oss; + oss << std::hex << std::setfill('0'); + for (int i = 0; i < 4; i++) { + oss << std::setw(8) << rand_values[i]; + } + + return oss.str(); +} + + +esp_err_t WebHandler::register_web_endpoints_() { + if (!web_server_ || !web_server_->is_running()) { + return ESP_FAIL; + } + + // GET / - Serve settings page + httpd_uri_t settings_page_uri = { + .uri = "/", + .method = HTTP_GET, + .handler = settings_page_handler_, + .user_ctx = this + }; + web_server_->register_uri_handler(&settings_page_uri); + + // POST /save - Save settings + httpd_uri_t save_settings_uri = { + .uri = "/save", + .method = HTTP_POST, + .handler = save_settings_handler_, + .user_ctx = this + }; + web_server_->register_uri_handler(&save_settings_uri); + + // POST /test - Test connection + httpd_uri_t test_connection_uri = { + .uri = "/test", + .method = HTTP_POST, + .handler = test_connection_handler_, + .user_ctx = this + }; + web_server_->register_uri_handler(&test_connection_uri); + + return ESP_OK; +} + +esp_err_t WebHandler::settings_page_handler_(httpd_req_t* req) { + WebHandler* self = static_cast(req->user_ctx); + + // Validate auth + size_t query_len = httpd_req_get_url_query_len(req); + if (query_len == 0) { + httpd_resp_send_err(req, HTTPD_401_UNAUTHORIZED, "Unauthorized"); + return ESP_FAIL; + } + + char* query = new char[query_len + 1]; + if (httpd_req_get_url_query_str(req, query, query_len + 1) != ESP_OK) { + delete[] query; + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Bad Request"); + return ESP_FAIL; + } + + if (!self->web_server_->validate_auth(query)) { + delete[] query; + httpd_resp_send_err(req, HTTPD_401_UNAUTHORIZED, "Unauthorized"); + return ESP_FAIL; + } + delete[] query; + + // Get current settings (access private members via friend) + std::string current_ip = self->setting_handler_->get_remote_ip(); + uint16_t current_port = self->setting_handler_->get_remote_port(); + uint16_t current_local_port = self->setting_handler_->get_local_port(); + + // Build HTML page + std::ostringstream html; + html << "" + << "" + << "Discord Bridge Settings" + << "" + << "

Discord Bridge Settings

" + << "
" + << "" + << "" + << "" + << "" + << "" + << "" + << "" + << "" + << "
" + << "
" + << ""; + + std::string html_str = html.str(); + httpd_resp_set_type(req, "text/html"); + httpd_resp_send(req, html_str.c_str(), html_str.length()); + + return ESP_OK; +} + +esp_err_t WebHandler::save_settings_handler_(httpd_req_t* req) { + WebHandler* self = static_cast(req->user_ctx); + + // Read POST data + char* buf = new char[req->content_len + 1]; + int ret = httpd_req_recv(req, buf, req->content_len); + if (ret <= 0) { + delete[] buf; + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Failed to read request"); + return ESP_FAIL; + } + buf[ret] = '\0'; + + // Parse form data + char ip[64] = { 0 }; + char port_str[8] = { 0 }; + char local_port_str[8] = { 0 }; + + httpd_query_key_value(buf, "ip", ip, sizeof(ip)); + httpd_query_key_value(buf, "port", port_str, sizeof(port_str)); + httpd_query_key_value(buf, "localPort", local_port_str, sizeof(local_port_str)); + delete[] buf; + + if (strlen(ip) == 0 || strlen(port_str) == 0 || strlen(local_port_str) == 0) { + const char* resp = "{\"success\":false,\"message\":\"Missing fields\"}"; + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, resp, strlen(resp)); + return ESP_OK; + } + + uint16_t port = static_cast(atoi(port_str)); + uint16_t local_port = static_cast(atoi(local_port_str)); + if (port == 0 || local_port == 0) { + const char* resp = "{\"success\":false,\"message\":\"Invalid port\"}"; + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, resp, strlen(resp)); + return ESP_OK; + } + + // Save settings + if (self && self->setting_handler_) { + self->setting_handler_->save_settings(std::string(ip), port, local_port); + } + + const char* resp = "{\"success\":true,\"message\":\"Settings saved successfully\"}"; + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, resp, strlen(resp)); + + ESP_LOGI(TAG, "Settings saved via web interface: %s:%u (local port: %u)", ip, port, local_port); + + return ESP_OK; +} + +esp_err_t WebHandler::test_connection_handler_(httpd_req_t* req) { + WebHandler* self = static_cast(req->user_ctx); + IotDisBridge* bridge = self ? self->bridge_ : nullptr; + + // Read POST data + char* buf = new char[req->content_len + 1]; + int ret = httpd_req_recv(req, buf, req->content_len); + if (ret <= 0) { + delete[] buf; + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Failed to read request"); + return ESP_FAIL; + } + buf[ret] = '\0'; + + // Parse form data + char ip[64] = { 0 }; + char port_str[8] = { 0 }; + char local_port_str[8] = { 0 }; + + httpd_query_key_value(buf, "ip", ip, sizeof(ip)); + httpd_query_key_value(buf, "port", port_str, sizeof(port_str)); + httpd_query_key_value(buf, "localPort", local_port_str, sizeof(local_port_str)); + delete[] buf; + + if (strlen(ip) == 0 || strlen(port_str) == 0 || strlen(local_port_str) == 0) { + const char* resp = "{\"success\":false,\"message\":\"Missing fields\"}"; + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, resp, strlen(resp)); + return ESP_OK; + } + + uint16_t port = static_cast(atoi(port_str)); + uint16_t local_port = static_cast(atoi(local_port_str)); + if (port == 0 || local_port == 0) { + const char* resp = "{\"success\":false,\"message\":\"Invalid port\"}"; + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, resp, strlen(resp)); + return ESP_OK; + } + + // Test connection + bool success = false; + if (bridge) { + success = bridge->test_connection(std::string(ip), port, local_port); + } else { + ESP_LOGE(TAG, "IotDisBridge pointer is null, cannot test connection"); + } + + const char* resp = success + ? "{\"success\":true,\"message\":\"Connection successful!\"}" + : "{\"success\":false,\"message\":\"No response from bridge\"}"; + + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, resp, strlen(resp)); + + ESP_LOGI(TAG, "Connection test via web interface: %s:%u (local port: %u) - %s", ip, port, local_port, success ? "SUCCESS" : "FAILED"); + + return ESP_OK; +} diff --git a/main/ui/apps/iotdis/web/web_handlers.h b/main/ui/apps/iotdis/web/web_handlers.h new file mode 100644 index 0000000..b0b568d --- /dev/null +++ b/main/ui/apps/iotdis/web/web_handlers.h @@ -0,0 +1,81 @@ +#pragma once + +#include "esp_http_server.h" +#include +#include "network/web_server_handler.h" +#include "ui/apps/iotdis/settings/settings_handler.h" +#include "ui/apps/iotdis/bridge/bridge.h" + +/** + * @brief HTTP request handlers for Discord Bridge settings web interface + * + * These handlers serve the web configuration page and process + * settings updates and connection tests. + */ +class WebHandler { +public: + WebHandler( + SettingHandler* setting_handler, + IotDisBridge* bridge + ) : + web_server_(std::make_unique()) + , setting_handler_(setting_handler) + , bridge_(bridge) { } + ~WebHandler(); + + esp_err_t start_web_server(); + esp_err_t stop_web_server(); + + std::string get_url() const; + std::string get_device_ip() const; + uint16_t get_port() const; + + bool is_running() const { + return web_server_ && web_server_->is_running(); + } + +private: + + std::string generate_auth_key_(); + + esp_err_t register_web_endpoints_(); + + /** + * @brief Serve the main settings configuration page + * + * Validates authentication and serves an HTML form with current settings. + * Requires auth query parameter matching the session key. + * + * @param req HTTP request object + * @return ESP_OK on success + */ + static esp_err_t settings_page_handler_(httpd_req_t* req); + + /** + * @brief Save bridge connection settings + * + * Parses POST data containing ip, port, and localPort fields. + * Validates and persists settings to NVS storage. + * + * @param req HTTP request object + * @return ESP_OK on success + */ + static esp_err_t save_settings_handler_(httpd_req_t* req); + + /** + * @brief Test connection to Discord bridge + * + * Creates temporary UDP client to test connectivity with provided settings. + * Returns JSON response indicating success or failure. + * + * @param req HTTP request object + * @return ESP_OK on success + */ + static esp_err_t test_connection_handler_(httpd_req_t* req); + + std::unique_ptr web_server_; + SettingHandler* setting_handler_ = nullptr; ///< Pointer to settings handler (not owned) + + std::string auth_key_; + IotDisBridge* bridge_ = nullptr; ///< Pointer to IotDisBridge (not owned) +}; diff --git a/main/ui/apps/registry.cpp b/main/ui/apps/registry.cpp new file mode 100644 index 0000000..528d568 --- /dev/null +++ b/main/ui/apps/registry.cpp @@ -0,0 +1,9 @@ +#include "ui/apps/registry.h" + +#include "ui/apps/iotdis/descriptor.h" + +esp_err_t AppRegistry::init(void) { + register_app(std::make_unique()); + + return ESP_OK; +} diff --git a/main/ui/apps/registry.h b/main/ui/apps/registry.h index 7d821e3..cbafe5d 100644 --- a/main/ui/apps/registry.h +++ b/main/ui/apps/registry.h @@ -13,6 +13,12 @@ public: return registry; } + /** + * @brief Initialize the app registry with built-in apps + * + */ + esp_err_t init(void); + void register_app(std::unique_ptr app_descriptor) { if (app_descriptors_.find(app_descriptor->get_name()) != app_descriptors_.end()) { // App already registered diff --git a/main/ui/interaction_handler.cpp b/main/ui/interaction_handler.cpp index 72534e2..7069d91 100644 --- a/main/ui/interaction_handler.cpp +++ b/main/ui/interaction_handler.cpp @@ -12,13 +12,20 @@ InteractionHandler::~InteractionHandler() { } } -esp_err_t InteractionHandler::init(lv_obj_t* app_container) { - if (!app_container) { - ESP_LOGE(TAG, "Invalid argument: app_container is nullptr"); +esp_err_t InteractionHandler::init(lv_obj_t* parent_container) { + if (!parent_container) { + ESP_LOGE(TAG, "Invalid argument: parent_container is nullptr"); return ESP_ERR_INVALID_ARG; } - app_container_ = app_container; - keyboard_ = lv_keyboard_create(app_container_); + parent_container_ = parent_container; + + keyboard_ = lv_keyboard_create(parent_container_); + if (!keyboard_) { + ESP_LOGE(TAG, "Failed to create keyboard object"); + return ESP_ERR_NO_MEM; + } + + ESP_LOGI(TAG, "Keyboard created successfully at %p", keyboard_); lv_obj_add_flag(keyboard_, LV_OBJ_FLAG_HIDDEN); // start hidden lv_obj_add_event_cb( keyboard_, @@ -102,11 +109,24 @@ void InteractionHandler::on_keyboard_event_(lv_event_t* e) { } 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_"); + if (!textarea) { + ESP_LOGE(TAG, "Invalid argument: textarea is nullptr"); return ESP_ERR_INVALID_ARG; } + if (!keyboard_) { + ESP_LOGE(TAG, "Keyboard object is nullptr - was InteractionHandler properly initialized?"); + return ESP_ERR_INVALID_STATE; + } + + // Verify keyboard object is still valid + if (!lv_obj_is_valid(keyboard_)) { + ESP_LOGE(TAG, "Keyboard object is no longer valid - it may have been deleted"); + keyboard_ = nullptr; + return ESP_ERR_INVALID_STATE; + } + + ESP_LOGI(TAG, "Showing keyboard for textarea %p", textarea); focused_textarea_ = textarea; lv_keyboard_set_textarea(keyboard_, textarea); lv_obj_clear_flag(keyboard_, LV_OBJ_FLAG_HIDDEN); diff --git a/main/ui/interaction_handler.h b/main/ui/interaction_handler.h index 0df0c4c..93f2ac7 100644 --- a/main/ui/interaction_handler.h +++ b/main/ui/interaction_handler.h @@ -28,9 +28,10 @@ public: * * Sets up necessary event listeners and state. * + * @param parent_container Parent container for keyboard (typically the screen) * @return ESP_OK on success, error code otherwise */ - esp_err_t init(lv_obj_t* app_container); + esp_err_t init(lv_obj_t* parent_container); /** * @brief Deinitialize the Interaction Handler @@ -58,8 +59,8 @@ private: 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; + // Parent container (typically screen), reference only + lv_obj_t* parent_container_ = nullptr; // owned keyboard object lv_obj_t* keyboard_ = nullptr; // Currently focused textarea, reference only diff --git a/main/ui/ui_handler.cpp b/main/ui/ui_handler.cpp index 3528b30..b01a146 100644 --- a/main/ui/ui_handler.cpp +++ b/main/ui/ui_handler.cpp @@ -1,8 +1,14 @@ #include "ui/ui_handler.h" +#include "ui/apps/registry.h" #include "esp_log.h" #define TAG "UIHandler" +struct AppClickUserData { + UIHandler* ui_handler; + std::string app_name; +}; + UIHandler::~UIHandler() { deinit(); } @@ -18,7 +24,9 @@ esp_err_t UIHandler::init(void) { return ret; } - ret = interaction_handler_.init(root_layout_.get_app_container()); + // Initialize InteractionHandler with screen as parent (not app_container) + // so keyboard survives app switches + ret = interaction_handler_.init(screen); if (ret != ESP_OK) { ESP_LOGE(TAG, "Failed to initialize InteractionHandler"); return ret; @@ -61,7 +69,7 @@ esp_err_t UIHandler::deinit(void) { return ESP_OK; } -esp_err_t UIHandler::switch_app(std::shared_ptr app_descriptor) { +esp_err_t UIHandler::switch_app(AppDescriptor* app_descriptor) { if (!app_descriptor) { ESP_LOGE(TAG, "Invalid app descriptor"); return ESP_ERR_INVALID_ARG; @@ -100,7 +108,7 @@ esp_err_t UIHandler::switch_app(std::shared_ptr app_descriptor) { return ESP_ERR_INVALID_STATE; } - ret = new_app->init(app_container); + ret = new_app->init(app_container, &interaction_handler_); if (ret != ESP_OK) { ESP_LOGE(TAG, "Failed to initialize app: %s", new_app->get_name().c_str()); active_descriptor_ = nullptr; @@ -239,6 +247,45 @@ esp_err_t UIHandler::create_main_screen_(lv_obj_t* parent) { return ret; } + // render all apps + + for (const auto& [name, descriptor] : AppRegistry::instance()) { + lv_obj_t* app_icon_container = lv_obj_create(root_layout_.get_app_container()); + lv_obj_set_size(app_icon_container, 100, 100); + lv_obj_set_style_pad_all(app_icon_container, 10, 0); + lv_obj_set_flex_flow(app_icon_container, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(app_icon_container, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + // Draw the app icon + descriptor->draw_icon(app_icon_container); + + // App name label + lv_obj_t* label = lv_label_create(app_icon_container); + lv_label_set_text(label, name.c_str()); + lv_obj_set_style_text_align(label, LV_TEXT_ALIGN_CENTER, 0); + + // Center the icon container + lv_obj_center(app_icon_container); + + // Register click event to switch to the app + lv_obj_add_event_cb(app_icon_container, + [](lv_event_t* e) { + AppClickUserData* user_data = static_cast(lv_event_get_user_data(e)); + UIHandler* ui_handler = user_data->ui_handler; + std::string app_name = user_data->app_name; + + AppDescriptor* descriptor = AppRegistry::instance()[app_name]; + if (descriptor) { + ui_handler->switch_app(descriptor); + } else { + ESP_LOGE(TAG, "App descriptor not found for app: %s", app_name.c_str()); + } + }, + LV_EVENT_CLICKED, + new AppClickUserData { this, name } + ); + } + // Register back button callback lv_event_dsc_t* back_event_dsc = nullptr; ret = root_layout_.register_back_button_callback( diff --git a/main/ui/ui_handler.h b/main/ui/ui_handler.h index 0714541..4a9ce68 100644 --- a/main/ui/ui_handler.h +++ b/main/ui/ui_handler.h @@ -53,13 +53,13 @@ public: * @brief Switch to a new app by its descriptor * * Deinitializes the current app (if any), initializes the new app, - * and updates the display. Holds shared ownership of the descriptor - * to ensure the app remains valid while active. + * and updates the display. The descriptor must remain valid in the + * AppRegistry for the lifetime of the app. * - * @param app_descriptor Shared pointer to the app descriptor + * @param app_descriptor Pointer to the app descriptor (managed by AppRegistry) * @return ESP_OK on success, error code otherwise */ - esp_err_t switch_app(std::shared_ptr app_descriptor); + esp_err_t switch_app(AppDescriptor* app_descriptor); /** * @brief Display shutdown screen @@ -114,5 +114,5 @@ private: lv_obj_t* main_screen_ = nullptr; ///< Root screen RootLayout root_layout_; ///< Main screen layout manager - std::shared_ptr active_descriptor_ = nullptr; ///< Currently active app descriptor (shared ownership) + AppDescriptor* active_descriptor_ = nullptr; ///< Currently active app descriptor (managed by AppRegistry) }; diff --git a/sdkconfig.default b/sdkconfig.default index 056c866..f1a3067 100644 --- a/sdkconfig.default +++ b/sdkconfig.default @@ -2338,7 +2338,7 @@ CONFIG_LV_FS_DEFAULT_DRIVER_LETTER=0 # CONFIG_LV_USE_GIF is not set # CONFIG_LV_BIN_DECODER_RAM_LOAD is not set # CONFIG_LV_USE_RLE is not set -# CONFIG_LV_USE_QRCODE is not set +CONFIG_LV_USE_QRCODE=y # CONFIG_LV_USE_BARCODE is not set # CONFIG_LV_USE_FREETYPE is not set # CONFIG_LV_USE_TINY_TTF is not set