From d091625ceacc129408c77239f7e80b827a2a5bd1 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Sat, 24 Jan 2026 16:46:00 +0800 Subject: [PATCH] feat: add MTR Next Train application with multi-page navigation and real-time arrival info --- main/CMakeLists.txt | 55 ++++-- main/main.cpp | 10 + main/ui/apps/mtr_app.cpp | 399 +++++++++++++++++++++++++++++++++++++++ main/ui/apps/mtr_app.h | 71 +++++++ 4 files changed, 516 insertions(+), 19 deletions(-) create mode 100644 main/ui/apps/mtr_app.cpp create mode 100644 main/ui/apps/mtr_app.h diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 6d2676a..0683fb1 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -1,23 +1,40 @@ set(requires esp-tls spi_flash nvs_flash esp_event esp_netif esp_http_client esp_wifi esp_psram esp_lvgl_port) -file(GLOB SRCS "main.cpp" "*.cpp" "*.c" "**/*.cpp" "**/*.cpp" "ui/**/*.cpp" "ui/**/*.c") -# Explicitly list all source files to ensure build system picks them up -# set(SRCS -# "main.cpp" -# "display/display.cpp" -# "display/eink_display_handler.cpp" -# "info/info.cpp" -# "io/nvs_handler.cpp" -# "network/http_handler.cpp" -# "network/network.cpp" -# "network/udp_client.cpp" -# "network/wifi_handler.cpp" -# "ui/page_stack.cpp" -# "ui/root_layout.cpp" -# "ui/ui_handler.cpp" -# "ui/apps/demo_app.cpp" -# "ui/apps/discord_app.cpp" -# "ui/apps/shutdown_app.cpp" -# ) +file(GLOB SRCS "main.cpp" "*.cpp" "*.c" "**/*.cpp" "**/*.cpp" "ui/**/*.cpp" "ui/**/*.c" "external/**/*.cpp" "external/**/*.c") + + +# Path to the source JSON in this component +set(ASSETS_SRC_DIR ${CMAKE_CURRENT_LIST_DIR}/../assets) +set(ASSETS_BINARY_OUTPUT_DIR ${CMAKE_CURRENT_BINARY_DIR}/assets) +set(MTR_JSON_SRC ${ASSETS_SRC_DIR}/MTR_LINE_STATION.json) +set(MTR_JSON_HEADER ${ASSETS_BINARY_OUTPUT_DIR}/MTR_LINE_STATION.h) +set(CUSTOM_CMAKE_MODULES_DIR ${CMAKE_CURRENT_LIST_DIR}/cmake) + +## Generate a minified header at configure time using Python +find_package(Python3 COMPONENTS Interpreter) +file(MAKE_DIRECTORY ${ASSETS_BINARY_OUTPUT_DIR}) +if (Python3_Interpreter_FOUND) + execute_process( + COMMAND ${Python3_EXECUTABLE} -c "import json,sys,io; sys.stdout.write(json.dumps(json.load(open(sys.argv[1], 'r', encoding='utf-8')),separators=(',',':')))" + "${MTR_JSON_SRC}" + RESULT_VARIABLE _mtr_json_minify_result + OUTPUT_VARIABLE MTR_JSON_MINIFIED + ERROR_VARIABLE _mtr_json_minify_error + OUTPUT_STRIP_TRAILING_WHITESPACE + ) + if (_mtr_json_minify_result) + message(WARNING "Python minify failed (code=${_mtr_json_minify_result}): ${_mtr_json_minify_error}\nEmbedding original ${MTR_JSON_SRC} instead.") + file(READ ${MTR_JSON_SRC} MTR_JSON_MINIFIED) + elseif (NOT MTR_JSON_MINIFIED) + message(WARNING "Python minified output empty; embedding original ${MTR_JSON_SRC} instead.") + file(READ ${MTR_JSON_SRC} MTR_JSON_MINIFIED) + endif() +else() + message(WARNING "Python3 not found; embedding original JSON without minification.") + file(READ ${MTR_JSON_SRC} MTR_JSON_MINIFIED) +endif() + +file(WRITE ${MTR_JSON_HEADER} "#pragma once\nstatic const char MTR_LINE_STATION_JSON[] = R\"json(${MTR_JSON_MINIFIED})json\";\n") + idf_component_register(SRCS ${SRCS} PRIV_REQUIRES ${requires} diff --git a/main/main.cpp b/main/main.cpp index 92f37d7..6c6858e 100644 --- a/main/main.cpp +++ b/main/main.cpp @@ -21,6 +21,7 @@ #include "ui/apps/demo_app.h" #include "ui/apps/shutdown_app.h" #include "ui/apps/discord_app.h" +#include "ui/apps/mtr_app.h" #include #include "esp_lvgl_port.h" #include "lvgl.h" @@ -127,6 +128,14 @@ void app_main(void) { DemoAppDescriptor* demo_descriptor = new DemoAppDescriptor(); ShutdownAppDescriptor* shutdown_descriptor = new ShutdownAppDescriptor(); DiscordAppDescriptor::instance(); // Use singleton pattern for Discord app + MtrAppDescriptor* mtr_descriptor = new MtrAppDescriptor(); + + // Pass network handler to MtrApp so it can fetch arrival data + MtrApp* mtr_app = dynamic_cast(mtr_descriptor->get_app_instance()); + if (mtr_app) { + mtr_app->set_network_handler(network_handler); + } + ESP_LOGI(TAG, "Apps registered with AppRegistry\n"); // Initialize UI Handler (will render app icons from registry) @@ -162,6 +171,7 @@ void app_main(void) { ui_handler.deinit(); delete demo_descriptor; delete shutdown_descriptor; + delete mtr_descriptor; delete display_handler; vSemaphoreDelete(lvgl_mutex); vEventGroupDelete(system_event_group); diff --git a/main/ui/apps/mtr_app.cpp b/main/ui/apps/mtr_app.cpp new file mode 100644 index 0000000..7108a73 --- /dev/null +++ b/main/ui/apps/mtr_app.cpp @@ -0,0 +1,399 @@ +#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 new file mode 100644 index 0000000..dd87dd6 --- /dev/null +++ b/main/ui/apps/mtr_app.h @@ -0,0 +1,71 @@ +#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; +};