diff --git a/main/ui/apps/travel/ui/main.cpp b/main/ui/apps/travel/ui/main.cpp index 3482e47..50dea31 100644 --- a/main/ui/apps/travel/ui/main.cpp +++ b/main/ui/apps/travel/ui/main.cpp @@ -4,317 +4,318 @@ #include "esp_log.h" static const char* TAG = "TravelMainUI"; +#define LVGL_PORT_LOCK_TIMEOUT_MS 6000 namespace travel { -MainUI::MainUI() = default; + MainUI::MainUI() = default; -MainUI::~MainUI() { - deinit(); -} - -esp_err_t MainUI::init(lv_obj_t* parent) { - if (!parent) { - ESP_LOGE(TAG, "Parent is null"); - return ESP_ERR_INVALID_ARG; + MainUI::~MainUI() { + deinit(); } - parent_ = parent; - - if (!lvgl_port_lock(pdMS_TO_TICKS(1000))) { - ESP_LOGE(TAG, "Failed to acquire LVGL lock"); - return ESP_ERR_TIMEOUT; - } - - // Create main container - container_ = lv_obj_create(parent_); - lv_obj_set_size(container_, LV_PCT(100), LV_PCT(100)); - lv_obj_set_flex_flow(container_, LV_FLEX_FLOW_COLUMN); - lv_obj_set_flex_align(container_, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); - lv_obj_set_style_pad_all(container_, 5, 0); - lv_obj_set_style_bg_opa(container_, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(container_, 0, 0); - // Disable animations and scrolling for e-ink - lv_obj_set_style_anim_time(container_, 0, 0); - lv_obj_clear_flag(container_, LV_OBJ_FLAG_SCROLLABLE); - - create_header_(); - create_route_displays_(); - - // Message label for errors/empty state - msg_label_ = lv_label_create(container_); - lv_obj_set_width(msg_label_, LV_PCT(100)); - lv_label_set_text(msg_label_, ""); - lv_obj_set_style_text_font(msg_label_, ¬o_sans_tc_14, 0); - lv_obj_add_flag(msg_label_, LV_OBJ_FLAG_HIDDEN); - - // Refresh time label at bottom - refresh_time_label_ = lv_label_create(container_); - lv_obj_set_width(refresh_time_label_, LV_PCT(100)); - lv_label_set_text(refresh_time_label_, ""); - lv_obj_set_style_text_font(refresh_time_label_, ¬o_sans_tc_14, 0); - lv_obj_set_style_text_color(refresh_time_label_, lv_color_hex(0x808080), 0); - - lvgl_port_unlock(); - - return ESP_OK; -} - -esp_err_t MainUI::deinit() { - if (!lvgl_port_lock(pdMS_TO_TICKS(1000))) { - ESP_LOGE(TAG, "Failed to acquire LVGL lock"); - return ESP_ERR_TIMEOUT; - } - - if (container_) { - lv_obj_del(container_); - container_ = nullptr; - } - - // Reset all pointers - header_label_ = nullptr; - settings_btn_ = nullptr; - refresh_time_label_ = nullptr; - msg_label_ = nullptr; - for (auto& display : route_displays_) { - display.container = nullptr; - display.header = nullptr; - for (int i = 0; i < MAX_ARRIVALS_PER_ROUTE; i++) { - display.arrival_labels[i] = nullptr; + esp_err_t MainUI::init(lv_obj_t* parent) { + if (!parent) { + ESP_LOGE(TAG, "Parent is null"); + return ESP_ERR_INVALID_ARG; } - } - lvgl_port_unlock(); - parent_ = nullptr; + parent_ = parent; - return ESP_OK; -} - -void MainUI::create_header_() { - // Header container - lv_obj_t* header = lv_obj_create(container_); - lv_obj_set_size(header, LV_PCT(100), 35); - lv_obj_set_flex_flow(header, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(header, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_pad_hor(header, 5, 0); - lv_obj_set_style_bg_opa(header, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(header, 0, 0); - lv_obj_set_style_border_width(header, 1, 0); - lv_obj_set_style_border_side(header, LV_BORDER_SIDE_BOTTOM, 0); - lv_obj_set_style_border_color(header, lv_color_hex(0x808080), 0); - lv_obj_set_style_anim_time(header, 0, 0); - lv_obj_clear_flag(header, LV_OBJ_FLAG_SCROLLABLE); - - // Title label - header_label_ = lv_label_create(header); - lv_label_set_text(header_label_, "MTR到站時間"); - lv_obj_set_style_text_font(header_label_, ¬o_sans_tc_14, 0); - - // Settings button - settings_btn_ = lv_btn_create(header); - lv_obj_set_size(settings_btn_, 30, 30); - lv_obj_t* btn_label = lv_label_create(settings_btn_); - lv_label_set_text(btn_label, LV_SYMBOL_SETTINGS); - lv_obj_center(btn_label); - lv_obj_set_style_anim_time(settings_btn_, 0, 0); -} - -void MainUI::create_route_displays_() { - for (int i = 0; i < MAX_DISPLAY_ROUTES; i++) { - RouteDisplay& display = route_displays_[i]; - - // Container for each route - display.container = lv_obj_create(container_); - lv_obj_set_size(display.container, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_flex_flow(display.container, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_all(display.container, 3, 0); - lv_obj_set_style_bg_opa(display.container, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(display.container, 0, 0); - lv_obj_set_style_border_width(display.container, 1, 0); - lv_obj_set_style_border_side(display.container, LV_BORDER_SIDE_BOTTOM, 0); - lv_obj_set_style_border_color(display.container, lv_color_hex(0xC0C0C0), 0); - lv_obj_set_style_anim_time(display.container, 0, 0); - lv_obj_clear_flag(display.container, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_add_flag(display.container, LV_OBJ_FLAG_HIDDEN); // Hidden by default - - // Route header (station -> destination with line color) - display.header = lv_label_create(display.container); - lv_obj_set_width(display.header, LV_PCT(100)); - lv_label_set_text(display.header, ""); - lv_obj_set_style_text_font(display.header, ¬o_sans_tc_14, 0); - - // Arrival labels (up to 3 per route) - for (int j = 0; j < MAX_ARRIVALS_PER_ROUTE; j++) { - display.arrival_labels[j] = lv_label_create(display.container); - lv_obj_set_width(display.arrival_labels[j], LV_PCT(100)); - lv_label_set_text(display.arrival_labels[j], ""); - lv_obj_set_style_text_font(display.arrival_labels[j], ¬o_sans_tc_14, 0); - lv_obj_set_style_pad_left(display.arrival_labels[j], 10, 0); + if (!lvgl_port_lock(pdMS_TO_TICKS(LVGL_PORT_LOCK_TIMEOUT_MS))) { + ESP_LOGE(TAG, "Failed to acquire LVGL lock"); + return ESP_ERR_TIMEOUT; } - } -} -void MainUI::update_arrivals(const std::vector& arrival_data) { - if (!lvgl_port_lock(pdMS_TO_TICKS(1000))) { - ESP_LOGE(TAG, "Failed to acquire LVGL lock"); - return; - } + // Create main container + container_ = lv_obj_create(parent_); + lv_obj_set_size(container_, LV_PCT(100), LV_PCT(100)); + lv_obj_set_flex_flow(container_, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(container_, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + lv_obj_set_style_pad_all(container_, 5, 0); + lv_obj_set_style_bg_opa(container_, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(container_, 0, 0); + // Disable animations and scrolling for e-ink + lv_obj_set_style_anim_time(container_, 0, 0); + lv_obj_clear_flag(container_, LV_OBJ_FLAG_SCROLLABLE); - // Hide message label only if it's currently visible - if (!lv_obj_has_flag(msg_label_, LV_OBJ_FLAG_HIDDEN)) { + create_header_(); + create_route_displays_(); + + // Message label for errors/empty state + msg_label_ = lv_label_create(container_); + lv_obj_set_width(msg_label_, LV_PCT(100)); + lv_label_set_text(msg_label_, ""); + lv_obj_set_style_text_font(msg_label_, ¬o_sans_tc_14, 0); lv_obj_add_flag(msg_label_, LV_OBJ_FLAG_HIDDEN); + + // Refresh time label at bottom + refresh_time_label_ = lv_label_create(container_); + lv_obj_set_width(refresh_time_label_, LV_PCT(100)); + lv_label_set_text(refresh_time_label_, ""); + lv_obj_set_style_text_font(refresh_time_label_, ¬o_sans_tc_14, 0); + lv_obj_set_style_text_color(refresh_time_label_, lv_color_black(), 0); + + lvgl_port_unlock(); + + return ESP_OK; } - // Update each route display - for (int i = 0; i < MAX_DISPLAY_ROUTES; i++) { - if (i < static_cast(arrival_data.size())) { - update_route_display_(route_displays_[i], arrival_data[i]); - } else { - // Hide unused route displays if currently visible - if (route_displays_[i].cached_visible) { - lv_obj_add_flag(route_displays_[i].container, LV_OBJ_FLAG_HIDDEN); - route_displays_[i].cached_visible = false; + esp_err_t MainUI::deinit() { + if (!lvgl_port_lock(pdMS_TO_TICKS(LVGL_PORT_LOCK_TIMEOUT_MS))) { + ESP_LOGE(TAG, "Failed to acquire LVGL lock"); + return ESP_ERR_TIMEOUT; + } + + if (container_) { + lv_obj_del(container_); + container_ = nullptr; + } + + // Reset all pointers + header_label_ = nullptr; + settings_btn_ = nullptr; + refresh_time_label_ = nullptr; + msg_label_ = nullptr; + for (auto& display : route_displays_) { + display.container = nullptr; + display.header = nullptr; + for (int i = 0; i < MAX_ARRIVALS_PER_ROUTE; i++) { + display.arrival_labels[i] = nullptr; + } + } + + lvgl_port_unlock(); + parent_ = nullptr; + + return ESP_OK; + } + + void MainUI::create_header_() { + // Header container + lv_obj_t* header = lv_obj_create(container_); + lv_obj_set_size(header, LV_PCT(100), 35); + lv_obj_set_flex_flow(header, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(header, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_hor(header, 5, 0); + lv_obj_set_style_bg_opa(header, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(header, 0, 0); + lv_obj_set_style_border_width(header, 1, 0); + lv_obj_set_style_border_side(header, LV_BORDER_SIDE_BOTTOM, 0); + lv_obj_set_style_border_color(header, lv_color_black(), 0); + lv_obj_set_style_anim_time(header, 0, 0); + lv_obj_clear_flag(header, LV_OBJ_FLAG_SCROLLABLE); + + // Title label + header_label_ = lv_label_create(header); + lv_label_set_text(header_label_, "MTR到站時間"); + lv_obj_set_style_text_font(header_label_, ¬o_sans_tc_14, 0); + + // Settings button + settings_btn_ = lv_btn_create(header); + lv_obj_set_size(settings_btn_, 30, 30); + lv_obj_t* btn_label = lv_label_create(settings_btn_); + lv_label_set_text(btn_label, LV_SYMBOL_SETTINGS); + lv_obj_center(btn_label); + lv_obj_set_style_anim_time(settings_btn_, 0, 0); + } + + void MainUI::create_route_displays_() { + for (int i = 0; i < MAX_DISPLAY_ROUTES; i++) { + RouteDisplay& display = route_displays_[i]; + + // Container for each route + display.container = lv_obj_create(container_); + lv_obj_set_size(display.container, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(display.container, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_all(display.container, 3, 0); + lv_obj_set_style_bg_opa(display.container, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(display.container, 0, 0); + lv_obj_set_style_border_width(display.container, 1, 0); + lv_obj_set_style_border_side(display.container, LV_BORDER_SIDE_BOTTOM, 0); + lv_obj_set_style_border_color(display.container, lv_color_black(), 0); + lv_obj_set_style_anim_time(display.container, 0, 0); + lv_obj_clear_flag(display.container, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(display.container, LV_OBJ_FLAG_HIDDEN); // Hidden by default + + // Route header (station -> destination with line color) + display.header = lv_label_create(display.container); + lv_obj_set_width(display.header, LV_PCT(100)); + lv_label_set_text(display.header, ""); + lv_obj_set_style_text_font(display.header, ¬o_sans_tc_14, 0); + + // Arrival labels (up to 3 per route) + for (int j = 0; j < MAX_ARRIVALS_PER_ROUTE; j++) { + display.arrival_labels[j] = lv_label_create(display.container); + lv_obj_set_width(display.arrival_labels[j], LV_PCT(100)); + lv_label_set_text(display.arrival_labels[j], ""); + lv_obj_set_style_text_font(display.arrival_labels[j], ¬o_sans_tc_14, 0); + lv_obj_set_style_pad_left(display.arrival_labels[j], 10, 0); } } } - lvgl_port_unlock(); -} - -void MainUI::update_route_display_(RouteDisplay& display, const RouteArrivalData& data) { - // Show container if hidden - if (!display.cached_visible) { - lv_obj_clear_flag(display.container, LV_OBJ_FLAG_HIDDEN); - display.cached_visible = true; - } - - // Update header text (station -> direction) - std::string header_text = data.route.station_name + " -> " + data.route.direction_name; - if (header_text != display.cached_header_text) { - lv_label_set_text(display.header, header_text.c_str()); - display.cached_header_text = header_text; - } - - // Update line color only if changed - if (data.route.line_color != display.cached_line_color) { - if (!data.route.line_color.empty()) { - lv_color_t line_color = hex_to_lv_color_(data.route.line_color); - lv_obj_set_style_text_color(display.header, line_color, 0); - } - display.cached_line_color = data.route.line_color; - } - - // Update arrival labels - only if text changed - for (int i = 0; i < MAX_ARRIVALS_PER_ROUTE; i++) { - std::string arrival_text = ""; - bool should_show = (i < static_cast(data.arrivals.size())); - - if (should_show) { - const auto& arrival = data.arrivals[i]; - arrival_text = " " + arrival.arrival_time; - if (!arrival.arrival_time_full.empty()) { - arrival_text += " (" + arrival.arrival_time_full + ")"; - } - arrival_text += " " + arrival.direction; + void MainUI::update_arrivals(const std::vector& arrival_data) { + if (!lvgl_port_lock(pdMS_TO_TICKS(LVGL_PORT_LOCK_TIMEOUT_MS))) { + ESP_LOGE(TAG, "Failed to acquire LVGL lock"); + return; } - if (arrival_text != display.cached_arrival_texts[i]) { - lv_label_set_text(display.arrival_labels[i], arrival_text.c_str()); - display.cached_arrival_texts[i] = arrival_text; + // Hide message label only if it's currently visible + if (!lv_obj_has_flag(msg_label_, LV_OBJ_FLAG_HIDDEN)) { + lv_obj_add_flag(msg_label_, LV_OBJ_FLAG_HIDDEN); } - // Handle visibility only if changed - if (should_show != display.cached_arrival_visible[i]) { - if (should_show) { - lv_obj_clear_flag(display.arrival_labels[i], LV_OBJ_FLAG_HIDDEN); + // Update each route display + for (int i = 0; i < MAX_DISPLAY_ROUTES; i++) { + if (i < static_cast(arrival_data.size())) { + update_route_display_(route_displays_[i], arrival_data[i]); } else { - lv_obj_add_flag(display.arrival_labels[i], LV_OBJ_FLAG_HIDDEN); + // Hide unused route displays if currently visible + if (route_displays_[i].cached_visible) { + lv_obj_add_flag(route_displays_[i].container, LV_OBJ_FLAG_HIDDEN); + route_displays_[i].cached_visible = false; + } + } + } + + lvgl_port_unlock(); + } + + void MainUI::update_route_display_(RouteDisplay& display, const RouteArrivalData& data) { + // Show container if hidden + if (!display.cached_visible) { + lv_obj_clear_flag(display.container, LV_OBJ_FLAG_HIDDEN); + display.cached_visible = true; + } + + // Update header text (station -> direction) + std::string header_text = data.route.station_name + " -> " + data.route.direction_name; + if (header_text != display.cached_header_text) { + lv_label_set_text(display.header, header_text.c_str()); + display.cached_header_text = header_text; + } + + // Update line color only if changed + if (data.route.line_color != display.cached_line_color) { + if (!data.route.line_color.empty()) { + lv_color_t line_color = hex_to_lv_color_(data.route.line_color); + lv_obj_set_style_text_color(display.header, line_color, 0); + } + display.cached_line_color = data.route.line_color; + } + + // Update arrival labels - only if text changed + for (int i = 0; i < MAX_ARRIVALS_PER_ROUTE; i++) { + std::string arrival_text = ""; + bool should_show = (i < static_cast(data.arrivals.size())); + + if (should_show) { + const auto& arrival = data.arrivals[i]; + arrival_text = " " + arrival.arrival_time; + if (!arrival.arrival_time_full.empty()) { + arrival_text += " (" + arrival.arrival_time_full + ")"; + } + arrival_text += " " + arrival.direction; + } + + if (arrival_text != display.cached_arrival_texts[i]) { + lv_label_set_text(display.arrival_labels[i], arrival_text.c_str()); + display.cached_arrival_texts[i] = arrival_text; + } + + // Handle visibility only if changed + if (should_show != display.cached_arrival_visible[i]) { + if (should_show) { + lv_obj_clear_flag(display.arrival_labels[i], LV_OBJ_FLAG_HIDDEN); + } else { + lv_obj_add_flag(display.arrival_labels[i], LV_OBJ_FLAG_HIDDEN); + } + display.cached_arrival_visible[i] = should_show; + } + } + + // Show error if any + if (!data.is_valid && !data.error_message.empty()) { + std::string error_text = " 錯誤: " + data.error_message; + if (error_text != display.cached_arrival_texts[0]) { + lv_label_set_text(display.arrival_labels[0], error_text.c_str()); + display.cached_arrival_texts[0] = error_text; + } + if (!display.cached_arrival_visible[0]) { + lv_obj_clear_flag(display.arrival_labels[0], LV_OBJ_FLAG_HIDDEN); + display.cached_arrival_visible[0] = true; } - display.cached_arrival_visible[i] = should_show; } } - // Show error if any - if (!data.is_valid && !data.error_message.empty()) { - std::string error_text = " 錯誤: " + data.error_message; - if (error_text != display.cached_arrival_texts[0]) { - lv_label_set_text(display.arrival_labels[0], error_text.c_str()); - display.cached_arrival_texts[0] = error_text; + void MainUI::update_last_refresh_time(const std::string& time_str) { + if (!lvgl_port_lock(pdMS_TO_TICKS(LVGL_PORT_LOCK_TIMEOUT_MS))) { + ESP_LOGE(TAG, "Failed to acquire LVGL lock"); + return; } - if (!display.cached_arrival_visible[0]) { - lv_obj_clear_flag(display.arrival_labels[0], LV_OBJ_FLAG_HIDDEN); - display.cached_arrival_visible[0] = true; + + std::string full_text = "更新: " + time_str; + if (full_text != cached_refresh_time_text) { + lv_label_set_text(refresh_time_label_, full_text.c_str()); + cached_refresh_time_text = full_text; + } + + lvgl_port_unlock(); + } + + void MainUI::show_no_routes_message() { + if (!lvgl_port_lock(pdMS_TO_TICKS(LVGL_PORT_LOCK_TIMEOUT_MS))) { + ESP_LOGE(TAG, "Failed to acquire LVGL lock"); + return; + } + + // Hide all route displays + for (auto& display : route_displays_) { + lv_obj_add_flag(display.container, LV_OBJ_FLAG_HIDDEN); + } + + // Show message + lv_label_set_text(msg_label_, "請按右上角設定按鈕添加路線"); + lv_obj_clear_flag(msg_label_, LV_OBJ_FLAG_HIDDEN); + + lvgl_port_unlock(); + } + + void MainUI::show_error_message(const std::string& message) { + if (!lvgl_port_lock(pdMS_TO_TICKS(LVGL_PORT_LOCK_TIMEOUT_MS))) { + ESP_LOGE(TAG, "Failed to acquire LVGL lock"); + return; + } + + // Hide all route displays + for (auto& display : route_displays_) { + lv_obj_add_flag(display.container, LV_OBJ_FLAG_HIDDEN); + } + + // Show error message + lv_label_set_text(msg_label_, ("錯誤: " + message).c_str()); + lv_obj_clear_flag(msg_label_, LV_OBJ_FLAG_HIDDEN); + + lvgl_port_unlock(); + } + + void MainUI::register_settings_button_callback(lv_event_cb_t cb, void* user_data) { + if (settings_btn_) { + lv_obj_add_event_cb(settings_btn_, cb, LV_EVENT_CLICKED, user_data); } } -} -void MainUI::update_last_refresh_time(const std::string& time_str) { - if (!lvgl_port_lock(pdMS_TO_TICKS(1000))) { - ESP_LOGE(TAG, "Failed to acquire LVGL lock"); - return; + lv_color_t MainUI::hex_to_lv_color_(const std::string& hex_color) { + if (hex_color.length() < 7 || hex_color[0] != '#') { + return lv_color_black(); + } + + unsigned int r = std::stoi(hex_color.substr(1, 2), nullptr, 16); + unsigned int g = std::stoi(hex_color.substr(3, 2), nullptr, 16); + unsigned int b = std::stoi(hex_color.substr(5, 2), nullptr, 16); + + return lv_color_make(r, g, b); } - std::string full_text = "更新: " + time_str; - if (full_text != cached_refresh_time_text) { - lv_label_set_text(refresh_time_label_, full_text.c_str()); - cached_refresh_time_text = full_text; - } - - lvgl_port_unlock(); -} - -void MainUI::show_no_routes_message() { - if (!lvgl_port_lock(pdMS_TO_TICKS(1000))) { - ESP_LOGE(TAG, "Failed to acquire LVGL lock"); - return; - } - - // Hide all route displays - for (auto& display : route_displays_) { - lv_obj_add_flag(display.container, LV_OBJ_FLAG_HIDDEN); - } - - // Show message - lv_label_set_text(msg_label_, "請按右上角設定按鈕添加路線"); - lv_obj_clear_flag(msg_label_, LV_OBJ_FLAG_HIDDEN); - - lvgl_port_unlock(); -} - -void MainUI::show_error_message(const std::string& message) { - if (!lvgl_port_lock(pdMS_TO_TICKS(1000))) { - ESP_LOGE(TAG, "Failed to acquire LVGL lock"); - return; - } - - // Hide all route displays - for (auto& display : route_displays_) { - lv_obj_add_flag(display.container, LV_OBJ_FLAG_HIDDEN); - } - - // Show error message - lv_label_set_text(msg_label_, ("錯誤: " + message).c_str()); - lv_obj_clear_flag(msg_label_, LV_OBJ_FLAG_HIDDEN); - - lvgl_port_unlock(); -} - -void MainUI::register_settings_button_callback(lv_event_cb_t cb, void* user_data) { - if (settings_btn_) { - lv_obj_add_event_cb(settings_btn_, cb, LV_EVENT_CLICKED, user_data); - } -} - -lv_color_t MainUI::hex_to_lv_color_(const std::string& hex_color) { - if (hex_color.length() < 7 || hex_color[0] != '#') { - return lv_color_black(); - } - - unsigned int r = std::stoi(hex_color.substr(1, 2), nullptr, 16); - unsigned int g = std::stoi(hex_color.substr(3, 2), nullptr, 16); - unsigned int b = std::stoi(hex_color.substr(5, 2), nullptr, 16); - - return lv_color_make(r, g, b); -} - } // namespace travel diff --git a/main/ui/apps/travel/ui/main_handler.cpp b/main/ui/apps/travel/ui/main_handler.cpp index 25ed997..c0aa1b6 100644 --- a/main/ui/apps/travel/ui/main_handler.cpp +++ b/main/ui/apps/travel/ui/main_handler.cpp @@ -5,342 +5,475 @@ #include #include #include +#include +#include static const char* TAG = "TravelMainHandler"; namespace travel { -MainUIHandler::MainUIHandler() - : main_ui_(std::make_unique()) - , mtr_handler_(std::make_unique()) { - refresh_mutex_ = xSemaphoreCreateMutex(); -} + // Helper functions shared by formatting routines + namespace { + // Parse an ISO 8601-like timestamp into epoch seconds (UTC). + // Supports formats like: 2024-01-15T14:30:00+08:00, 2026-02-03 23:08:22, or ...Z + bool parse_iso_to_epoch(const std::string& s, time_t& out_epoch) { + auto trim_copy = [](const std::string& in) { + size_t a = 0; + size_t b = in.size(); + while (a < b && std::isspace((unsigned char)in[a])) ++a; + while (b > a && std::isspace((unsigned char)in[b - 1])) --b; + return in.substr(a, b - a); + }; -MainUIHandler::~MainUIHandler() { - deinit(); - if (refresh_mutex_) { - vSemaphoreDelete(refresh_mutex_); - } -} + std::string s_trim = trim_copy(s); + // Accept either 'T' or space (also lowercase 't') as date/time separator + size_t t_pos = s_trim.find_first_of("Tt "); + if (t_pos == std::string::npos) return false; -esp_err_t MainUIHandler::init( - lv_obj_t* parent, - InteractionHandler* interaction_handler, - SettingHandler* setting_handler, - NetworkHandler* network_handler -) { - ESP_LOGI(TAG, "Initializing main UI handler"); + std::string date = trim_copy(s_trim.substr(0, t_pos)); + std::string tzpart; + std::string timepart = trim_copy(s_trim.substr(t_pos + 1)); - setting_handler_ = setting_handler; - network_handler_ = network_handler; + // Extract timezone (Z or +HH:MM or +HHMM or +HH) + size_t zpos = timepart.find_first_of("Zz"); + if (zpos != std::string::npos) { + tzpart = "Z"; + timepart = trim_copy(timepart.substr(0, zpos)); + } else { + // Find first '+' or '-' AFTER the numeric time portion + size_t plus = std::string::npos; + for (size_t i = 0; i < timepart.size(); ++i) { + if (timepart[i] == '+' || timepart[i] == '-') { plus = i; break; } + } + if (plus != std::string::npos) { + tzpart = trim_copy(timepart.substr(plus)); + timepart = trim_copy(timepart.substr(0, plus)); + } + } - // Initialize UI - esp_err_t err = main_ui_->init(parent); - if (err != ESP_OK) { - ESP_LOGE(TAG, "Failed to init main UI"); - return err; + int year = 0, month = 0, day = 0; + if (sscanf(date.c_str(), "%d-%d-%d", &year, &month, &day) != 3) { + // Try alternative separators like '/' + if (sscanf(date.c_str(), "%d/%d/%d", &year, &month, &day) != 3) return false; + } + + int hour = 0, min = 0, sec = 0; + // Remove fractional seconds if present (e.g., 10:34:52.123) + size_t dot = timepart.find('.'); + if (dot != std::string::npos) timepart = timepart.substr(0, dot); + + int time_parsed = sscanf(timepart.c_str(), "%d:%d:%d", &hour, &min, &sec); + if (time_parsed < 2) { + // Try hour only or hour:minute + if (sscanf(timepart.c_str(), "%d:%d", &hour, &min) == 2) { + sec = 0; + } else { + return false; + } + } + + int parsed_offset_seconds = 0; // seconds east of UTC + bool has_tz = false; + if (!tzpart.empty()) { + has_tz = true; + if (tzpart == "Z" || tzpart == "z") { + parsed_offset_seconds = 0; + } else { + char sign = tzpart[0]; + int oh = 0, om = 0; + std::string tznum = tzpart.substr(1); + // Accept +HH:MM, +HHMM, or +HH + if (sscanf(tznum.c_str(), "%d:%d", &oh, &om) == 2) { + parsed_offset_seconds = oh * 3600 + om * 60; + } else if (sscanf(tznum.c_str(), "%d", &oh) == 1) { + // If tz like 0800, interpret as HHMM when length>=3 + if (tznum.size() >= 3 && tznum.find(':') == std::string::npos && tznum.size() <= 4) { + if (tznum.size() == 4) { + int hh = 0, mm = 0; + if (sscanf(tznum.c_str(), "%2d%2d", &hh, &mm) == 2) { + oh = hh; om = mm; + } + } + parsed_offset_seconds = oh * 3600 + om * 60; + } else { + parsed_offset_seconds = oh * 3600; + } + } + if (sign == '-') parsed_offset_seconds = -parsed_offset_seconds; + } + } + + std::tm tm = {}; + tm.tm_year = year - 1900; + tm.tm_mon = month - 1; + tm.tm_mday = day; + tm.tm_hour = hour; + tm.tm_min = min; + tm.tm_sec = sec; + tm.tm_isdst = -1; + + time_t now = time(nullptr); + std::tm local_tm = *std::localtime(&now); + std::tm gm_tm = *std::gmtime(&now); + time_t local_epoch = mktime(&local_tm); + time_t gm_epoch = mktime(&gm_tm); + int local_offset = static_cast(difftime(local_epoch, gm_epoch)); + + time_t epoch_assuming_local = mktime(&tm); + if (epoch_assuming_local == (time_t)-1) return false; + + if (!has_tz) { + // No timezone provided: assume local time + out_epoch = epoch_assuming_local; + } else { + // Adjust when parsed time had a specific timezone + out_epoch = epoch_assuming_local + (local_offset - parsed_offset_seconds); + } + return true; + } + + std::string format_epoch_HHMM(time_t epoch) { + std::tm at = *std::localtime(&epoch); + char buf[6]; + strftime(buf, sizeof(buf), "%H:%M", &at); + return std::string(buf); + } } - // Register settings button callback - main_ui_->register_settings_button_callback(on_settings_button_clicked_static_, this); + MainUIHandler::MainUIHandler() + : main_ui_(std::make_unique()) + , mtr_handler_(std::make_unique()) { + refresh_mutex_ = xSemaphoreCreateMutex(); + } + + MainUIHandler::~MainUIHandler() { + deinit(); + if (refresh_mutex_) { + vSemaphoreDelete(refresh_mutex_); + } + } + + esp_err_t MainUIHandler::init( + lv_obj_t* parent, + InteractionHandler* interaction_handler, + SettingHandler* setting_handler, + NetworkHandler* network_handler + ) { + ESP_LOGI(TAG, "Initializing main UI handler"); + + setting_handler_ = setting_handler; + network_handler_ = network_handler; + + // Initialize UI + esp_err_t err = main_ui_->init(parent); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to init main UI"); + return err; + } + + // Register settings button callback + main_ui_->register_settings_button_callback(on_settings_button_clicked_static_, this); + + // Check if configured + if (!setting_handler_->is_configured()) { + main_ui_->show_no_routes_message(); + return ESP_OK; + } + + // Start polling task + polling_running_ = true; + BaseType_t task_created = xTaskCreate( + polling_task_, + "travel_poll", + 8192, + this, + 5, + &polling_task_handle_ + ); + + if (task_created != pdPASS) { + ESP_LOGE(TAG, "Failed to create polling task"); + polling_running_ = false; + return ESP_ERR_NO_MEM; + } + + // Do initial refresh + fetch_and_update_arrivals_(); - // Check if configured - if (!setting_handler_->is_configured()) { - main_ui_->show_no_routes_message(); return ESP_OK; } - // Start polling task - polling_running_ = true; - BaseType_t task_created = xTaskCreate( - polling_task_, - "travel_poll", - 8192, - this, - 5, - &polling_task_handle_ - ); + esp_err_t MainUIHandler::deinit() { + ESP_LOGI(TAG, "Deinitializing main UI handler"); - if (task_created != pdPASS) { - ESP_LOGE(TAG, "Failed to create polling task"); - polling_running_ = false; - return ESP_ERR_NO_MEM; - } - - // Do initial refresh - fetch_and_update_arrivals_(); - - return ESP_OK; -} - -esp_err_t MainUIHandler::deinit() { - ESP_LOGI(TAG, "Deinitializing main UI handler"); - - // Stop polling task - if (polling_task_handle_) { - polling_running_ = false; - // Wait for task to finish - vTaskDelay(pdMS_TO_TICKS(100)); - polling_task_handle_ = nullptr; - } - - // Deinit UI - if (main_ui_) { - main_ui_->deinit(); - } - - return ESP_OK; -} - -void MainUIHandler::register_on_settings_button_clicked(SettingsButtonCallback cb, void* user_data) { - on_settings_callback_ = cb; - settings_callback_user_data_ = user_data; -} - -void MainUIHandler::force_refresh() { - if (xSemaphoreTake(refresh_mutex_, pdMS_TO_TICKS(1000)) == pdTRUE) { - fetch_and_update_arrivals_(); - xSemaphoreGive(refresh_mutex_); - } -} - -void MainUIHandler::polling_task_(void* param) { - MainUIHandler* handler = static_cast(param); - - while (handler->polling_running_) { - uint32_t interval_ms = handler->setting_handler_->get_polling_interval() * 1000; - - if (xSemaphoreTake(handler->refresh_mutex_, pdMS_TO_TICKS(1000)) == pdTRUE) { - handler->fetch_and_update_arrivals_(); - xSemaphoreGive(handler->refresh_mutex_); + // Stop polling task + if (polling_task_handle_) { + polling_running_ = false; + // Wait for task to finish + vTaskDelay(pdMS_TO_TICKS(100)); + polling_task_handle_ = nullptr; } - // Delay until next poll - vTaskDelay(pdMS_TO_TICKS(interval_ms)); + // Deinit UI + if (main_ui_) { + main_ui_->deinit(); + } + + return ESP_OK; } - vTaskDelete(nullptr); -} - -void MainUIHandler::fetch_and_update_arrivals_() { - if (!network_handler_ || !setting_handler_) { - return; + void MainUIHandler::register_on_settings_button_clicked(SettingsButtonCallback cb, void* user_data) { + on_settings_callback_ = cb; + settings_callback_user_data_ = user_data; } - const auto& routes = setting_handler_->get_routes(); - if (routes.empty()) { - main_ui_->show_no_routes_message(); - return; + void MainUIHandler::force_refresh() { + if (xSemaphoreTake(refresh_mutex_, pdMS_TO_TICKS(1000)) == pdTRUE) { + fetch_and_update_arrivals_(); + xSemaphoreGive(refresh_mutex_); + } } - std::vector arrival_data; + void MainUIHandler::polling_task_(void* param) { + MainUIHandler* handler = static_cast(param); - for (const auto& route : routes) { - RouteArrivalData data; - data.route = route; + while (handler->polling_running_) { + uint32_t interval_ms = handler->setting_handler_->get_polling_interval() * 1000; - // Fetch arrival info from MTR API - std::string line_code = route.line_code; - std::string station_code = route.station_code; - StationArrivalInfo* arrival_info = nullptr; + if (xSemaphoreTake(handler->refresh_mutex_, pdMS_TO_TICKS(1000)) == pdTRUE) { + handler->fetch_and_update_arrivals_(); + xSemaphoreGive(handler->refresh_mutex_); + } - MtrArrivalErrorCode error = mtr_handler_->get_next_arrival_info( - network_handler_, - line_code, - station_code, - arrival_info, - Language::TC // Traditional Chinese - ); + // Delay until next poll + vTaskDelay(pdMS_TO_TICKS(interval_ms)); + } - if (error == MtrArrivalErrorCode::NONE && arrival_info) { - // Determine which direction (UP or DOWN) to filter based on terminal station - const auto* up_arrivals = arrival_info->up_arrivals(); - const auto* down_arrivals = arrival_info->down_arrivals(); + vTaskDelete(nullptr); + } - // Get all lines to find station positions - std::vector lines = mtr_handler_->get_lines(); + void MainUIHandler::fetch_and_update_arrivals_() { + if (!network_handler_ || !setting_handler_) { + return; + } - // Find current line and determine direction - bool filter_up = false; - bool filter_down = false; + const auto& routes = setting_handler_->get_routes(); + if (routes.empty()) { + main_ui_->show_no_routes_message(); + return; + } - for (const auto& line : lines) { - if (std::string(line.code()) == line_code) { - const auto* stations = line.stations(); - if (stations) { - // Find index of current station and terminal station - size_t current_idx = SIZE_MAX; - size_t terminal_idx = SIZE_MAX; + std::vector arrival_data; - for (size_t i = 0; i < stations->size(); i++) { - if (std::string(stations->at(i).code()) == station_code) { - current_idx = i; + for (const auto& route : routes) { + RouteArrivalData data; + data.route = route; + + // Fetch arrival info from MTR API + std::string line_code = route.line_code; + std::string station_code = route.station_code; + StationArrivalInfo* arrival_info = nullptr; + + MtrArrivalErrorCode error = mtr_handler_->get_next_arrival_info( + network_handler_, + line_code, + station_code, + arrival_info, + Language::TC // Traditional Chinese + ); + + if (error == MtrArrivalErrorCode::NONE && arrival_info) { + // Determine which direction (UP or DOWN) to filter based on terminal station + const auto* up_arrivals = arrival_info->up_arrivals(); + const auto* down_arrivals = arrival_info->down_arrivals(); + + // Get all lines to find station positions + std::vector lines = mtr_handler_->get_lines(); + + // Find current line and determine direction + bool filter_up = false; + bool filter_down = false; + + for (const auto& line : lines) { + if (std::string(line.code()) == line_code) { + const auto* stations = line.stations(); + if (stations) { + // Find index of current station and terminal station + size_t current_idx = SIZE_MAX; + size_t terminal_idx = SIZE_MAX; + + for (size_t i = 0; i < stations->size(); i++) { + if (std::string(stations->at(i).code()) == station_code) { + current_idx = i; + } + if (std::string(stations->at(i).code()) == route.direction) { + terminal_idx = i; + } } - if (std::string(stations->at(i).code()) == route.direction) { - terminal_idx = i; - } - } - - // Determine direction: if terminal is at higher index, it's DOWN - if (current_idx != SIZE_MAX && terminal_idx != SIZE_MAX) { - if (terminal_idx > current_idx) { - filter_down = true; - } else { - filter_up = true; + + // Determine direction: if terminal is at higher index, it's DOWN + if (current_idx != SIZE_MAX && terminal_idx != SIZE_MAX) { + if (terminal_idx > current_idx) { + filter_down = true; + } else { + filter_up = true; + } } } + break; } - break; + } + + // Filter arrivals based on direction + auto process_arrivals = [&](const std::vector* arrivals) { + if (!arrivals) return; + for (const auto& arrival : *arrivals) { + ArrivalDisplay display; + display.arrival_time = format_arrival_time_(arrival.arrival_time()); + display.arrival_time_full = format_arrival_time_full_(arrival.arrival_time()); + display.direction = route.direction_name; + data.arrivals.push_back(display); + } + }; + + if (filter_up) { + process_arrivals(up_arrivals); + } + if (filter_down) { + process_arrivals(down_arrivals); + } + + data.is_valid = true; + + // Clean up + delete arrival_info; + } else { + data.is_valid = false; + switch (error) { + case MtrArrivalErrorCode::LINE_NOT_FOUND: + data.error_message = "路線不存在"; + break; + case MtrArrivalErrorCode::STATION_NOT_FOUND: + data.error_message = "車站不存在"; + break; + case MtrArrivalErrorCode::NO_ARRIVAL_INFO: + data.error_message = "無到站資料"; + break; + default: + data.error_message = "無法連接"; + break; } } - // Filter arrivals based on direction - auto process_arrivals = [&](const std::vector* arrivals) { - if (!arrivals) return; - for (const auto& arrival : *arrivals) { - ArrivalDisplay display; - display.arrival_time = format_arrival_time_(arrival.arrival_time()); - display.arrival_time_full = format_arrival_time_full_(arrival.arrival_time()); - display.direction = route.direction_name; - data.arrivals.push_back(display); - } - }; + arrival_data.push_back(data); + } - if (filter_up) { - process_arrivals(up_arrivals); - } - if (filter_down) { - process_arrivals(down_arrivals); - } + // Update UI only if data changed + if (has_arrival_data_changed_(arrival_data)) { + main_ui_->update_arrivals(arrival_data); + cached_arrival_data_ = arrival_data; + } + main_ui_->update_last_refresh_time(get_current_time_string_()); + } - data.is_valid = true; + std::string MainUIHandler::format_arrival_time_(const std::string& api_time) { + // Keep fallback for numeric minute strings (e.g., "0", "6", "15") + if (!api_time.empty() && std::all_of(api_time.begin(), api_time.end(), [](char c) { return std::isdigit((unsigned char)c); })) { + return api_time + "分鐘"; + } - // Clean up - delete arrival_info; - } else { - data.is_valid = false; - switch (error) { - case MtrArrivalErrorCode::LINE_NOT_FOUND: - data.error_message = "路線不存在"; - break; - case MtrArrivalErrorCode::STATION_NOT_FOUND: - data.error_message = "車站不存在"; - break; - case MtrArrivalErrorCode::NO_ARRIVAL_INFO: - data.error_message = "無到站資料"; - break; - default: - data.error_message = "無法連接"; - break; + time_t arrival_epoch = 0; + if (parse_iso_to_epoch(api_time, arrival_epoch)) { + time_t now = time(nullptr); + double diff_seconds = difftime(arrival_epoch, now); + if (diff_seconds < 0) diff_seconds = 0; // already arrived -> show 0 + int minutes = static_cast((diff_seconds + 59) / 60); // round up + + if (minutes < 60) { + return std::to_string(minutes) + "分鐘"; } } - arrival_data.push_back(data); + // Only relative minutes are returned from this function. + ESP_LOGW(TAG, "Unable to parse arrival time for relative format: %s", api_time.c_str()); + return std::string(); } - // Update UI only if data changed - if (has_arrival_data_changed_(arrival_data)) { - main_ui_->update_arrivals(arrival_data); - cached_arrival_data_ = arrival_data; - } - main_ui_->update_last_refresh_time(get_current_time_string_()); -} - -std::string MainUIHandler::format_arrival_time_(const std::string& api_time) { - // API returns time in format like "2024-01-15T14:30:00+08:00" or "2" - // Check if it's a simple minute count - if (api_time.length() <= 2) { - return api_time + "分鐘"; - } - - // Try to parse ISO format time - // Extract time part (HH:MM) - size_t t_pos = api_time.find('T'); - if (t_pos != std::string::npos && api_time.length() > t_pos + 5) { - std::string time_part = api_time.substr(t_pos + 1, 5); - return time_part; - } - - // Try to find HH:MM pattern in the string - for (size_t i = 0; i < api_time.length() - 5; i++) { - if (api_time[i] == ':' && i > 0 && i < api_time.length() - 3) { - std::string candidate = api_time.substr(i - 2, 5); - if (isdigit(candidate[0]) && isdigit(candidate[1]) && candidate[2] == ':' && - isdigit(candidate[3]) && isdigit(candidate[4])) { - return candidate; - } + std::string MainUIHandler::format_arrival_time_full_(const std::string& api_time) { + // Returns absolute time for display (e.g., "14:32") + // Returns empty string for relative times + if (api_time.length() <= 2) { + ESP_LOGW(TAG, "Arrival time appears to be relative, no full time available: %s", api_time.c_str()); + return ""; } + + time_t arrival_epoch = 0; + if (parse_iso_to_epoch(api_time, arrival_epoch)) { + return format_epoch_HHMM(arrival_epoch); + } + + // fallback: extract HH:MM + size_t t_pos = api_time.find('T'); + if (t_pos != std::string::npos && api_time.length() > t_pos + 5) { + return api_time.substr(t_pos + 1, 5); + } + + ESP_LOGW(TAG, "Unable to parse arrival time for full format: %s", api_time.c_str()); + return std::string(); } - return ""; // Return empty instead of raw input -} + std::string MainUIHandler::get_current_time_string_() { + time_t now = time(nullptr); + std::tm tm = *std::localtime(&now); -std::string MainUIHandler::format_arrival_time_full_(const std::string& api_time) { - // Returns absolute time for display (e.g., "14:32") - // Returns empty string for relative times - if (api_time.length() <= 2) { - return ""; + char buffer[32]; + // Return absolute local date and time: YYYY-MM-DD HH:MM + strftime(buffer, sizeof(buffer), "%Y-%m-%d %H:%M", &tm); + return std::string(buffer); } - size_t t_pos = api_time.find('T'); - if (t_pos != std::string::npos && api_time.length() > t_pos + 5) { - return api_time.substr(t_pos + 1, 5); - } + bool MainUIHandler::has_arrival_data_changed_(const std::vector& new_data) { + if (new_data.size() != cached_arrival_data_.size()) { + return true; + } - return ""; -} + for (size_t i = 0; i < new_data.size(); i++) { + const auto& new_route = new_data[i]; + const auto& cached_route = cached_arrival_data_[i]; -std::string MainUIHandler::get_current_time_string_() { - auto now = std::time(nullptr); - auto tm = *std::localtime(&now); - - char buffer[6]; // HH:MM\0 - strftime(buffer, sizeof(buffer), "%H:%M", &tm); - return std::string(buffer); -} - -bool MainUIHandler::has_arrival_data_changed_(const std::vector& new_data) { - if (new_data.size() != cached_arrival_data_.size()) { - return true; - } - - for (size_t i = 0; i < new_data.size(); i++) { - const auto& new_route = new_data[i]; - const auto& cached_route = cached_arrival_data_[i]; - - if (new_route.route.station_code != cached_route.route.station_code || + if (new_route.route.station_code != cached_route.route.station_code || new_route.route.direction != cached_route.route.direction || new_route.is_valid != cached_route.is_valid || new_route.error_message != cached_route.error_message) { - return true; - } - - if (new_route.arrivals.size() != cached_route.arrivals.size()) { - return true; - } - - for (size_t j = 0; j < new_route.arrivals.size(); j++) { - if (new_route.arrivals[j].arrival_time != cached_route.arrivals[j].arrival_time || - new_route.arrivals[j].arrival_time_full != cached_route.arrivals[j].arrival_time_full) { return true; } + + if (new_route.arrivals.size() != cached_route.arrivals.size()) { + return true; + } + + for (size_t j = 0; j < new_route.arrivals.size(); j++) { + if (new_route.arrivals[j].arrival_time != cached_route.arrivals[j].arrival_time || + new_route.arrivals[j].arrival_time_full != cached_route.arrivals[j].arrival_time_full) { + return true; + } + } + } + + return false; + } + + void MainUIHandler::on_settings_button_clicked_static_(lv_event_t* e) { + MainUIHandler* handler = static_cast(lv_event_get_user_data(e)); + if (handler) { + handler->on_settings_button_clicked_(); } } - return false; -} - -void MainUIHandler::on_settings_button_clicked_static_(lv_event_t* e) { - MainUIHandler* handler = static_cast(lv_event_get_user_data(e)); - if (handler) { - handler->on_settings_button_clicked_(); + void MainUIHandler::on_settings_button_clicked_() { + if (on_settings_callback_) { + on_settings_callback_(settings_callback_user_data_); + } } -} - -void MainUIHandler::on_settings_button_clicked_() { - if (on_settings_callback_) { - on_settings_callback_(settings_callback_user_data_); - } -} } // namespace travel