feat: add MTR Next Train application with multi-page navigation and real-time arrival info

This commit is contained in:
GW_MC
2026-01-24 16:46:00 +08:00
parent d01167fd77
commit d091625cea
4 changed files with 516 additions and 19 deletions

View File

@@ -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}

View File

@@ -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 <tick/lv_tick.h>
#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<MtrApp*>(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);

399
main/ui/apps/mtr_app.cpp Normal file
View File

@@ -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<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);
}

71
main/ui/apps/mtr_app.h Normal file
View File

@@ -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 <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;
};