diff --git a/main/main.cpp b/main/main.cpp index 0b5ffc7..bde5bd5 100644 --- a/main/main.cpp +++ b/main/main.cpp @@ -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 #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_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( diff --git a/main/ui/app_registry.h b/main/ui/app_registry.h deleted file mode 100644 index 1b3b463..0000000 --- a/main/ui/app_registry.h +++ /dev/null @@ -1,39 +0,0 @@ -#pragma once -#include "ui/ui_app.h" -#include - -/** - * @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& get_app_descriptors() const { - return _app_descriptors; - } - -private: - AppRegistry() = default; - ~AppRegistry() = default; - std::vector _app_descriptors = {}; -}; \ No newline at end of file diff --git a/main/ui/ui_app.h b/main/ui/apps/app.h similarity index 67% rename from main/ui/ui_app.h rename to main/ui/apps/app.h index 42b6bac..69fd64d 100644 --- a/main/ui/ui_app.h +++ b/main/ui/apps/app.h @@ -3,6 +3,7 @@ #include "lvgl.h" #include "esp_err.h" #include +#include /** * @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 app_instance) + : name_(name), app_instance_(std::move(app_instance)) { } - std::string _name; - UIApp* _app_instance; -}; \ No newline at end of file + std::string name_; + std::unique_ptr app_instance_; +}; diff --git a/main/ui/apps/discord_app.cpp b/main/ui/apps/discord_app.cpp deleted file mode 100644 index 954aba6..0000000 --- a/main/ui/apps/discord_app.cpp +++ /dev/null @@ -1,652 +0,0 @@ -#include "discord_app.h" -#include "esp_log.h" -#include "network/network.h" -#include - -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(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(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(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(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(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); -} diff --git a/main/ui/apps/discord_app.h b/main/ui/apps/discord_app.h deleted file mode 100644 index d1e5f6e..0000000 --- a/main/ui/apps/discord_app.h +++ /dev/null @@ -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 - -/** - * @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(); -}; diff --git a/main/ui/apps/mtr_app.cpp b/main/ui/apps/mtr_app.cpp deleted file mode 100644 index 7108a73..0000000 --- a/main/ui/apps/mtr_app.cpp +++ /dev/null @@ -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(); -} - -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(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* 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* 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* 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); -} diff --git a/main/ui/apps/mtr_app.h b/main/ui/apps/mtr_app.h deleted file mode 100644 index dd87dd6..0000000 --- a/main/ui/apps/mtr_app.h +++ /dev/null @@ -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 -#include - -/** - * @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 _mtr_handler; - std::unique_ptr _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 _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; -}; diff --git a/main/ui/apps/registry.h b/main/ui/apps/registry.h new file mode 100644 index 0000000..7d821e3 --- /dev/null +++ b/main/ui/apps/registry.h @@ -0,0 +1,53 @@ +#pragma once + +#include "ui/apps/app.h" +#include +#include +#include "esp_log.h" +#include + +class AppRegistry { +public: + static AppRegistry& instance() { + static AppRegistry registry; + return registry; + } + + void register_app(std::unique_ptr 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> 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; +}; diff --git a/main/ui/apps/shutdown_app.cpp b/main/ui/apps/shutdown_app.cpp deleted file mode 100644 index 19593cd..0000000 --- a/main/ui/apps/shutdown_app.cpp +++ /dev/null @@ -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); -} diff --git a/main/ui/apps/shutdown_app.h b/main/ui/apps/shutdown_app.h deleted file mode 100644 index 87c72d4..0000000 --- a/main/ui/apps/shutdown_app.h +++ /dev/null @@ -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; -}; diff --git a/main/ui/events.cpp b/main/ui/events.cpp new file mode 100644 index 0000000..f416a8c --- /dev/null +++ b/main/ui/events.cpp @@ -0,0 +1,4 @@ +#include "events.h" + +// Define the event base +ESP_EVENT_DEFINE_BASE(UI_EVENT_BASE); diff --git a/main/ui/events.h b/main/ui/events.h new file mode 100644 index 0000000..6ae0863 --- /dev/null +++ b/main/ui/events.h @@ -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 +}; diff --git a/main/ui/interaction_handler.cpp b/main/ui/interaction_handler.cpp new file mode 100644 index 0000000..72534e2 --- /dev/null +++ b/main/ui/interaction_handler.cpp @@ -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(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(lv_event_get_user_data(e)); + + esp_err_t err = handler->show_keyboard_for_textarea_(static_cast(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(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; +} diff --git a/main/ui/interaction_handler.h b/main/ui/interaction_handler.h new file mode 100644 index 0000000..0df0c4c --- /dev/null +++ b/main/ui/interaction_handler.h @@ -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; +}; diff --git a/main/ui/page_stack.cpp b/main/ui/page_stack.cpp deleted file mode 100644 index 3b65173..0000000 --- a/main/ui/page_stack.cpp +++ /dev/null @@ -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; -} diff --git a/main/ui/page_stack.h b/main/ui/page_stack.h deleted file mode 100644 index 0a3633a..0000000 --- a/main/ui/page_stack.h +++ /dev/null @@ -1,86 +0,0 @@ -#pragma once - -#include "lvgl.h" -#include -#include - -/** - * @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; - - /** - * @brief Page cleanup callback - * @param page_container The LVGL container being destroyed - */ - using PageCleanup = std::function; - - /** - * @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 pages_; - - /** - * @brief Create a page container - */ - lv_obj_t* create_page_container(); -}; diff --git a/main/ui/root_layout.cpp b/main/ui/root_layout.cpp index 1e7c0a8..0f333da 100644 --- a/main/ui/root_layout.cpp +++ b/main/ui/root_layout.cpp @@ -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(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_event_get_current_target(event)); - UIHandler* handler = static_cast(lv_event_get_user_data(event)); - AppDescriptor* descriptor = static_cast(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(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(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; + } } diff --git a/main/ui/root_layout.h b/main/ui/root_layout.h index c4a4f28..87ed41d 100644 --- a/main/ui/root_layout.h +++ b/main/ui/root_layout.h @@ -1,138 +1,126 @@ #pragma once - -#include "lvgl.h" #include "esp_err.h" +#include "esp_event.h" +#include "lvgl.h" #include -// 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 }; diff --git a/main/ui/ui_handler.cpp b/main/ui/ui_handler.cpp index d962dbe..3528b30 100644 --- a/main/ui/ui_handler.cpp +++ b/main/ui/ui_handler.cpp @@ -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_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 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(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(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; +} + + diff --git a/main/ui/ui_handler.h b/main/ui/ui_handler.h index 4ec419a..0714541 100644 --- a/main/ui/ui_handler.h +++ b/main/ui/ui_handler.h @@ -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 /** * @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 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 active_descriptor_ = nullptr; ///< Currently active app descriptor (shared ownership) }; diff --git a/main/ui/widgets/textarea.cpp b/main/ui/widgets/textarea.cpp new file mode 100644 index 0000000..e5e430c --- /dev/null +++ b/main/ui/widgets/textarea.cpp @@ -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; +} + diff --git a/main/ui/widgets/textarea.h b/main/ui/widgets/textarea.h new file mode 100644 index 0000000..6161daf --- /dev/null +++ b/main/ui/widgets/textarea.h @@ -0,0 +1,4 @@ +#pragma once +#include "lvgl.h" + +lv_obj_t* textarea_create(lv_obj_t* parent);