Refactor RootLayout and UIHandler for improved structure and functionality
- Updated RootLayout to manage layout initialization and deinitialization more effectively. - Removed unnecessary dependencies and streamlined event handling for keyboard events. - Enhanced UIHandler to utilize shared pointers for app descriptors, improving memory management. - Added methods for showing and hiding navigation elements in RootLayout. - Introduced textarea widget with instant response by disabling animations. - Improved error handling and logging throughout the UI components.
This commit is contained in:
87
main/ui/apps/app.h
Normal file
87
main/ui/apps/app.h
Normal file
@@ -0,0 +1,87 @@
|
||||
#pragma once
|
||||
|
||||
#include "lvgl.h"
|
||||
#include "esp_err.h"
|
||||
#include <string>
|
||||
#include <memory>
|
||||
|
||||
/**
|
||||
* @brief Base class for all UI applications
|
||||
*
|
||||
* All UI applications (apps) must inherit from this class.
|
||||
* Each app is responsible for managing its own widgets within
|
||||
* the provided LVGL container. The UIHandler will manage the
|
||||
* lifecycle of apps and event routing.
|
||||
*/
|
||||
class UIApp {
|
||||
public:
|
||||
virtual ~UIApp() = default;
|
||||
|
||||
/**
|
||||
* @brief Initialize the app with the given container
|
||||
*
|
||||
* The app should create all its widgets as children of the
|
||||
* provided container. The container is already positioned
|
||||
* between the header and navigation bar.
|
||||
*
|
||||
* @param container LVGL container object for this app
|
||||
* @return ESP_OK on success, error code otherwise
|
||||
*/
|
||||
virtual esp_err_t init(lv_obj_t* container) = 0;
|
||||
|
||||
/**
|
||||
* @brief Deinitialize and clean up app resources
|
||||
*
|
||||
* The app should delete all widgets and release any resources.
|
||||
* The container itself will be handled by UIHandler.
|
||||
*
|
||||
* @return ESP_OK on success, error code otherwise
|
||||
*/
|
||||
virtual esp_err_t deinit(void) = 0;
|
||||
|
||||
/**
|
||||
* @brief Get the display name of this app
|
||||
*
|
||||
* Used for logging and potentially showing in navigation.
|
||||
*
|
||||
* @return std::string app name
|
||||
*/
|
||||
virtual std::string get_name(void) const = 0;
|
||||
|
||||
/**
|
||||
* @brief Handle back button press
|
||||
*
|
||||
* 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).
|
||||
*
|
||||
* @return true if the event was handled, false otherwise
|
||||
*/
|
||||
virtual bool on_back_button_pressed(void) {
|
||||
return false; // default: not handled
|
||||
}
|
||||
|
||||
protected:
|
||||
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_;
|
||||
}
|
||||
|
||||
UIApp* get_app_instance() const {
|
||||
return app_instance_.get();
|
||||
}
|
||||
|
||||
protected:
|
||||
AppDescriptor(std::string name, std::unique_ptr<UIApp> app_instance)
|
||||
: name_(name), app_instance_(std::move(app_instance)) { }
|
||||
|
||||
std::string name_;
|
||||
std::unique_ptr<UIApp> app_instance_;
|
||||
};
|
||||
@@ -1,652 +0,0 @@
|
||||
#include "discord_app.h"
|
||||
#include "esp_log.h"
|
||||
#include "network/network.h"
|
||||
#include <sstream>
|
||||
|
||||
static const char* TAG = "DiscordApp";
|
||||
|
||||
// ============================================================================
|
||||
// DiscordApp Implementation
|
||||
// ============================================================================
|
||||
|
||||
DiscordApp::DiscordApp()
|
||||
: page_stack_(nullptr)
|
||||
, status_icon_label_(nullptr)
|
||||
, status_text_label_(nullptr)
|
||||
, mute_button_(nullptr)
|
||||
, error_notification_(nullptr)
|
||||
, ip_textarea_(nullptr)
|
||||
, port_textarea_(nullptr)
|
||||
, test_result_label_(nullptr)
|
||||
, remote_port_(0)
|
||||
, settings_configured_(false)
|
||||
, current_state_(VoiceState::UNKNOWN)
|
||||
, state_mutex_(nullptr)
|
||||
, poll_task_handle_(nullptr)
|
||||
, stop_polling_(false)
|
||||
, consecutive_failures_(0)
|
||||
, storage_(nullptr) {
|
||||
|
||||
// Create mutex for thread-safe state access
|
||||
state_mutex_ = xSemaphoreCreateMutex();
|
||||
|
||||
// Initialize storage
|
||||
storage_ = new NVSStorageHandler(NVS_NAMESPACE);
|
||||
}
|
||||
|
||||
DiscordApp::~DiscordApp() {
|
||||
stop_polling_task();
|
||||
|
||||
if (state_mutex_) {
|
||||
vSemaphoreDelete(state_mutex_);
|
||||
}
|
||||
|
||||
if (storage_) {
|
||||
delete storage_;
|
||||
}
|
||||
}
|
||||
|
||||
esp_err_t DiscordApp::init(lv_obj_t* container) {
|
||||
ESP_LOGI(TAG, "Initializing Discord app");
|
||||
|
||||
_container = container;
|
||||
|
||||
// Initialize storage
|
||||
storage_->init(nullptr);
|
||||
|
||||
// Load saved settings
|
||||
load_settings();
|
||||
|
||||
// Initialize UDP client
|
||||
udp_client_.init();
|
||||
|
||||
// Configure UDP if settings are available
|
||||
if (settings_configured_) {
|
||||
udp_client_.configure(remote_ip_, remote_port_);
|
||||
}
|
||||
|
||||
// Create page stack
|
||||
page_stack_ = new PageStack(container);
|
||||
|
||||
// Build main page
|
||||
page_stack_->push([this](lv_obj_t* page) {
|
||||
build_main_page(page);
|
||||
});
|
||||
|
||||
// Start polling task
|
||||
start_polling_task();
|
||||
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t DiscordApp::deinit() {
|
||||
ESP_LOGI(TAG, "Deinitializing Discord app");
|
||||
|
||||
// Stop polling
|
||||
stop_polling_task();
|
||||
|
||||
// Clean up page stack
|
||||
if (page_stack_) {
|
||||
delete page_stack_;
|
||||
page_stack_ = nullptr;
|
||||
}
|
||||
|
||||
// Close UDP client
|
||||
udp_client_.close();
|
||||
|
||||
// Reset widget pointers
|
||||
status_icon_label_ = nullptr;
|
||||
status_text_label_ = nullptr;
|
||||
mute_button_ = nullptr;
|
||||
error_notification_ = nullptr;
|
||||
ip_textarea_ = nullptr;
|
||||
port_textarea_ = nullptr;
|
||||
test_result_label_ = nullptr;
|
||||
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
void DiscordApp::handle_event(uint32_t event_type, void* event_data) {
|
||||
// Handle system events if needed
|
||||
}
|
||||
|
||||
bool DiscordApp::on_back_button_pressed() {
|
||||
// If on settings page, go back to main page
|
||||
if (page_stack_ && page_stack_->depth() > 1) {
|
||||
page_stack_->pop();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Let system handle back (return to app icons)
|
||||
return false;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Page UI
|
||||
// ============================================================================
|
||||
|
||||
void DiscordApp::build_main_page(lv_obj_t* page) {
|
||||
// Set up main page with flex column layout
|
||||
lv_obj_set_flex_flow(page, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_flex_align(page, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_set_style_pad_all(page, 10, 0);
|
||||
|
||||
// === Top Section: Error Notification ===
|
||||
error_notification_ = lv_obj_create(page);
|
||||
lv_obj_set_width(error_notification_, LV_PCT(90));
|
||||
lv_obj_set_height(error_notification_, LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_bg_color(error_notification_, lv_color_hex(0xFF0000), 0);
|
||||
lv_obj_set_style_bg_opa(error_notification_, LV_OPA_70, 0);
|
||||
lv_obj_set_style_pad_all(error_notification_, 10, 0);
|
||||
lv_obj_set_style_radius(error_notification_, 8, 0);
|
||||
lv_obj_add_flag(error_notification_, LV_OBJ_FLAG_HIDDEN);
|
||||
lv_obj_set_flex_flow(error_notification_, LV_FLEX_FLOW_ROW);
|
||||
lv_obj_set_flex_align(error_notification_, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
|
||||
lv_obj_t* error_label = lv_label_create(error_notification_);
|
||||
lv_label_set_text(error_label, LV_SYMBOL_WARNING " Connection Lost");
|
||||
lv_obj_set_style_text_color(error_label, lv_color_white(), 0);
|
||||
|
||||
// === Center Section: Main Content ===
|
||||
lv_obj_t* center_container = lv_obj_create(page);
|
||||
lv_obj_set_size(center_container, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_bg_opa(center_container, LV_OPA_TRANSP, 0);
|
||||
lv_obj_set_style_border_width(center_container, 0, 0);
|
||||
lv_obj_set_style_pad_all(center_container, 0, 0);
|
||||
lv_obj_set_flex_flow(center_container, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_flex_align(center_container, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_set_style_pad_row(center_container, 15, 0);
|
||||
lv_obj_set_flex_grow(center_container, 1);
|
||||
|
||||
// Status icon (large, centered)
|
||||
status_icon_label_ = lv_label_create(center_container);
|
||||
lv_label_set_text(status_icon_label_, LV_SYMBOL_MUTE);
|
||||
|
||||
// Status text
|
||||
status_text_label_ = lv_label_create(center_container);
|
||||
lv_label_set_text(status_text_label_, "Unknown Status");
|
||||
|
||||
// Mute button
|
||||
mute_button_ = lv_btn_create(center_container);
|
||||
lv_obj_set_size(mute_button_, 200, 60);
|
||||
lv_obj_add_event_cb(mute_button_, on_mute_button_clicked, LV_EVENT_CLICKED, this);
|
||||
|
||||
lv_obj_t* mute_label = lv_label_create(mute_button_);
|
||||
lv_label_set_text(mute_label, "MUTE");
|
||||
lv_obj_center(mute_label);
|
||||
|
||||
// === Bottom Section: Settings and Config Prompt ===
|
||||
lv_obj_t* bottom_container = lv_obj_create(page);
|
||||
lv_obj_set_size(bottom_container, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_bg_opa(bottom_container, LV_OPA_TRANSP, 0);
|
||||
lv_obj_set_style_border_width(bottom_container, 0, 0);
|
||||
lv_obj_set_style_pad_all(bottom_container, 0, 0);
|
||||
lv_obj_set_flex_flow(bottom_container, LV_FLEX_FLOW_ROW);
|
||||
lv_obj_set_flex_align(bottom_container, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
|
||||
// Config prompt (left side)
|
||||
if (!settings_configured_) {
|
||||
lv_obj_t* config_prompt = lv_label_create(bottom_container);
|
||||
lv_label_set_text(config_prompt, "Tap " LV_SYMBOL_SETTINGS " to configure");
|
||||
lv_obj_set_style_text_color(config_prompt, lv_color_hex(0x888888), 0);
|
||||
} else {
|
||||
// Empty spacer if configured
|
||||
lv_obj_t* spacer = lv_obj_create(bottom_container);
|
||||
lv_obj_set_size(spacer, 0, 0);
|
||||
lv_obj_set_style_bg_opa(spacer, LV_OPA_TRANSP, 0);
|
||||
lv_obj_set_style_border_width(spacer, 0, 0);
|
||||
}
|
||||
|
||||
// Settings button (right side)
|
||||
lv_obj_t* settings_btn = lv_btn_create(bottom_container);
|
||||
lv_obj_set_size(settings_btn, 60, 60);
|
||||
lv_obj_add_event_cb(settings_btn, on_settings_button_clicked, LV_EVENT_CLICKED, this);
|
||||
|
||||
lv_obj_t* settings_icon = lv_label_create(settings_btn);
|
||||
lv_label_set_text(settings_icon, LV_SYMBOL_SETTINGS);
|
||||
lv_obj_center(settings_icon);
|
||||
|
||||
// Update display with current state
|
||||
update_status_display();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Settings Page UI
|
||||
// ============================================================================
|
||||
|
||||
void DiscordApp::build_settings_page(lv_obj_t* page) {
|
||||
// Title
|
||||
lv_obj_t* title = lv_label_create(page);
|
||||
lv_label_set_text(title, "Discord Bridge Settings");
|
||||
// Using default font
|
||||
lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 20);
|
||||
|
||||
// IP address label
|
||||
lv_obj_t* ip_label = lv_label_create(page);
|
||||
lv_label_set_text(ip_label, "Bridge IP Address:");
|
||||
lv_obj_align(ip_label, LV_ALIGN_TOP_LEFT, 20, 70);
|
||||
|
||||
// IP address textarea
|
||||
ip_textarea_ = lv_textarea_create(page);
|
||||
lv_obj_set_size(ip_textarea_, 300, 50);
|
||||
lv_obj_align(ip_textarea_, LV_ALIGN_TOP_LEFT, 20, 100);
|
||||
lv_textarea_set_one_line(ip_textarea_, true);
|
||||
lv_textarea_set_placeholder_text(ip_textarea_, "e.g., 192.168.1.100");
|
||||
|
||||
if (!remote_ip_.empty()) {
|
||||
lv_textarea_set_text(ip_textarea_, remote_ip_.c_str());
|
||||
}
|
||||
|
||||
// Port label
|
||||
lv_obj_t* port_label = lv_label_create(page);
|
||||
lv_label_set_text(port_label, "Bridge Port:");
|
||||
lv_obj_align(port_label, LV_ALIGN_TOP_LEFT, 20, 170);
|
||||
|
||||
// Port textarea
|
||||
port_textarea_ = lv_textarea_create(page);
|
||||
lv_obj_set_size(port_textarea_, 150, 50);
|
||||
lv_obj_align(port_textarea_, LV_ALIGN_TOP_LEFT, 20, 200);
|
||||
lv_textarea_set_one_line(port_textarea_, true);
|
||||
lv_textarea_set_placeholder_text(port_textarea_, "e.g., 4211");
|
||||
lv_textarea_set_accepted_chars(port_textarea_, "0123456789");
|
||||
lv_textarea_set_max_length(port_textarea_, 5);
|
||||
|
||||
if (remote_port_ > 0) {
|
||||
char port_str[8];
|
||||
snprintf(port_str, sizeof(port_str), "%u", remote_port_);
|
||||
lv_textarea_set_text(port_textarea_, port_str);
|
||||
}
|
||||
|
||||
// Test connection button
|
||||
lv_obj_t* test_btn = lv_btn_create(page);
|
||||
lv_obj_set_size(test_btn, 200, 50);
|
||||
lv_obj_align(test_btn, LV_ALIGN_TOP_MID, 0, 270);
|
||||
lv_obj_add_event_cb(test_btn, on_test_connection_clicked, LV_EVENT_CLICKED, this);
|
||||
|
||||
lv_obj_t* test_label = lv_label_create(test_btn);
|
||||
lv_label_set_text(test_label, "Test Connection");
|
||||
lv_obj_center(test_label);
|
||||
|
||||
// Test result label
|
||||
test_result_label_ = lv_label_create(page);
|
||||
lv_label_set_text(test_result_label_, "");
|
||||
lv_obj_align(test_result_label_, LV_ALIGN_TOP_MID, 0, 330);
|
||||
|
||||
// Save button
|
||||
lv_obj_t* save_btn = lv_btn_create(page);
|
||||
lv_obj_set_size(save_btn, 150, 50);
|
||||
lv_obj_align(save_btn, LV_ALIGN_BOTTOM_MID, 0, -20);
|
||||
lv_obj_add_event_cb(save_btn, on_save_settings_clicked, LV_EVENT_CLICKED, this);
|
||||
lv_obj_set_style_bg_color(save_btn, lv_color_hex(0x00AA00), 0);
|
||||
|
||||
lv_obj_t* save_label = lv_label_create(save_btn);
|
||||
lv_label_set_text(save_label, LV_SYMBOL_SAVE " Save");
|
||||
lv_obj_set_style_text_color(save_label, lv_color_white(), 0);
|
||||
lv_obj_center(save_label);
|
||||
}
|
||||
|
||||
void DiscordApp::show_settings_page() {
|
||||
page_stack_->push([this](lv_obj_t* page) {
|
||||
build_settings_page(page);
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Event Callbacks
|
||||
// ============================================================================
|
||||
|
||||
void DiscordApp::on_mute_button_clicked(lv_event_t* e) {
|
||||
DiscordApp* app = static_cast<DiscordApp*>(lv_event_get_user_data(e));
|
||||
if (app) {
|
||||
app->send_mute_command();
|
||||
}
|
||||
}
|
||||
|
||||
void DiscordApp::on_settings_button_clicked(lv_event_t* e) {
|
||||
DiscordApp* app = static_cast<DiscordApp*>(lv_event_get_user_data(e));
|
||||
if (app) {
|
||||
app->show_settings_page();
|
||||
}
|
||||
}
|
||||
|
||||
void DiscordApp::on_save_settings_clicked(lv_event_t* e) {
|
||||
DiscordApp* app = static_cast<DiscordApp*>(lv_event_get_user_data(e));
|
||||
if (app) {
|
||||
app->save_settings();
|
||||
|
||||
// Go back to main page
|
||||
if (app->page_stack_->depth() > 1) {
|
||||
app->page_stack_->pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void DiscordApp::on_test_connection_clicked(lv_event_t* e) {
|
||||
DiscordApp* app = static_cast<DiscordApp*>(lv_event_get_user_data(e));
|
||||
if (!app || !app->test_result_label_) return;
|
||||
|
||||
// Get values from textareas
|
||||
const char* ip = lv_textarea_get_text(app->ip_textarea_);
|
||||
const char* port_str = lv_textarea_get_text(app->port_textarea_);
|
||||
|
||||
if (strlen(ip) == 0 || strlen(port_str) == 0) {
|
||||
lv_label_set_text(app->test_result_label_, LV_SYMBOL_CLOSE " Please fill all fields");
|
||||
lv_obj_set_style_text_color(app->test_result_label_, lv_color_hex(0xFF0000), 0);
|
||||
return;
|
||||
}
|
||||
|
||||
uint16_t port = atoi(port_str);
|
||||
if (port == 0) {
|
||||
lv_label_set_text(app->test_result_label_, LV_SYMBOL_CLOSE " Invalid port");
|
||||
lv_obj_set_style_text_color(app->test_result_label_, lv_color_hex(0xFF0000), 0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Configure UDP temporarily
|
||||
UDPClient test_client;
|
||||
test_client.init();
|
||||
esp_err_t err = test_client.configure(ip, port);
|
||||
|
||||
if (err != ESP_OK) {
|
||||
lv_label_set_text(app->test_result_label_, LV_SYMBOL_CLOSE " Invalid IP address");
|
||||
lv_obj_set_style_text_color(app->test_result_label_, lv_color_hex(0xFF0000), 0);
|
||||
return;
|
||||
}
|
||||
|
||||
lv_label_set_text(app->test_result_label_, "Testing...");
|
||||
lv_obj_set_style_text_color(app->test_result_label_, lv_color_hex(0x0000FF), 0);
|
||||
|
||||
// Send STATUS command
|
||||
err = test_client.send_command("STATUS");
|
||||
if (err != ESP_OK) {
|
||||
lv_label_set_text(app->test_result_label_, LV_SYMBOL_CLOSE " Failed to send");
|
||||
lv_obj_set_style_text_color(app->test_result_label_, lv_color_hex(0xFF0000), 0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for response
|
||||
std::string response;
|
||||
err = test_client.receive_response(response, 3000);
|
||||
|
||||
if (err == ESP_OK && (response == "MUTED" || response == "UNMUTED")) {
|
||||
lv_label_set_text(app->test_result_label_, LV_SYMBOL_OK " Connection successful!");
|
||||
lv_obj_set_style_text_color(app->test_result_label_, lv_color_hex(0x00AA00), 0);
|
||||
} else {
|
||||
lv_label_set_text(app->test_result_label_, LV_SYMBOL_CLOSE " No response from bridge");
|
||||
lv_obj_set_style_text_color(app->test_result_label_, lv_color_hex(0xFF0000), 0);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// UDP Communication
|
||||
// ============================================================================
|
||||
|
||||
void DiscordApp::send_mute_command() {
|
||||
if (!settings_configured_) {
|
||||
ESP_LOGW(TAG, "Cannot send command: not configured");
|
||||
return;
|
||||
}
|
||||
|
||||
esp_err_t err = udp_client_.send_command("MUTE");
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Failed to send MUTE command");
|
||||
}
|
||||
}
|
||||
|
||||
bool DiscordApp::test_connection() {
|
||||
if (!settings_configured_) {
|
||||
return false;
|
||||
}
|
||||
|
||||
esp_err_t err = udp_client_.send_command("STATUS");
|
||||
if (err != ESP_OK) {
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string response;
|
||||
err = udp_client_.receive_response(response, RESPONSE_TIMEOUT_MS);
|
||||
|
||||
return (err == ESP_OK && (response == "MUTED" || response == "UNMUTED"));
|
||||
}
|
||||
|
||||
void DiscordApp::update_status_display() {
|
||||
if (!status_icon_label_ || !status_text_label_) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Thread-safe state access
|
||||
VoiceState state;
|
||||
if (xSemaphoreTake(state_mutex_, pdMS_TO_TICKS(100)) == pdTRUE) {
|
||||
state = current_state_;
|
||||
xSemaphoreGive(state_mutex_);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (state) {
|
||||
case VoiceState::MUTED:
|
||||
lv_label_set_text(status_icon_label_, LV_SYMBOL_MUTE);
|
||||
lv_label_set_text(status_text_label_, "Muted");
|
||||
lv_obj_set_style_text_color(status_icon_label_, lv_color_hex(0xFF0000), 0);
|
||||
break;
|
||||
|
||||
case VoiceState::UNMUTED:
|
||||
lv_label_set_text(status_icon_label_, LV_SYMBOL_VOLUME_MAX);
|
||||
lv_label_set_text(status_text_label_, "Unmuted");
|
||||
lv_obj_set_style_text_color(status_icon_label_, lv_color_hex(0x00AA00), 0);
|
||||
break;
|
||||
|
||||
case VoiceState::ERROR:
|
||||
lv_label_set_text(status_icon_label_, LV_SYMBOL_WARNING);
|
||||
lv_label_set_text(status_text_label_, "Connection Error");
|
||||
lv_obj_set_style_text_color(status_icon_label_, lv_color_hex(0xFF8800), 0);
|
||||
break;
|
||||
|
||||
case VoiceState::UNKNOWN:
|
||||
default:
|
||||
lv_label_set_text(status_icon_label_, LV_SYMBOL_BLUETOOTH);
|
||||
lv_label_set_text(status_text_label_, "Unknown Status");
|
||||
lv_obj_set_style_text_color(status_icon_label_, lv_color_hex(0x888888), 0);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void DiscordApp::show_error_notification(bool show) {
|
||||
if (error_notification_) {
|
||||
if (show) {
|
||||
lv_obj_clear_flag(error_notification_, LV_OBJ_FLAG_HIDDEN);
|
||||
} else {
|
||||
lv_obj_add_flag(error_notification_, LV_OBJ_FLAG_HIDDEN);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Settings Management
|
||||
// ============================================================================
|
||||
|
||||
void DiscordApp::load_settings() {
|
||||
remote_ip_ = storage_->get(NVS_KEY_IP);
|
||||
std::string port_str = storage_->get(NVS_KEY_PORT);
|
||||
|
||||
if (!remote_ip_.empty() && !port_str.empty()) {
|
||||
remote_port_ = atoi(port_str.c_str());
|
||||
settings_configured_ = (remote_port_ > 0);
|
||||
ESP_LOGI(TAG, "Loaded settings: %s:%u", remote_ip_.c_str(), remote_port_);
|
||||
} else {
|
||||
settings_configured_ = false;
|
||||
ESP_LOGI(TAG, "No settings found, user setup required");
|
||||
}
|
||||
}
|
||||
|
||||
void DiscordApp::save_settings() {
|
||||
if (!ip_textarea_ || !port_textarea_) {
|
||||
return;
|
||||
}
|
||||
|
||||
const char* ip = lv_textarea_get_text(ip_textarea_);
|
||||
const char* port_str = lv_textarea_get_text(port_textarea_);
|
||||
|
||||
if (strlen(ip) == 0 || strlen(port_str) == 0) {
|
||||
ESP_LOGW(TAG, "Cannot save: empty fields");
|
||||
return;
|
||||
}
|
||||
|
||||
uint16_t port = atoi(port_str);
|
||||
if (port == 0) {
|
||||
ESP_LOGW(TAG, "Cannot save: invalid port");
|
||||
return;
|
||||
}
|
||||
|
||||
// Save to NVS
|
||||
storage_->put(NVS_KEY_IP, ip);
|
||||
storage_->put(NVS_KEY_PORT, port_str);
|
||||
|
||||
// Update local config
|
||||
remote_ip_ = ip;
|
||||
remote_port_ = port;
|
||||
settings_configured_ = true;
|
||||
|
||||
// Reconfigure UDP client
|
||||
udp_client_.configure(remote_ip_, remote_port_);
|
||||
|
||||
// Reset failure counter
|
||||
consecutive_failures_ = 0;
|
||||
|
||||
ESP_LOGI(TAG, "Settings saved: %s:%u", remote_ip_.c_str(), remote_port_);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Polling Task
|
||||
// ============================================================================
|
||||
|
||||
void DiscordApp::poll_task(void* param) {
|
||||
DiscordApp* app = static_cast<DiscordApp*>(param);
|
||||
|
||||
ESP_LOGI(TAG, "Polling task started");
|
||||
|
||||
while (!app->stop_polling_) {
|
||||
app->poll_status();
|
||||
|
||||
// Use longer interval if in error state
|
||||
int interval = (app->consecutive_failures_ >= MAX_FAILURES_BEFORE_ERROR)
|
||||
? ERROR_POLL_INTERVAL_MS
|
||||
: POLL_INTERVAL_MS;
|
||||
|
||||
vTaskDelay(pdMS_TO_TICKS(interval));
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Polling task stopped");
|
||||
app->poll_task_handle_ = nullptr;
|
||||
vTaskDelete(nullptr);
|
||||
}
|
||||
|
||||
void DiscordApp::start_polling_task() {
|
||||
if (poll_task_handle_) {
|
||||
ESP_LOGW(TAG, "Polling task already running");
|
||||
return;
|
||||
}
|
||||
|
||||
stop_polling_ = false;
|
||||
xTaskCreate(poll_task, "discord_poll", 4096, this, 5, &poll_task_handle_);
|
||||
}
|
||||
|
||||
void DiscordApp::stop_polling_task() {
|
||||
if (!poll_task_handle_) {
|
||||
return;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Stopping polling task");
|
||||
stop_polling_ = true;
|
||||
|
||||
// Wait for task to finish (max 2 seconds)
|
||||
int wait_count = 0;
|
||||
while (poll_task_handle_ && wait_count < 20) {
|
||||
vTaskDelay(pdMS_TO_TICKS(100));
|
||||
wait_count++;
|
||||
}
|
||||
|
||||
if (poll_task_handle_) {
|
||||
ESP_LOGW(TAG, "Force deleting polling task");
|
||||
vTaskDelete(poll_task_handle_);
|
||||
poll_task_handle_ = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
void DiscordApp::poll_status() {
|
||||
if (!settings_configured_) {
|
||||
// Don't poll if not configured
|
||||
return;
|
||||
}
|
||||
|
||||
// Send STATUS command
|
||||
esp_err_t err = udp_client_.send_command("STATUS");
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "Failed to send STATUS command");
|
||||
consecutive_failures_++;
|
||||
|
||||
if (consecutive_failures_ >= MAX_FAILURES_BEFORE_ERROR) {
|
||||
if (xSemaphoreTake(state_mutex_, pdMS_TO_TICKS(100)) == pdTRUE) {
|
||||
current_state_ = VoiceState::ERROR;
|
||||
xSemaphoreGive(state_mutex_);
|
||||
}
|
||||
show_error_notification(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for response
|
||||
std::string response;
|
||||
err = udp_client_.receive_response(response, RESPONSE_TIMEOUT_MS);
|
||||
|
||||
if (err == ESP_OK) {
|
||||
// Success - reset failure counter
|
||||
consecutive_failures_ = 0;
|
||||
show_error_notification(false);
|
||||
|
||||
// Update state
|
||||
VoiceState new_state = VoiceState::UNKNOWN;
|
||||
if (response == "MUTED") {
|
||||
new_state = VoiceState::MUTED;
|
||||
} else if (response == "UNMUTED") {
|
||||
new_state = VoiceState::UNMUTED;
|
||||
}
|
||||
|
||||
if (xSemaphoreTake(state_mutex_, pdMS_TO_TICKS(100)) == pdTRUE) {
|
||||
current_state_ = new_state;
|
||||
xSemaphoreGive(state_mutex_);
|
||||
}
|
||||
|
||||
update_status_display();
|
||||
|
||||
} else {
|
||||
// Timeout or error
|
||||
consecutive_failures_++;
|
||||
ESP_LOGW(TAG, "No response (failures: %d)", consecutive_failures_);
|
||||
|
||||
if (consecutive_failures_ >= MAX_FAILURES_BEFORE_ERROR) {
|
||||
if (xSemaphoreTake(state_mutex_, pdMS_TO_TICKS(100)) == pdTRUE) {
|
||||
current_state_ = VoiceState::ERROR;
|
||||
xSemaphoreGive(state_mutex_);
|
||||
}
|
||||
update_status_display();
|
||||
show_error_notification(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DiscordAppDescriptor Implementation
|
||||
// ============================================================================
|
||||
|
||||
DiscordAppDescriptor::DiscordAppDescriptor()
|
||||
: AppDescriptor("Discord", new DiscordApp()) {
|
||||
// Auto-register on construction
|
||||
AppRegistry::instance().register_app(this);
|
||||
}
|
||||
|
||||
void DiscordAppDescriptor::draw_icon(lv_obj_t* parent) {
|
||||
lv_obj_t* icon = lv_label_create(parent);
|
||||
lv_label_set_text(icon, LV_SYMBOL_CALL);
|
||||
lv_obj_center(icon);
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "ui/ui_app.h"
|
||||
#include "ui/page_stack.h"
|
||||
#include "ui/app_registry.h"
|
||||
#include "network/udp_client.h"
|
||||
#include "io/nvs_handler.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include <string>
|
||||
|
||||
/**
|
||||
* @brief Discord voice control app
|
||||
*
|
||||
* Allows control of Discord voice settings (mute/unmute) via UDP communication
|
||||
* with the IotDis Node.js bridge. Features:
|
||||
* - Main page: Status icon + mute button
|
||||
* - Settings page: IP/port configuration with connection test
|
||||
* - Periodic status polling with automatic retry
|
||||
* - Error notification when remote is unreachable
|
||||
*/
|
||||
class DiscordApp : public UIApp {
|
||||
public:
|
||||
DiscordApp();
|
||||
~DiscordApp() override;
|
||||
|
||||
// UIApp interface
|
||||
esp_err_t init(lv_obj_t* container) override;
|
||||
esp_err_t deinit() override;
|
||||
std::string get_name() const override { return "Discord"; }
|
||||
void handle_event(uint32_t event_type, void* event_data = nullptr) override;
|
||||
bool on_back_button_pressed() override;
|
||||
|
||||
private:
|
||||
// Voice state enum
|
||||
enum class VoiceState {
|
||||
UNKNOWN,
|
||||
MUTED,
|
||||
UNMUTED,
|
||||
ERROR
|
||||
};
|
||||
|
||||
// Page management
|
||||
PageStack* page_stack_;
|
||||
void build_main_page(lv_obj_t* page);
|
||||
void build_settings_page(lv_obj_t* page);
|
||||
void show_settings_page();
|
||||
|
||||
// Main page widgets
|
||||
lv_obj_t* status_icon_label_;
|
||||
lv_obj_t* status_text_label_;
|
||||
lv_obj_t* mute_button_;
|
||||
lv_obj_t* error_notification_;
|
||||
|
||||
// Settings page widgets
|
||||
lv_obj_t* ip_textarea_;
|
||||
lv_obj_t* port_textarea_;
|
||||
lv_obj_t* test_result_label_;
|
||||
|
||||
// UDP client and configuration
|
||||
UDPClient udp_client_;
|
||||
std::string remote_ip_;
|
||||
uint16_t remote_port_;
|
||||
bool settings_configured_;
|
||||
|
||||
// Voice state
|
||||
VoiceState current_state_;
|
||||
SemaphoreHandle_t state_mutex_;
|
||||
|
||||
// Polling task
|
||||
TaskHandle_t poll_task_handle_;
|
||||
bool stop_polling_;
|
||||
int consecutive_failures_;
|
||||
static constexpr int MAX_FAILURES_BEFORE_ERROR = 3;
|
||||
static constexpr int POLL_INTERVAL_MS = 5000;
|
||||
static constexpr int ERROR_POLL_INTERVAL_MS = 15000;
|
||||
static constexpr int RESPONSE_TIMEOUT_MS = 2000;
|
||||
|
||||
// NVS storage
|
||||
NVSStorageHandler* storage_;
|
||||
static constexpr const char* NVS_NAMESPACE = "discord";
|
||||
static constexpr const char* NVS_KEY_IP = "remote_ip";
|
||||
static constexpr const char* NVS_KEY_PORT = "remote_port";
|
||||
|
||||
// Event callbacks
|
||||
static void on_mute_button_clicked(lv_event_t* e);
|
||||
static void on_settings_button_clicked(lv_event_t* e);
|
||||
static void on_save_settings_clicked(lv_event_t* e);
|
||||
static void on_test_connection_clicked(lv_event_t* e);
|
||||
|
||||
// UDP communication
|
||||
void send_mute_command();
|
||||
bool test_connection();
|
||||
void update_status_display();
|
||||
void show_error_notification(bool show);
|
||||
|
||||
// Settings management
|
||||
void load_settings();
|
||||
void save_settings();
|
||||
|
||||
// Polling task
|
||||
static void poll_task(void* param);
|
||||
void start_polling_task();
|
||||
void stop_polling_task();
|
||||
void poll_status();
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Discord app descriptor for registration
|
||||
*/
|
||||
class DiscordAppDescriptor : public AppDescriptor {
|
||||
public:
|
||||
static DiscordAppDescriptor& instance() {
|
||||
static DiscordAppDescriptor instance;
|
||||
return instance;
|
||||
}
|
||||
|
||||
void draw_icon(lv_obj_t* parent) override;
|
||||
|
||||
private:
|
||||
DiscordAppDescriptor();
|
||||
};
|
||||
@@ -1,399 +0,0 @@
|
||||
#include "apps/mtr_app.h"
|
||||
#include "external/mtr/arrival.h"
|
||||
#include "esp_log.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
|
||||
#define TAG "MtrApp"
|
||||
|
||||
// Event type for network ready
|
||||
#define EVENT_NETWORK_READY 1
|
||||
|
||||
MtrApp::MtrApp() {
|
||||
_mtr_handler = std::make_unique<MTRNextTrainHandler>();
|
||||
}
|
||||
|
||||
esp_err_t MtrApp::init(lv_obj_t* container) {
|
||||
if (!container) {
|
||||
ESP_LOGE(TAG, "Container is null");
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
_container = container;
|
||||
ESP_LOGI(TAG, "Initializing MTR app...");
|
||||
|
||||
// Create page stack
|
||||
_page_stack = std::make_unique<PageStack>(container);
|
||||
|
||||
// Load all lines
|
||||
_all_lines = _mtr_handler->get_lines();
|
||||
ESP_LOGI(TAG, "Loaded %zu MTR lines", _all_lines.size());
|
||||
|
||||
// Build initial line selection page
|
||||
_page_stack->push([this](lv_obj_t* page) {
|
||||
this->build_line_selection_page(page);
|
||||
});
|
||||
|
||||
ESP_LOGI(TAG, "MTR app initialized successfully");
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t MtrApp::deinit(void) {
|
||||
ESP_LOGI(TAG, "Deinitializing MTR app");
|
||||
|
||||
// Clear page stack
|
||||
if (_page_stack) {
|
||||
_page_stack->clear();
|
||||
_page_stack.reset();
|
||||
}
|
||||
|
||||
// Clear state
|
||||
_selected_line_code.clear();
|
||||
_selected_station_code.clear();
|
||||
_selected_line_info = nullptr;
|
||||
_all_lines.clear();
|
||||
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
std::string MtrApp::get_name(void) const {
|
||||
return "MTR";
|
||||
}
|
||||
|
||||
bool MtrApp::on_back_button_pressed(void) {
|
||||
if (_page_stack && _page_stack->depth() > 1) {
|
||||
_page_stack->pop();
|
||||
return true; // Handled
|
||||
}
|
||||
return false; // Not handled, go back to main menu
|
||||
}
|
||||
|
||||
void MtrApp::handle_event(uint32_t event_type, void* event_data) {
|
||||
if (event_type == EVENT_NETWORK_READY) {
|
||||
ESP_LOGI(TAG, "Network ready event received");
|
||||
}
|
||||
}
|
||||
|
||||
void MtrApp::build_line_selection_page(lv_obj_t* page_container) {
|
||||
ESP_LOGI(TAG, "Building line selection page");
|
||||
|
||||
// Title
|
||||
lv_obj_t* title = lv_label_create(page_container);
|
||||
lv_label_set_text(title, "選擇路綫 Select Line");
|
||||
lv_obj_set_style_text_color(title, lv_color_black(), 0);
|
||||
lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 10);
|
||||
|
||||
// Scrollable container for line buttons
|
||||
lv_obj_t* scroll_container = lv_obj_create(page_container);
|
||||
lv_obj_set_size(scroll_container, lv_pct(95), lv_pct(85));
|
||||
lv_obj_align(scroll_container, LV_ALIGN_TOP_MID, 0, 40);
|
||||
lv_obj_set_flex_flow(scroll_container, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_flex_align(scroll_container, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_set_style_pad_all(scroll_container, 5, 0);
|
||||
lv_obj_set_style_pad_row(scroll_container, 8, 0);
|
||||
|
||||
// Create button for each line
|
||||
for (size_t i = 0; i < _all_lines.size(); i++) {
|
||||
LineInfo* line = &_all_lines[i];
|
||||
|
||||
lv_obj_t* btn = lv_btn_create(scroll_container);
|
||||
lv_obj_set_size(btn, lv_pct(95), 60);
|
||||
|
||||
// Set button color based on line color
|
||||
uint32_t color = parse_color_hex(line->color());
|
||||
lv_obj_set_style_bg_color(btn, lv_color_hex(color), 0);
|
||||
|
||||
// Button label
|
||||
lv_obj_t* label = lv_label_create(btn);
|
||||
lv_label_set_text_fmt(label, "%s", line->code());
|
||||
lv_obj_set_style_text_color(label, lv_color_white(), 0);
|
||||
lv_obj_center(label);
|
||||
|
||||
// Store line pointer in user data
|
||||
lv_obj_add_event_cb(btn, line_button_event_cb, LV_EVENT_CLICKED, this);
|
||||
lv_obj_set_user_data(btn, (void*)line);
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Created %zu line buttons", _all_lines.size());
|
||||
}
|
||||
|
||||
void MtrApp::build_station_selection_page(lv_obj_t* page_container) {
|
||||
ESP_LOGI(TAG, "Building station selection page for line: %s", _selected_line_code.c_str());
|
||||
|
||||
if (!_selected_line_info) {
|
||||
ESP_LOGE(TAG, "No line info selected");
|
||||
return;
|
||||
}
|
||||
|
||||
// Title with line code
|
||||
lv_obj_t* title = lv_label_create(page_container);
|
||||
lv_label_set_text_fmt(title, "%s 路綫車站", _selected_line_code.c_str());
|
||||
lv_obj_set_style_text_color(title, lv_color_black(), 0);
|
||||
lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 10);
|
||||
|
||||
// Scrollable container for station buttons
|
||||
lv_obj_t* scroll_container = lv_obj_create(page_container);
|
||||
lv_obj_set_size(scroll_container, lv_pct(95), lv_pct(85));
|
||||
lv_obj_align(scroll_container, LV_ALIGN_TOP_MID, 0, 40);
|
||||
lv_obj_set_flex_flow(scroll_container, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_flex_align(scroll_container, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_set_style_pad_all(scroll_container, 5, 0);
|
||||
lv_obj_set_style_pad_row(scroll_container, 6, 0);
|
||||
|
||||
// Create button for each station
|
||||
const std::vector<StationInfo>* stations = _selected_line_info->stations();
|
||||
for (size_t i = 0; i < stations->size(); i++) {
|
||||
const StationInfo* station = &(*stations)[i];
|
||||
|
||||
lv_obj_t* btn = lv_btn_create(scroll_container);
|
||||
lv_obj_set_size(btn, lv_pct(95), 50);
|
||||
lv_obj_set_style_bg_color(btn, lv_color_hex(0x4CAF50), 0);
|
||||
|
||||
// Button label with station name and code
|
||||
lv_obj_t* label = lv_label_create(btn);
|
||||
lv_label_set_text_fmt(label, "%s (%s)", station->name(), station->code());
|
||||
lv_obj_set_style_text_color(label, lv_color_white(), 0);
|
||||
lv_obj_center(label);
|
||||
|
||||
// Store station pointer in user data
|
||||
lv_obj_add_event_cb(btn, station_button_event_cb, LV_EVENT_CLICKED, this);
|
||||
lv_obj_set_user_data(btn, (void*)station);
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Created %zu station buttons", stations->size());
|
||||
}
|
||||
|
||||
void MtrApp::build_arrival_page(lv_obj_t* page_container) {
|
||||
ESP_LOGI(TAG, "Building arrival page");
|
||||
|
||||
// Title
|
||||
lv_obj_t* title = lv_label_create(page_container);
|
||||
lv_label_set_text_fmt(title, "%s - %s", _selected_line_code.c_str(), _selected_station_code.c_str());
|
||||
lv_obj_set_style_text_color(title, lv_color_black(), 0);
|
||||
lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 10);
|
||||
|
||||
// Loading message
|
||||
lv_obj_t* loading_label = lv_label_create(page_container);
|
||||
lv_label_set_text(loading_label, "載入中... Loading...");
|
||||
lv_obj_set_style_text_color(loading_label, lv_color_black(), 0);
|
||||
lv_obj_center(loading_label);
|
||||
|
||||
// Refresh button
|
||||
lv_obj_t* refresh_btn = lv_btn_create(page_container);
|
||||
lv_obj_set_size(refresh_btn, 120, 50);
|
||||
lv_obj_align(refresh_btn, LV_ALIGN_BOTTOM_MID, 0, -10);
|
||||
lv_obj_add_event_cb(refresh_btn, refresh_button_event_cb, LV_EVENT_CLICKED, this);
|
||||
|
||||
lv_obj_t* refresh_label = lv_label_create(refresh_btn);
|
||||
lv_label_set_text(refresh_label, LV_SYMBOL_REFRESH " 重新整理");
|
||||
lv_obj_set_style_text_color(refresh_label, lv_color_white(), 0);
|
||||
lv_obj_center(refresh_label);
|
||||
|
||||
// Load arrival data asynchronously
|
||||
load_arrival_data(page_container);
|
||||
}
|
||||
|
||||
void MtrApp::load_arrival_data(lv_obj_t* page_container) {
|
||||
if (!_network_handler) {
|
||||
ESP_LOGW(TAG, "Network handler not set, cannot fetch arrival data");
|
||||
// Update UI to show error
|
||||
lv_obj_t* error_label = lv_label_create(page_container);
|
||||
lv_label_set_text(error_label, "網絡未就緒\nNetwork not ready");
|
||||
lv_obj_set_style_text_color(error_label, lv_color_black(), 0);
|
||||
lv_obj_align(error_label, LV_ALIGN_CENTER, 0, -30);
|
||||
return;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Fetching arrival data for %s/%s", _selected_line_code.c_str(), _selected_station_code.c_str());
|
||||
|
||||
StationArrivalInfo* arrival_info = nullptr;
|
||||
MtrArrivalErrorCode error_code = _mtr_handler->get_next_arrival_info(
|
||||
_network_handler,
|
||||
_selected_line_code,
|
||||
_selected_station_code,
|
||||
arrival_info,
|
||||
Language::TC
|
||||
);
|
||||
|
||||
// Clear loading message
|
||||
lv_obj_clean(page_container);
|
||||
|
||||
// Recreate title
|
||||
lv_obj_t* title = lv_label_create(page_container);
|
||||
lv_label_set_text_fmt(title, "%s - %s", _selected_line_code.c_str(), _selected_station_code.c_str());
|
||||
lv_obj_set_style_text_color(title, lv_color_black(), 0);
|
||||
lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 10);
|
||||
|
||||
if (error_code != MtrArrivalErrorCode::NONE || !arrival_info) {
|
||||
ESP_LOGE(TAG, "Failed to fetch arrival info, error code: %d", (int)error_code);
|
||||
|
||||
lv_obj_t* error_label = lv_label_create(page_container);
|
||||
lv_label_set_text(error_label, "無法取得班次資料\nFailed to fetch arrival data");
|
||||
lv_obj_set_style_text_color(error_label, lv_color_black(), 0);
|
||||
lv_obj_center(error_label);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create scrollable container for arrivals
|
||||
lv_obj_t* scroll_container = lv_obj_create(page_container);
|
||||
lv_obj_set_size(scroll_container, lv_pct(95), lv_pct(75));
|
||||
lv_obj_align(scroll_container, LV_ALIGN_TOP_MID, 0, 45);
|
||||
lv_obj_set_style_pad_all(scroll_container, 10, 0);
|
||||
|
||||
int y_offset = 0;
|
||||
|
||||
// Display UP direction trains
|
||||
lv_obj_t* up_header = lv_label_create(scroll_container);
|
||||
lv_label_set_text(up_header, "上行 UP:");
|
||||
lv_obj_set_style_text_color(up_header, lv_color_black(), 0);
|
||||
lv_obj_set_pos(up_header, 0, y_offset);
|
||||
y_offset += 30;
|
||||
|
||||
const std::vector<ArrivalInfo>* up_arrivals = arrival_info->up_arrivals();
|
||||
if (up_arrivals->empty()) {
|
||||
lv_obj_t* no_train = lv_label_create(scroll_container);
|
||||
lv_label_set_text(no_train, " 暫無班次 No trains");
|
||||
lv_obj_set_style_text_color(no_train, lv_color_hex(0x666666), 0);
|
||||
lv_obj_set_pos(no_train, 10, y_offset);
|
||||
y_offset += 25;
|
||||
} else {
|
||||
for (const auto& arrival : *up_arrivals) {
|
||||
lv_obj_t* arrival_label = lv_label_create(scroll_container);
|
||||
lv_label_set_text_fmt(arrival_label, " %s → %s", arrival.arrival_time(), arrival.destination());
|
||||
lv_obj_set_style_text_color(arrival_label, lv_color_black(), 0);
|
||||
lv_obj_set_pos(arrival_label, 10, y_offset);
|
||||
y_offset += 25;
|
||||
}
|
||||
}
|
||||
|
||||
y_offset += 10;
|
||||
|
||||
// Display DOWN direction trains
|
||||
lv_obj_t* down_header = lv_label_create(scroll_container);
|
||||
lv_label_set_text(down_header, "下行 DOWN:");
|
||||
lv_obj_set_style_text_color(down_header, lv_color_black(), 0);
|
||||
lv_obj_set_pos(down_header, 0, y_offset);
|
||||
y_offset += 30;
|
||||
|
||||
const std::vector<ArrivalInfo>* down_arrivals = arrival_info->down_arrivals();
|
||||
if (down_arrivals->empty()) {
|
||||
lv_obj_t* no_train = lv_label_create(scroll_container);
|
||||
lv_label_set_text(no_train, " 暫無班次 No trains");
|
||||
lv_obj_set_style_text_color(no_train, lv_color_hex(0x666666), 0);
|
||||
lv_obj_set_pos(no_train, 10, y_offset);
|
||||
y_offset += 25;
|
||||
} else {
|
||||
for (const auto& arrival : *down_arrivals) {
|
||||
lv_obj_t* arrival_label = lv_label_create(scroll_container);
|
||||
lv_label_set_text_fmt(arrival_label, " %s → %s", arrival.arrival_time(), arrival.destination());
|
||||
lv_obj_set_style_text_color(arrival_label, lv_color_black(), 0);
|
||||
lv_obj_set_pos(arrival_label, 10, y_offset);
|
||||
y_offset += 25;
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up
|
||||
if (arrival_info != nullptr) {
|
||||
delete arrival_info;
|
||||
}
|
||||
|
||||
// Refresh button
|
||||
lv_obj_t* refresh_btn = lv_btn_create(page_container);
|
||||
lv_obj_set_size(refresh_btn, 120, 50);
|
||||
lv_obj_align(refresh_btn, LV_ALIGN_BOTTOM_MID, 0, -10);
|
||||
lv_obj_add_event_cb(refresh_btn, refresh_button_event_cb, LV_EVENT_CLICKED, this);
|
||||
|
||||
lv_obj_t* refresh_label = lv_label_create(refresh_btn);
|
||||
lv_label_set_text(refresh_label, LV_SYMBOL_REFRESH " 重新整理");
|
||||
lv_obj_set_style_text_color(refresh_label, lv_color_white(), 0);
|
||||
lv_obj_center(refresh_label);
|
||||
|
||||
ESP_LOGI(TAG, "Arrival data displayed successfully");
|
||||
}
|
||||
|
||||
uint32_t MtrApp::parse_color_hex(const char* hex_str) {
|
||||
if (!hex_str || hex_str[0] != '#') {
|
||||
return 0x808080; // Default gray
|
||||
}
|
||||
|
||||
// Skip the '#' character
|
||||
hex_str++;
|
||||
|
||||
uint32_t color = 0;
|
||||
sscanf(hex_str, "%" SCNx32, &color);
|
||||
return color;
|
||||
}
|
||||
|
||||
void MtrApp::line_button_event_cb(lv_event_t* e) {
|
||||
lv_event_code_t code = lv_event_get_code(e);
|
||||
if (code == LV_EVENT_CLICKED) {
|
||||
MtrApp* app = (MtrApp*)lv_event_get_user_data(e);
|
||||
lv_obj_t* btn = (lv_obj_t*)lv_event_get_target(e);
|
||||
LineInfo* line = (LineInfo*)lv_obj_get_user_data(btn);
|
||||
|
||||
if (app && line) {
|
||||
ESP_LOGI(TAG, "Line selected: %s", line->code());
|
||||
app->_selected_line_code = line->code();
|
||||
app->_selected_line_info = line;
|
||||
|
||||
// Push station selection page
|
||||
app->_page_stack->push([app](lv_obj_t* page) {
|
||||
app->build_station_selection_page(page);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void MtrApp::station_button_event_cb(lv_event_t* e) {
|
||||
lv_event_code_t code = lv_event_get_code(e);
|
||||
if (code == LV_EVENT_CLICKED) {
|
||||
MtrApp* app = (MtrApp*)lv_event_get_user_data(e);
|
||||
lv_obj_t* btn = (lv_obj_t*)lv_event_get_target(e);
|
||||
const StationInfo* station = (const StationInfo*)lv_obj_get_user_data(btn);
|
||||
|
||||
if (app && station) {
|
||||
ESP_LOGI(TAG, "Station selected: %s (%s)", station->name(), station->code());
|
||||
app->_selected_station_code = station->code();
|
||||
|
||||
// Push arrival page
|
||||
app->_page_stack->push([app](lv_obj_t* page) {
|
||||
app->build_arrival_page(page);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void MtrApp::refresh_button_event_cb(lv_event_t* e) {
|
||||
lv_event_code_t code = lv_event_get_code(e);
|
||||
if (code == LV_EVENT_CLICKED) {
|
||||
MtrApp* app = (MtrApp*)lv_event_get_user_data(e);
|
||||
if (app && app->_page_stack && app->_page_stack->current_page()) {
|
||||
ESP_LOGI(TAG, "Refresh button clicked");
|
||||
app->load_arrival_data(app->_page_stack->current_page());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MtrAppDescriptor implementation
|
||||
MtrApp* MtrAppDescriptor::_app_instance = nullptr;
|
||||
|
||||
MtrAppDescriptor::MtrAppDescriptor()
|
||||
: AppDescriptor("MTR", []() -> UIApp* {
|
||||
if (!MtrAppDescriptor::_app_instance) {
|
||||
MtrAppDescriptor::_app_instance = new MtrApp();
|
||||
}
|
||||
return MtrAppDescriptor::_app_instance;
|
||||
}()) {
|
||||
// Register with AppRegistry
|
||||
AppRegistry::instance().register_app(this);
|
||||
ESP_LOGI(TAG, "MtrApp registered with AppRegistry");
|
||||
}
|
||||
|
||||
void MtrAppDescriptor::draw_icon(lv_obj_t* parent) {
|
||||
// Create MTR icon with train symbol
|
||||
lv_obj_t* icon_label = lv_label_create(parent);
|
||||
lv_label_set_text(icon_label, LV_SYMBOL_GPS "\nMTR");
|
||||
lv_obj_set_style_text_color(icon_label, lv_color_white(), 0);
|
||||
lv_obj_set_style_text_align(icon_label, LV_TEXT_ALIGN_CENTER, 0);
|
||||
lv_obj_center(icon_label);
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "ui/ui_app.h"
|
||||
#include "ui/app_registry.h"
|
||||
#include "ui/page_stack.h"
|
||||
#include "external/mtr/mtr.h"
|
||||
#include "external/mtr/line_info.h"
|
||||
#include "external/mtr/station_info.h"
|
||||
#include "network/network.h"
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
/**
|
||||
* @brief MTR Next Train application
|
||||
*
|
||||
* Provides multi-page navigation for:
|
||||
* 1. Line selection - choose MTR line
|
||||
* 2. Station selection - choose station within selected line
|
||||
* 3. Arrival display - show real-time train arrival information
|
||||
*/
|
||||
class MtrApp : public UIApp {
|
||||
public:
|
||||
MtrApp();
|
||||
virtual ~MtrApp() = default;
|
||||
|
||||
esp_err_t init(lv_obj_t* container) override;
|
||||
esp_err_t deinit(void) override;
|
||||
std::string get_name(void) const override;
|
||||
bool on_back_button_pressed(void) override;
|
||||
void handle_event(uint32_t event_type, void* event_data) override;
|
||||
|
||||
// Set network handler (must be called before using app)
|
||||
void set_network_handler(NetworkHandler* handler) { _network_handler = handler; }
|
||||
|
||||
private:
|
||||
std::unique_ptr<MTRNextTrainHandler> _mtr_handler;
|
||||
std::unique_ptr<PageStack> _page_stack;
|
||||
NetworkHandler* _network_handler = nullptr;
|
||||
|
||||
// Current selection state
|
||||
std::string _selected_line_code;
|
||||
std::string _selected_station_code;
|
||||
LineInfo* _selected_line_info = nullptr;
|
||||
std::vector<LineInfo> _all_lines;
|
||||
|
||||
// Page builders
|
||||
void build_line_selection_page(lv_obj_t* page_container);
|
||||
void build_station_selection_page(lv_obj_t* page_container);
|
||||
void build_arrival_page(lv_obj_t* page_container);
|
||||
|
||||
// Event handlers
|
||||
static void line_button_event_cb(lv_event_t* e);
|
||||
static void station_button_event_cb(lv_event_t* e);
|
||||
static void refresh_button_event_cb(lv_event_t* e);
|
||||
|
||||
// Helper functions
|
||||
void load_arrival_data(lv_obj_t* page_container);
|
||||
uint32_t parse_color_hex(const char* hex_str);
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief AppDescriptor for MtrApp
|
||||
*/
|
||||
class MtrAppDescriptor : public AppDescriptor {
|
||||
public:
|
||||
MtrAppDescriptor();
|
||||
void draw_icon(lv_obj_t* parent) override;
|
||||
|
||||
private:
|
||||
static MtrApp* _app_instance;
|
||||
};
|
||||
53
main/ui/apps/registry.h
Normal file
53
main/ui/apps/registry.h
Normal file
@@ -0,0 +1,53 @@
|
||||
#pragma once
|
||||
|
||||
#include "ui/apps/app.h"
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include "esp_log.h"
|
||||
#include <memory>
|
||||
|
||||
class AppRegistry {
|
||||
public:
|
||||
static AppRegistry& instance() {
|
||||
static AppRegistry registry;
|
||||
return registry;
|
||||
}
|
||||
|
||||
void register_app(std::unique_ptr<AppDescriptor> app_descriptor) {
|
||||
if (app_descriptors_.find(app_descriptor->get_name()) != app_descriptors_.end()) {
|
||||
// App already registered
|
||||
ESP_LOGW("AppRegistry", "App '%s' is already registered", app_descriptor->get_name().c_str());
|
||||
return;
|
||||
}
|
||||
app_descriptors_.emplace(app_descriptor->get_name(), std::move(app_descriptor));
|
||||
}
|
||||
|
||||
size_t size() const {
|
||||
return app_descriptors_.size();
|
||||
}
|
||||
|
||||
// iterators to access registered apps
|
||||
auto begin() { return app_descriptors_.begin(); }
|
||||
auto begin() const { return app_descriptors_.begin(); }
|
||||
auto end() { return app_descriptors_.end(); }
|
||||
auto end() const { return app_descriptors_.end(); }
|
||||
|
||||
// [] operator to get app by name
|
||||
AppDescriptor* operator[](const std::string& name) {
|
||||
auto it = app_descriptors_.find(name);
|
||||
if (it != app_descriptors_.end()) {
|
||||
return it->second.get();
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
private:
|
||||
std::map<std::string, std::unique_ptr<AppDescriptor>> app_descriptors_ = {};
|
||||
|
||||
AppRegistry() = default;
|
||||
// Disable copy and move semantics
|
||||
AppRegistry(const AppRegistry&) = delete;
|
||||
AppRegistry& operator=(const AppRegistry&) = delete;
|
||||
AppRegistry(AppRegistry&&) = delete;
|
||||
AppRegistry& operator=(AppRegistry&&) = delete;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user