From 110fa96be8ae24f6d4580847e080c19a566e5d04 Mon Sep 17 00:00:00 2001 From: liu weikai Date: Tue, 21 Apr 2026 22:32:52 +0800 Subject: [PATCH] Refine node info UI and peer announcements --- .../node_info/node_info_page_components.h | 65 +- .../screens/node_info/node_info_page_layout.h | 14 +- .../node_info/node_info_page_components.cpp | 2210 +++++++++++------ .../node_info/node_info_page_layout.cpp | 269 +- .../screens/pc_link/pc_link_page_runtime.cpp | 6 +- .../src/ui/screens/usb/usb_page_runtime.cpp | 12 +- .../chat/infra/meshtastic/mt_adapter.h | 5 + .../src/app_runtime_support.cpp | 65 + .../src/chat/infra/meshtastic/mt_adapter.cpp | 45 + .../src/chat/internal/blob_store_io.cpp | 37 +- 10 files changed, 1721 insertions(+), 1007 deletions(-) diff --git a/modules/ui_shared/include/ui/screens/node_info/node_info_page_components.h b/modules/ui_shared/include/ui/screens/node_info/node_info_page_components.h index b08de55b..a2d69d16 100644 --- a/modules/ui_shared/include/ui/screens/node_info/node_info_page_components.h +++ b/modules/ui_shared/include/ui/screens/node_info/node_info_page_components.h @@ -5,6 +5,8 @@ #pragma once +#include + #include "chat/domain/contact_types.h" #include "lvgl.h" @@ -13,62 +15,53 @@ namespace node_info namespace ui { +static constexpr std::size_t kNodeInfoTileCount = 9; +static constexpr std::size_t kNodeInfoInfoLineCount = 8; + struct NodeInfoWidgets { lv_obj_t* root = nullptr; lv_obj_t* header = nullptr; lv_obj_t* content = nullptr; - lv_obj_t* top_row = nullptr; - lv_obj_t* info_card = nullptr; - lv_obj_t* info_header = nullptr; - lv_obj_t* info_footer = nullptr; - lv_obj_t* location_card = nullptr; - lv_obj_t* location_header = nullptr; - lv_obj_t* location_map = nullptr; - lv_obj_t* location_coords = nullptr; - lv_obj_t* location_updated = nullptr; - lv_obj_t* link_panel = nullptr; - lv_obj_t* link_header = nullptr; - lv_obj_t* link_row_1 = nullptr; - lv_obj_t* link_row_2 = nullptr; lv_obj_t* back_btn = nullptr; lv_obj_t* back_label = nullptr; lv_obj_t* title_label = nullptr; lv_obj_t* battery_label = nullptr; - lv_obj_t* avatar_bg = nullptr; - lv_obj_t* avatar_label = nullptr; - lv_obj_t* name_label = nullptr; - lv_obj_t* desc_label = nullptr; + lv_obj_t* map_stage = nullptr; + lv_obj_t* tile_layer = nullptr; + lv_obj_t* left_scrim = nullptr; + lv_obj_t* right_scrim = nullptr; + lv_obj_t* id_label = nullptr; - lv_obj_t* role_label = nullptr; + lv_obj_t* lon_label = nullptr; + lv_obj_t* lat_label = nullptr; + lv_obj_t* no_position_label = nullptr; - lv_obj_t* location_title_label = nullptr; - lv_obj_t* map_image = nullptr; - lv_obj_t* map_label = nullptr; - lv_obj_t* coords_latlon_label = nullptr; - lv_obj_t* coords_acc_label = nullptr; - lv_obj_t* coords_alt_label = nullptr; - lv_obj_t* updated_label = nullptr; + lv_obj_t* connection_line = nullptr; + lv_obj_t* marker_node_ring = nullptr; + lv_obj_t* marker_node_dot = nullptr; + lv_obj_t* marker_self_ring = nullptr; + lv_obj_t* marker_self_dot = nullptr; + lv_obj_t* distance_label = nullptr; - lv_obj_t* link_title_label = nullptr; - lv_obj_t* link_rssi_label = nullptr; - lv_obj_t* link_snr_label = nullptr; - lv_obj_t* link_ch_label = nullptr; - lv_obj_t* link_sf_label = nullptr; - lv_obj_t* link_bw_label = nullptr; - lv_obj_t* link_hop_label = nullptr; - lv_obj_t* link_last_heard_label = nullptr; + lv_obj_t* zoom_in_btn = nullptr; + lv_obj_t* zoom_out_btn = nullptr; + lv_obj_t* zoom_in_label = nullptr; + lv_obj_t* zoom_out_label = nullptr; + + lv_obj_t* tile_images[kNodeInfoTileCount]{}; + lv_obj_t* info_labels[kNodeInfoInfoLineCount]{}; }; /** - * @brief Create an empty Node Info screen structure. + * @brief Create the Node Info page widgets. */ NodeInfoWidgets create(lv_obj_t* parent); /** - * @brief Destroy the Node Info screen (if created). + * @brief Destroy the Node Info page (if created). */ void destroy(); @@ -78,7 +71,7 @@ void destroy(); const NodeInfoWidgets& widgets(); /** - * @brief Update UI labels with NodeInfo data. + * @brief Update UI widgets with NodeInfo data. */ void set_node_info(const chat::contacts::NodeInfo& node); diff --git a/modules/ui_shared/include/ui/screens/node_info/node_info_page_layout.h b/modules/ui_shared/include/ui/screens/node_info/node_info_page_layout.h index d3fd1ba5..38e5715d 100644 --- a/modules/ui_shared/include/ui/screens/node_info/node_info_page_layout.h +++ b/modules/ui_shared/include/ui/screens/node_info/node_info_page_layout.h @@ -1,6 +1,6 @@ /** * @file node_info_page_layout.h - * @brief Node info page layout (structure only) + * @brief Node info page layout helpers */ #pragma once @@ -17,18 +17,6 @@ namespace layout lv_obj_t* create_root(lv_obj_t* parent); lv_obj_t* create_header(lv_obj_t* root); lv_obj_t* create_content(lv_obj_t* root); -lv_obj_t* create_top_row(lv_obj_t* content); -lv_obj_t* create_info_card(lv_obj_t* top_row); -lv_obj_t* create_info_header(lv_obj_t* info_card); -lv_obj_t* create_info_footer(lv_obj_t* info_card); -lv_obj_t* create_location_card(lv_obj_t* top_row); -lv_obj_t* create_location_header(lv_obj_t* location_card); -lv_obj_t* create_location_map(lv_obj_t* location_card); -lv_obj_t* create_location_coords(lv_obj_t* location_card); -lv_obj_t* create_location_updated(lv_obj_t* location_card); -lv_obj_t* create_link_panel(lv_obj_t* content); -lv_obj_t* create_link_header(lv_obj_t* link_panel); -lv_obj_t* create_link_row(lv_obj_t* link_panel); } // namespace layout } // namespace ui diff --git a/modules/ui_shared/src/ui/screens/node_info/node_info_page_components.cpp b/modules/ui_shared/src/ui/screens/node_info/node_info_page_components.cpp index 0354da93..7155f0d2 100644 --- a/modules/ui_shared/src/ui/screens/node_info/node_info_page_components.cpp +++ b/modules/ui_shared/src/ui/screens/node_info/node_info_page_components.cpp @@ -4,19 +4,25 @@ */ #include "ui/screens/node_info/node_info_page_components.h" + #include "app/app_config.h" #include "app/app_facade_access.h" #include "chat/infra/meshtastic/mt_region.h" +#include "chat/usecase/contact_service.h" +#include "platform/ui/gps_runtime.h" #include "sys/clock.h" #include "ui/assets/fonts/font_utils.h" #include "ui/localization.h" +#include "ui/menu/dashboard/dashboard_style.h" +#include "ui/page/page_profile.h" #include "ui/screens/node_info/node_info_page_layout.h" #include "ui/ui_common.h" #include "ui/widgets/top_bar.h" + #include #include #include -#include +#include #include #ifndef M_PI @@ -30,23 +36,80 @@ namespace ui namespace { + +namespace dashboard = ::ui::menu::dashboard; + NodeInfoWidgets s_widgets; ::ui::widgets::TopBar s_top_bar; -static const lv_color_t kColorAccent = lv_color_hex(0xEBA341); -static const lv_color_t kColorText = lv_color_hex(0x3A2A1A); -static const lv_color_t kColorTextMuted = lv_color_hex(0x6A5646); -static const lv_color_t kColorPageBg = lv_color_hex(0xFFF3DF); -static const lv_color_t kColorCardBg = lv_color_hex(0xFFF7E9); -static const lv_color_t kColorCardBorder = lv_color_hex(0xD9B06A); -static const lv_color_t kColorSeparator = lv_color_hex(0xE8D2AB); -static const lv_color_t kColorInfoBar = lv_color_hex(0xFFF0D3); -static const lv_color_t kColorAvatarBg = lv_color_hex(0x5BAF4A); -static const lv_color_t kColorMapBg = lv_color_hex(0xF6E7C8); -static const lv_color_t kColorMapAccent = lv_color_hex(0x2F6FD6); +struct GeoPoint +{ + bool valid = false; + double lat = 0.0; + double lon = 0.0; +}; + +struct NodeInfoRuntimeState +{ + bool has_node = false; + bool map_ready = false; + chat::contacts::NodeInfo node{}; + GeoPoint self_point{}; + int zoom = 12; + char tile_paths[kNodeInfoTileCount][96]{}; + lv_point_precise_t link_points[2]{}; +}; + +NodeInfoRuntimeState s_state; + +struct ViewMetrics +{ + lv_coord_t width = 0; + lv_coord_t height = 0; + lv_coord_t pad = 12; + lv_coord_t right_col_w = 120; + lv_coord_t right_x = 0; + lv_coord_t left_w = 0; + lv_coord_t focus_x = 0; + lv_coord_t focus_y = 0; + lv_coord_t info_top = 0; + lv_coord_t info_line_h = 16; + lv_coord_t info_gap = 5; + lv_coord_t zoom_size = 30; + lv_coord_t zoom_gap = 8; + bool compact = true; +}; -constexpr int kNodeInfoMapZoom = 12; constexpr int kTileSize = 256; +constexpr int kDefaultZoom = 12; +constexpr int kMinZoom = 2; +constexpr int kMaxZoom = 16; + +static const lv_color_t kColorBackdrop = lv_color_hex(0x0D1520); +static const lv_color_t kColorBackdropAlt = lv_color_hex(0x121F2A); +static const lv_color_t kColorTopBar = lv_color_hex(0x081018); +static const lv_color_t kColorTopText = lv_color_hex(0xF4E6C7); +static const lv_color_t kColorId = lv_color_hex(0xFFB44E); +static const lv_color_t kColorLon = lv_color_hex(0x5ED8FF); +static const lv_color_t kColorLat = lv_color_hex(0x96EA69); +static const lv_color_t kColorDistance = lv_color_hex(0xFFD166); +static const lv_color_t kColorNodeMarker = lv_color_hex(0xFF8C42); +static const lv_color_t kColorSelfMarker = lv_color_hex(0x4ED9FF); +static const lv_color_t kColorLink = lv_color_hex(0x8BE2C8); +static const lv_color_t kColorMuted = lv_color_hex(0xB5C7D3); +static const lv_color_t kColorButtonBg = lv_color_hex(0x182739); +static const lv_color_t kColorButtonDisabled = lv_color_hex(0x243545); + +static const lv_color_t kInfoLineColors[kNodeInfoInfoLineCount] = { + lv_color_hex(0xFF9D6C), + lv_color_hex(0x69E5DB), + lv_color_hex(0xB1F06D), + lv_color_hex(0xFFD26A), + lv_color_hex(0xB99CFF), + lv_color_hex(0xFF9CCA), + lv_color_hex(0x83CAFF), + lv_color_hex(0xF4F28C), +}; const lv_font_t* font_montserrat_12_safe() { @@ -57,12 +120,30 @@ const lv_font_t* font_montserrat_12_safe() #endif } +const lv_font_t* font_montserrat_14_safe() +{ +#if defined(LV_FONT_MONTSERRAT_14) && LV_FONT_MONTSERRAT_14 + return &lv_font_montserrat_14; +#else + return font_montserrat_12_safe(); +#endif +} + const lv_font_t* font_montserrat_16_safe() { #if defined(LV_FONT_MONTSERRAT_16) && LV_FONT_MONTSERRAT_16 return &lv_font_montserrat_16; #else - return LV_FONT_DEFAULT; + return font_montserrat_14_safe(); +#endif +} + +const lv_font_t* font_montserrat_18_safe() +{ +#if defined(LV_FONT_MONTSERRAT_18) && LV_FONT_MONTSERRAT_18 + return &lv_font_montserrat_18; +#else + return font_montserrat_16_safe(); #endif } @@ -71,17 +152,211 @@ const lv_font_t* font_montserrat_22_safe() #if defined(LV_FONT_MONTSERRAT_22) && LV_FONT_MONTSERRAT_22 return &lv_font_montserrat_22; #else - return font_montserrat_16_safe(); + return font_montserrat_18_safe(); #endif } -const lv_font_t* font_montserrat_24_safe() +lv_coord_t clamp_coord(lv_coord_t value, lv_coord_t min_value, lv_coord_t max_value) { -#if defined(LV_FONT_MONTSERRAT_24) && LV_FONT_MONTSERRAT_24 - return &lv_font_montserrat_24; -#else - return font_montserrat_16_safe(); -#endif + if (max_value < min_value) + { + max_value = min_value; + } + if (value < min_value) + { + return min_value; + } + if (value > max_value) + { + return max_value; + } + return value; +} + +double clamp_double(double value, double min_value, double max_value) +{ + if (max_value < min_value) + { + max_value = min_value; + } + if (value < min_value) + { + return min_value; + } + if (value > max_value) + { + return max_value; + } + return value; +} + +void make_plain(lv_obj_t* obj) +{ + lv_obj_clear_flag(obj, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_scrollbar_mode(obj, LV_SCROLLBAR_MODE_OFF); + lv_obj_set_style_pad_all(obj, 0, 0); + lv_obj_set_style_border_width(obj, 0, 0); + lv_obj_set_style_radius(obj, 0, 0); +} + +void set_label_text(lv_obj_t* label, const char* text) +{ + if (!label || !text) + { + return; + } + lv_label_set_text(label, text); + ::ui::fonts::apply_localized_font(label, text, lv_obj_get_style_text_font(label, LV_PART_MAIN)); +} + +lv_obj_t* create_label(lv_obj_t* parent, + const char* text, + const lv_font_t* font, + lv_color_t color) +{ + lv_obj_t* label = lv_label_create(parent); + lv_label_set_text(label, text); + lv_obj_set_style_text_font(label, font, 0); + lv_obj_set_style_text_color(label, color, 0); + lv_obj_set_style_bg_opa(label, LV_OPA_TRANSP, 0); + ::ui::fonts::apply_localized_font(label, text, font); + return label; +} + +void apply_top_bar_style() +{ + if (!s_top_bar.container) + { + return; + } + + lv_obj_set_style_bg_color(s_top_bar.container, kColorTopBar, 0); + lv_obj_set_style_bg_opa(s_top_bar.container, 220, 0); + lv_obj_set_style_border_width(s_top_bar.container, 1, 0); + lv_obj_set_style_border_side(s_top_bar.container, LV_BORDER_SIDE_BOTTOM, 0); + lv_obj_set_style_border_color(s_top_bar.container, lv_color_hex(0x294057), 0); + + if (s_widgets.title_label) + { + lv_obj_set_style_text_color(s_widgets.title_label, kColorTopText, 0); + } + if (s_widgets.battery_label) + { + lv_obj_set_style_text_color(s_widgets.battery_label, kColorTopText, 0); + } + if (s_widgets.back_label) + { + lv_obj_set_style_text_color(s_widgets.back_label, kColorTopText, 0); + } +} + +void apply_zoom_button_style(lv_obj_t* button, lv_obj_t* label) +{ + if (!button) + { + return; + } + + lv_obj_set_style_radius(button, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_bg_color(button, kColorButtonBg, 0); + lv_obj_set_style_bg_opa(button, 190, 0); + lv_obj_set_style_border_width(button, 1, 0); + lv_obj_set_style_border_color(button, lv_color_hex(0x44627C), 0); + lv_obj_set_style_shadow_width(button, 0, 0); + lv_obj_set_style_bg_color(button, kColorButtonDisabled, LV_STATE_DISABLED); + lv_obj_set_style_bg_opa(button, 120, LV_STATE_DISABLED); + lv_obj_set_style_border_color(button, lv_color_hex(0x354B5E), LV_STATE_DISABLED); + lv_obj_clear_flag(button, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_scrollbar_mode(button, LV_SCROLLBAR_MODE_OFF); + + if (label) + { + lv_obj_set_style_text_font(label, font_montserrat_22_safe(), 0); + lv_obj_set_style_text_color(label, kColorTopText, 0); + ::ui::fonts::apply_localized_font(label, lv_label_get_text(label), font_montserrat_22_safe()); + } +} + +void apply_marker_style(lv_obj_t* obj, lv_coord_t size, lv_color_t color, bool filled) +{ + if (!obj) + { + return; + } + + lv_obj_set_size(obj, size, size); + lv_obj_set_style_radius(obj, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_border_width(obj, filled ? 0 : 2, 0); + lv_obj_set_style_border_color(obj, color, 0); + lv_obj_set_style_bg_color(obj, color, 0); + lv_obj_set_style_bg_opa(obj, filled ? LV_OPA_COVER : LV_OPA_TRANSP, 0); + lv_obj_clear_flag(obj, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_scrollbar_mode(obj, LV_SCROLLBAR_MODE_OFF); +} + +void apply_scrim_style(lv_obj_t* obj, lv_color_t color, lv_opa_t opa) +{ + if (!obj) + { + return; + } + + lv_obj_set_style_bg_color(obj, color, 0); + lv_obj_set_style_bg_opa(obj, opa, 0); + lv_obj_set_style_border_width(obj, 0, 0); + lv_obj_set_style_radius(obj, 0, 0); + make_plain(obj); +} + +ViewMetrics view_metrics() +{ + ViewMetrics metrics{}; + const auto& profile = ::ui::page_profile::current(); + + metrics.width = s_widgets.content ? lv_obj_get_width(s_widgets.content) : 0; + metrics.height = s_widgets.content ? lv_obj_get_height(s_widgets.content) : 0; + if (metrics.width <= 0 && s_widgets.root) + { + metrics.width = lv_obj_get_width(s_widgets.root); + } + if (metrics.height <= 0 && s_widgets.root) + { + metrics.height = lv_obj_get_height(s_widgets.root) - profile.top_bar_height; + } + + metrics.pad = profile.large_touch_hitbox ? 18 : 12; + metrics.compact = metrics.width < 420; + metrics.right_col_w = metrics.compact ? 124 : 176; + if (metrics.width > 0) + { + const lv_coord_t max_right = metrics.width - (metrics.pad * 2) - 96; + if (max_right < metrics.right_col_w) + { + metrics.right_col_w = max_right; + } + } + if (metrics.right_col_w < 108) + { + metrics.right_col_w = 108; + } + metrics.right_x = metrics.width - metrics.pad - metrics.right_col_w; + metrics.left_w = metrics.right_x - metrics.pad; + if (metrics.left_w < 96) + { + metrics.left_w = 96; + } + + const lv_coord_t focus_min = metrics.pad + 42; + const lv_coord_t focus_max = metrics.right_x - metrics.pad - 28; + metrics.focus_x = clamp_coord(metrics.width / 3, focus_min, focus_max); + metrics.focus_y = metrics.height / 2; + + metrics.info_top = metrics.pad + 10; + metrics.info_line_h = metrics.compact ? 14 : 16; + metrics.info_gap = metrics.compact ? 5 : 6; + metrics.zoom_size = metrics.compact ? 30 : 36; + metrics.zoom_gap = metrics.compact ? 8 : 10; + return metrics; } uint8_t sanitize_map_source(uint8_t map_source) @@ -89,7 +364,7 @@ uint8_t sanitize_map_source(uint8_t map_source) return map_source <= 2 ? map_source : 0; } -bool build_base_tile_path(int z, int x, int y, uint8_t map_source, char* out_path, size_t out_size) +bool build_base_tile_path(int zoom, int x, int y, uint8_t map_source, char* out_path, size_t out_size) { if (!out_path || out_size == 0) { @@ -111,81 +386,260 @@ bool build_base_tile_path(int z, int x, int y, uint8_t map_source, char* out_pat break; } - std::snprintf(out_path, out_size, "A:/maps/base/%s/%d/%d/%d.%s", source_dir, z, x, y, ext); + std::snprintf(out_path, out_size, "A:/maps/base/%s/%d/%d/%d.%s", source_dir, zoom, x, y, ext); out_path[out_size - 1] = '\0'; return true; } -void apply_root_style(lv_obj_t* obj) +bool tile_exists(const char* path) { - lv_obj_set_style_bg_color(obj, kColorPageBg, 0); - lv_obj_set_style_bg_opa(obj, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(obj, 0, 0); - lv_obj_set_style_radius(obj, 0, 0); -} - -void apply_card_style(lv_obj_t* obj) -{ - lv_obj_set_style_bg_color(obj, kColorCardBg, 0); - lv_obj_set_style_bg_opa(obj, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(obj, 2, 0); - lv_obj_set_style_border_color(obj, kColorCardBorder, 0); - lv_obj_set_style_radius(obj, 10, 0); - lv_obj_set_style_pad_all(obj, 0, 0); -} - -void apply_title_bar_style(lv_obj_t* obj) -{ - lv_obj_set_style_bg_color(obj, kColorAccent, 0); - lv_obj_set_style_bg_opa(obj, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(obj, 0, 0); - lv_obj_set_style_radius(obj, 10, 0); - lv_obj_set_style_pad_all(obj, 0, 0); -} - -void apply_info_bar_style(lv_obj_t* obj, int radius) -{ - lv_obj_set_style_bg_color(obj, kColorInfoBar, 0); - lv_obj_set_style_bg_opa(obj, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(obj, 1, 0); - lv_obj_set_style_border_color(obj, kColorSeparator, 0); - lv_obj_set_style_radius(obj, radius, 0); - lv_obj_set_style_pad_all(obj, 0, 0); -} - -void apply_map_style(lv_obj_t* obj) -{ - lv_obj_set_style_bg_color(obj, kColorMapBg, 0); - lv_obj_set_style_bg_opa(obj, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(obj, 1, 0); - lv_obj_set_style_border_color(obj, kColorSeparator, 0); - lv_obj_set_style_radius(obj, 6, 0); - lv_obj_set_style_pad_all(obj, 0, 0); -} - -void apply_row_style(lv_obj_t* obj) -{ - lv_obj_set_style_bg_opa(obj, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(obj, 0, 0); - lv_obj_set_style_pad_all(obj, 0, 0); -} - -lv_obj_t* create_label(lv_obj_t* parent, const char* text, const lv_font_t* font, lv_color_t color) -{ - lv_obj_t* label = lv_label_create(parent); - lv_label_set_text(label, text); - lv_obj_set_style_text_font(label, font, 0); - lv_obj_set_style_text_color(label, color, 0); - ::ui::fonts::apply_localized_font(label, text, font); - return label; -} - -void set_label_text(lv_obj_t* label, const char* text) -{ - if (label && text) + if (!path || path[0] == '\0') { - lv_label_set_text(label, text); - ::ui::fonts::apply_localized_font(label, text, lv_obj_get_style_text_font(label, LV_PART_MAIN)); + return false; + } + + lv_fs_file_t file; + const lv_fs_res_t res = lv_fs_open(&file, path, LV_FS_MODE_RD); + if (res != LV_FS_RES_OK) + { + return false; + } + + lv_fs_close(&file); + return true; +} + +bool latlng_to_world_pixels(double lat, double lon, int zoom, double& out_x, double& out_y) +{ + const double kMaxLat = 85.05112878; + lat = clamp_double(lat, -kMaxLat, kMaxLat); + while (lon < -180.0) + { + lon += 360.0; + } + while (lon >= 180.0) + { + lon -= 360.0; + } + + const double n = static_cast(1 << zoom); + const double lat_rad = lat * M_PI / 180.0; + const double tile_x = (lon + 180.0) / 360.0 * n; + const double tile_y = + (1.0 - std::log(std::tan(lat_rad) + 1.0 / std::cos(lat_rad)) / M_PI) / 2.0 * n; + + out_x = tile_x * static_cast(kTileSize); + out_y = tile_y * static_cast(kTileSize); + return true; +} + +int normalize_tile_x(int x, int zoom) +{ + const int tile_count = 1 << zoom; + if (tile_count <= 0) + { + return 0; + } + + int value = x % tile_count; + if (value < 0) + { + value += tile_count; + } + return value; +} + +GeoPoint node_point_from_info(const chat::contacts::NodeInfo& node) +{ + GeoPoint point{}; + if (!node.position.valid) + { + return point; + } + + point.valid = true; + point.lat = static_cast(node.position.latitude_i) * 1e-7; + point.lon = static_cast(node.position.longitude_i) * 1e-7; + return point; +} + +GeoPoint resolve_self_position() +{ + GeoPoint point{}; + + if (app::hasAppFacade()) + { + const chat::NodeId self_node_id = app::messagingFacade().getSelfNodeId(); + if (self_node_id != 0) + { + const auto* self_info = app::messagingFacade().getContactService().getNodeInfo(self_node_id); + if (self_info && self_info->position.valid) + { + point.valid = true; + point.lat = static_cast(self_info->position.latitude_i) * 1e-7; + point.lon = static_cast(self_info->position.longitude_i) * 1e-7; + return point; + } + } + } + + const auto gps = platform::ui::gps::get_data(); + if (gps.valid) + { + point.valid = true; + point.lat = gps.lat; + point.lon = gps.lng; + } + return point; +} + +bool center_tile_exists(const GeoPoint& node_point, int zoom) +{ + if (!node_point.valid) + { + return false; + } + + double world_x = 0.0; + double world_y = 0.0; + latlng_to_world_pixels(node_point.lat, node_point.lon, zoom, world_x, world_y); + const int tile_x = normalize_tile_x(static_cast(std::floor(world_x / kTileSize)), zoom); + const int tile_y = static_cast(std::floor(world_y / kTileSize)); + const int max_tile = (1 << zoom) - 1; + if (tile_y < 0 || tile_y > max_tile) + { + return false; + } + + char path[96]; + if (!build_base_tile_path( + zoom, tile_x, tile_y, sanitize_map_source(app::configFacade().getConfig().map_source), path, sizeof(path))) + { + return false; + } + return tile_exists(path); +} + +int best_available_zoom(const GeoPoint& node_point) +{ + if (!node_point.valid) + { + return kDefaultZoom; + } + + if (center_tile_exists(node_point, kDefaultZoom)) + { + return kDefaultZoom; + } + + for (int delta = 1; delta <= (kMaxZoom - kMinZoom); ++delta) + { + const int higher = kDefaultZoom + delta; + if (higher <= kMaxZoom && center_tile_exists(node_point, higher)) + { + return higher; + } + const int lower = kDefaultZoom - delta; + if (lower >= kMinZoom && center_tile_exists(node_point, lower)) + { + return lower; + } + } + + return kDefaultZoom; +} + +int compute_initial_zoom(const GeoPoint& node_point, const GeoPoint& self_point, const ViewMetrics& metrics) +{ + if (!node_point.valid) + { + return kDefaultZoom; + } + if (!self_point.valid) + { + return best_available_zoom(node_point); + } + + const double min_x = static_cast(metrics.pad + 8); + const double max_x = static_cast(metrics.right_x - 14); + const double min_y = static_cast(metrics.pad + 6); + const double max_y = static_cast(metrics.height - metrics.pad - 6); + + for (int zoom = kMaxZoom; zoom >= kMinZoom; --zoom) + { + if (!center_tile_exists(node_point, zoom)) + { + continue; + } + + double node_x = 0.0; + double node_y = 0.0; + double self_x = 0.0; + double self_y = 0.0; + latlng_to_world_pixels(node_point.lat, node_point.lon, zoom, node_x, node_y); + latlng_to_world_pixels(self_point.lat, self_point.lon, zoom, self_x, self_y); + + const double draw_x = static_cast(metrics.focus_x) + (self_x - node_x); + const double draw_y = static_cast(metrics.focus_y) + (self_y - node_y); + if (draw_x >= min_x && draw_x <= max_x && draw_y >= min_y && draw_y <= max_y) + { + return zoom; + } + } + + return best_available_zoom(node_point); +} + +int select_zoom_after_delta(const GeoPoint& node_point, int current_zoom, int delta) +{ + if (!node_point.valid) + { + return current_zoom; + } + + const int start = clamp_coord(current_zoom + delta, kMinZoom, kMaxZoom); + if (center_tile_exists(node_point, start)) + { + return start; + } + + if (delta > 0) + { + for (int zoom = start + 1; zoom <= kMaxZoom; ++zoom) + { + if (center_tile_exists(node_point, zoom)) + { + return zoom; + } + } + } + else if (delta < 0) + { + for (int zoom = start - 1; zoom >= kMinZoom; --zoom) + { + if (center_tile_exists(node_point, zoom)) + { + return zoom; + } + } + } + + return current_zoom; +} + +void set_hidden(lv_obj_t* obj, bool hidden) +{ + if (!obj) + { + return; + } + if (hidden) + { + lv_obj_add_flag(obj, LV_OBJ_FLAG_HIDDEN); + } + else + { + lv_obj_clear_flag(obj, LV_OBJ_FLAG_HIDDEN); } } @@ -195,196 +649,54 @@ void format_node_id(uint32_t node_id, char* out, size_t out_len) { return; } - if (node_id <= 0xFFFFFF) + + if (node_id <= 0xFFFFFFUL) { - const std::string text = - ::ui::i18n::format("ID: !%06lx", static_cast(node_id)); - snprintf(out, out_len, "%s", text.c_str()); + std::snprintf(out, out_len, "ID !%06lX", static_cast(node_id)); } else { - const std::string text = - ::ui::i18n::format("ID: !%08lx", static_cast(node_id)); - snprintf(out, out_len, "%s", text.c_str()); + std::snprintf(out, out_len, "ID !%08lX", static_cast(node_id)); } } -void format_age(const char* prefix, uint32_t ts, char* out, size_t out_len) +void format_age_short(uint32_t ts, char* out, size_t out_len) { if (!out || out_len == 0) { return; } + if (ts == 0) { - const std::string text = ::ui::i18n::format("%s -", prefix); - snprintf(out, out_len, "%s", text.c_str()); + std::snprintf(out, out_len, "Seen --"); return; } - uint32_t now = sys::epoch_seconds_now(); - if (now < ts) + + const uint32_t now = sys::epoch_seconds_now(); + if (now <= ts) { - const std::string text = ::ui::i18n::format("%s 0s", prefix); - snprintf(out, out_len, "%s", text.c_str()); + std::snprintf(out, out_len, "Seen 0s"); return; } - uint32_t age = now - ts; + + const uint32_t age = now - ts; if (age < 60) { - const std::string text = - ::ui::i18n::format("%s %us", prefix, static_cast(age)); - snprintf(out, out_len, "%s", text.c_str()); + std::snprintf(out, out_len, "Seen %us", static_cast(age)); return; } if (age < 3600) { - const std::string text = - ::ui::i18n::format("%s %um", prefix, static_cast(age / 60)); - snprintf(out, out_len, "%s", text.c_str()); + std::snprintf(out, out_len, "Seen %um", static_cast(age / 60)); return; } if (age < 86400) { - const std::string text = - ::ui::i18n::format("%s %uh", prefix, static_cast(age / 3600)); - snprintf(out, out_len, "%s", text.c_str()); + std::snprintf(out, out_len, "Seen %uh", static_cast(age / 3600)); return; } - const std::string text = - ::ui::i18n::format("%s %ud", prefix, static_cast(age / 86400)); - snprintf(out, out_len, "%s", text.c_str()); -} - -void format_radio_params(char* ch_out, size_t ch_len, char* sf_out, size_t sf_len, - char* bw_out, size_t bw_len) -{ - if (!ch_out || ch_len == 0 || !sf_out || sf_len == 0 || !bw_out || bw_len == 0) - { - return; - } - - ch_out[0] = '\0'; - sf_out[0] = '\0'; - bw_out[0] = '\0'; - - const auto& cfg = app::configFacade().getConfig(); - auto region_code = static_cast(cfg.meshtastic_config.region); - if (region_code == meshtastic_Config_LoRaConfig_RegionCode_UNSET) - { - region_code = meshtastic_Config_LoRaConfig_RegionCode_CN; - } - const chat::meshtastic::RegionInfo* region = chat::meshtastic::findRegion(region_code); - if (!region) - { - snprintf(ch_out, ch_len, "%s", ::ui::i18n::tr("Ch: -")); - snprintf(sf_out, sf_len, "%s", ::ui::i18n::tr("SF: -")); - snprintf(bw_out, bw_len, "%s", ::ui::i18n::tr("BW: -")); - return; - } - - float bw_khz = 250.0f; - uint8_t sf = 11; - auto preset = static_cast(cfg.meshtastic_config.modem_preset); - switch (preset) - { - case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO: - bw_khz = region->wide_lora ? 1625.0f : 500.0f; - sf = 7; - break; - case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST: - bw_khz = region->wide_lora ? 812.5f : 250.0f; - sf = 7; - break; - case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW: - bw_khz = region->wide_lora ? 812.5f : 250.0f; - sf = 8; - break; - case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST: - bw_khz = region->wide_lora ? 812.5f : 250.0f; - sf = 9; - break; - case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW: - bw_khz = region->wide_lora ? 812.5f : 250.0f; - sf = 10; - break; - case meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO: - bw_khz = region->wide_lora ? 1625.0f : 500.0f; - sf = 11; - break; - case meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE: - bw_khz = region->wide_lora ? 406.25f : 125.0f; - sf = 11; - break; - case meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW: - bw_khz = region->wide_lora ? 406.25f : 125.0f; - sf = 12; - break; - case meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST: - default: - bw_khz = region->wide_lora ? 812.5f : 250.0f; - sf = 11; - break; - } - - const char* channel_name = chat::meshtastic::presetDisplayName(preset); - float freq_mhz = chat::meshtastic::computeFrequencyMhz(region, bw_khz, channel_name); - if (freq_mhz <= 0.0f) - { - freq_mhz = region->freq_start_mhz + (bw_khz / 2000.0f); - } - - const std::string ch_text = ::ui::i18n::format("Ch: %.3f", static_cast(freq_mhz)); - const std::string sf_text = ::ui::i18n::format("SF: %u", static_cast(sf)); - snprintf(ch_out, ch_len, "%s", ch_text.c_str()); - snprintf(sf_out, sf_len, "%s", sf_text.c_str()); - float bw_round = std::round(bw_khz); - if (std::fabs(bw_khz - bw_round) < 0.05f) - { - const std::string bw_text = ::ui::i18n::format("BW: %.0fk", static_cast(bw_khz)); - snprintf(bw_out, bw_len, "%s", bw_text.c_str()); - } - else - { - const std::string bw_text = ::ui::i18n::format("BW: %.1fk", static_cast(bw_khz)); - snprintf(bw_out, bw_len, "%s", bw_text.c_str()); - } -} - -const char* role_to_text(chat::contacts::NodeRoleType role) -{ - using chat::contacts::NodeRoleType; - switch (role) - { - case NodeRoleType::Client: - return "Client"; - case NodeRoleType::ClientMute: - return "ClientMute"; - case NodeRoleType::Router: - return "Router"; - case NodeRoleType::RouterClient: - return "RouterClient"; - case NodeRoleType::Repeater: - return "Repeater"; - case NodeRoleType::Tracker: - return "Tracker"; - case NodeRoleType::Sensor: - return "Sensor"; - case NodeRoleType::Tak: - return "TAK"; - case NodeRoleType::ClientHidden: - return "ClientHidden"; - case NodeRoleType::LostAndFound: - return "Lost&Found"; - case NodeRoleType::TakTracker: - return "TAKTracker"; - case NodeRoleType::RouterLate: - return "RouterLate"; - case NodeRoleType::ClientBase: - return "ClientBase"; - case NodeRoleType::Unknown: - default: - return "-"; - } + std::snprintf(out, out_len, "Seen %ud", static_cast(age / 86400)); } double compute_accuracy_m(const chat::contacts::NodePosition& pos) @@ -394,7 +706,7 @@ double compute_accuracy_m(const chat::contacts::NodePosition& pos) return -1.0; } double acc = static_cast(pos.gps_accuracy_mm) / 1000.0; - uint32_t dop = pos.pdop ? pos.pdop : (pos.hdop ? pos.hdop : pos.vdop); + const uint32_t dop = pos.pdop ? pos.pdop : (pos.hdop ? pos.hdop : pos.vdop); if (dop > 0) { acc *= static_cast(dop) / 100.0; @@ -402,327 +714,986 @@ double compute_accuracy_m(const chat::contacts::NodePosition& pos) return acc; } -bool latlng_to_tile(double lat, double lon, int zoom, int& tile_x, int& tile_y, int& offset_x, int& offset_y) +bool get_meshtastic_radio_params(double& out_freq_mhz, unsigned& out_sf, double& out_bw_khz) { - const double kMaxLat = 85.05112878; - if (lat > kMaxLat) lat = kMaxLat; - if (lat < -kMaxLat) lat = -kMaxLat; - while (lon < -180.0) lon += 360.0; - while (lon >= 180.0) lon -= 360.0; + const auto& cfg = app::configFacade().getConfig(); + auto region_code = + static_cast(cfg.meshtastic_config.region); + if (region_code == meshtastic_Config_LoRaConfig_RegionCode_UNSET) + { + region_code = meshtastic_Config_LoRaConfig_RegionCode_CN; + } - double n = std::pow(2.0, zoom); - double lat_rad = lat * M_PI / 180.0; - double x = (lon + 180.0) / 360.0 * n; - double y = (1.0 - std::log(std::tan(lat_rad) + 1.0 / std::cos(lat_rad)) / M_PI) / 2.0 * n; + const chat::meshtastic::RegionInfo* region = chat::meshtastic::findRegion(region_code); + if (!region) + { + return false; + } - int x_int = static_cast(std::floor(x)); - int y_int = static_cast(std::floor(y)); - if (x_int < 0) x_int = 0; - if (y_int < 0) y_int = 0; - int max_tile = static_cast(n) - 1; - if (x_int > max_tile) x_int = max_tile; - if (y_int > max_tile) y_int = max_tile; + double bw_khz = 250.0; + unsigned sf = 11; + const auto preset = static_cast( + cfg.meshtastic_config.modem_preset); - tile_x = x_int; - tile_y = y_int; + switch (preset) + { + case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO: + bw_khz = region->wide_lora ? 1625.0 : 500.0; + sf = 7; + break; + case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST: + bw_khz = region->wide_lora ? 812.5 : 250.0; + sf = 7; + break; + case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW: + bw_khz = region->wide_lora ? 812.5 : 250.0; + sf = 8; + break; + case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST: + bw_khz = region->wide_lora ? 812.5 : 250.0; + sf = 9; + break; + case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW: + bw_khz = region->wide_lora ? 812.5 : 250.0; + sf = 10; + break; + case meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO: + bw_khz = region->wide_lora ? 1625.0 : 500.0; + sf = 11; + break; + case meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE: + bw_khz = region->wide_lora ? 406.25 : 125.0; + sf = 11; + break; + case meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW: + bw_khz = region->wide_lora ? 406.25 : 125.0; + sf = 12; + break; + case meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST: + default: + bw_khz = region->wide_lora ? 812.5 : 250.0; + sf = 11; + break; + } - double frac_x = x - static_cast(x_int); - double frac_y = y - static_cast(y_int); - offset_x = static_cast(std::round(frac_x * kTileSize)); - offset_y = static_cast(std::round(frac_y * kTileSize)); - if (offset_x < 0) offset_x = 0; - if (offset_x >= kTileSize) offset_x = kTileSize - 1; - if (offset_y < 0) offset_y = 0; - if (offset_y >= kTileSize) offset_y = kTileSize - 1; + const char* channel_name = chat::meshtastic::presetDisplayName(preset); + double freq_mhz = chat::meshtastic::computeFrequencyMhz( + region, static_cast(bw_khz), channel_name); + if (freq_mhz <= 0.0) + { + freq_mhz = region->freq_start_mhz + (bw_khz / 2000.0); + } + + out_freq_mhz = freq_mhz; + out_sf = sf; + out_bw_khz = bw_khz; return true; } -void update_location_map(const chat::contacts::NodeInfo& node) +const char* protocol_name(chat::contacts::NodeProtocolType protocol) { - if (!s_widgets.location_map) + switch (protocol) + { + case chat::contacts::NodeProtocolType::Meshtastic: + return "Meshtastic"; + case chat::contacts::NodeProtocolType::MeshCore: + return "MeshCore"; + case chat::contacts::NodeProtocolType::RNode: + return "RNode"; + case chat::contacts::NodeProtocolType::LXMF: + return "LXMF"; + default: + return "Unknown"; + } +} + +const char* protocol_short_name(chat::contacts::NodeProtocolType protocol) +{ + switch (protocol) + { + case chat::contacts::NodeProtocolType::Meshtastic: + return "MT"; + case chat::contacts::NodeProtocolType::MeshCore: + return "MC"; + case chat::contacts::NodeProtocolType::RNode: + return "RN"; + case chat::contacts::NodeProtocolType::LXMF: + return "LX"; + default: + return "--"; + } +} + +std::string preferred_node_title(const chat::contacts::NodeInfo& node) +{ + if (!node.display_name.empty()) + { + return node.display_name; + } + if (node.short_name[0] != '\0') + { + return node.short_name; + } + if (node.long_name[0] != '\0') + { + return node.long_name; + } + return ::ui::i18n::tr("NODE INFO"); +} + +void set_top_bar_title(const chat::contacts::NodeInfo& node) +{ + if (!s_widgets.title_label) { return; } - if (!s_widgets.map_image || !lv_obj_is_valid(s_widgets.map_image)) + const std::string title = preferred_node_title(node); + ::ui::i18n::set_content_label_text_raw(s_widgets.title_label, title.c_str()); + lv_obj_set_style_text_color(s_widgets.title_label, kColorTopText, 0); +} + +void format_coord_label(const char* prefix, double value, char* out, size_t out_len) +{ + if (!out || out_len == 0) { - s_widgets.map_image = lv_image_create(s_widgets.location_map); - lv_obj_set_size(s_widgets.map_image, kTileSize, kTileSize); - lv_obj_set_style_border_width(s_widgets.map_image, 0, 0); - lv_obj_set_style_pad_all(s_widgets.map_image, 0, 0); - lv_obj_move_background(s_widgets.map_image); + return; + } + std::snprintf(out, out_len, "%s %.5f", prefix, value); +} + +bool append_info_line(std::size_t& count, const char* text) +{ + if (!text || text[0] == '\0' || count >= kNodeInfoInfoLineCount) + { + return false; } + set_label_text(s_widgets.info_labels[count], text); + set_hidden(s_widgets.info_labels[count], false); + ++count; + return true; +} + +void hide_unused_info_lines(std::size_t visible_count) +{ + for (std::size_t index = visible_count; index < kNodeInfoInfoLineCount; ++index) + { + set_hidden(s_widgets.info_labels[index], true); + } +} + +void build_protocol_line(const chat::contacts::NodeInfo& node, bool compact, char* out, size_t out_len) +{ + const char* medium = node.via_mqtt ? "MQTT" : "LoRa"; + if (compact) + { + std::snprintf(out, out_len, "%s / %s", protocol_short_name(node.protocol), medium); + return; + } + std::snprintf(out, out_len, "Protocol %s / %s", protocol_name(node.protocol), medium); +} + +void build_signal_line(const chat::contacts::NodeInfo& node, bool compact, char* out, size_t out_len) +{ + char rssi[24]; + char snr[24]; + + if (std::isnan(node.rssi)) + { + std::snprintf(rssi, sizeof(rssi), "RSSI --"); + } + else + { + std::snprintf(rssi, sizeof(rssi), "RSSI %.0f", node.rssi); + } + + if (std::isnan(node.snr)) + { + std::snprintf(snr, sizeof(snr), "SNR --"); + } + else + { + std::snprintf(snr, sizeof(snr), "SNR %.1f", node.snr); + } + + if (compact) + { + std::snprintf(out, out_len, "%s / %s", rssi, snr); + } + else + { + std::snprintf(out, out_len, "Signal %s / %s", rssi + 5, snr + 4); + } +} + +bool build_radio_line(const chat::contacts::NodeInfo& node, bool compact, char* out, size_t out_len) +{ + if (node.protocol == chat::contacts::NodeProtocolType::Meshtastic) + { + double freq_mhz = 0.0; + double bw_khz = 0.0; + unsigned sf = 0; + if (!get_meshtastic_radio_params(freq_mhz, sf, bw_khz)) + { + return false; + } + + if (compact) + { + std::snprintf(out, out_len, "%.3f / SF%u / %.0fk", freq_mhz, sf, bw_khz); + } + else + { + std::snprintf(out, out_len, "Radio %.3f MHz / SF%u / %.0fk", freq_mhz, sf, bw_khz); + } + return true; + } + + if (node.channel != 0xFF) + { + if (compact) + { + std::snprintf(out, out_len, "CH %u", static_cast(node.channel)); + } + else + { + std::snprintf(out, out_len, "Channel %u", static_cast(node.channel)); + } + return true; + } + + return false; +} + +bool build_route_line(const chat::contacts::NodeInfo& node, bool compact, char* out, size_t out_len) +{ + if (node.hops_away == 0xFF && node.next_hop == 0 && !node.via_mqtt) + { + return false; + } + + if (node.via_mqtt && node.hops_away == 0xFF && node.next_hop == 0) + { + if (compact) + { + std::snprintf(out, out_len, "MQTT path"); + } + else + { + std::snprintf(out, out_len, "Route MQTT path"); + } + return true; + } + + if (compact) + { + if (node.via_mqtt && node.hops_away != 0xFF) + { + std::snprintf(out, + out_len, + "MQTT / %u hops", + static_cast(node.hops_away)); + return true; + } + if (node.hops_away != 0xFF && node.next_hop != 0) + { + std::snprintf(out, + out_len, + "%u hops / NH %02X", + static_cast(node.hops_away), + static_cast(node.next_hop)); + return true; + } + if (node.hops_away != 0xFF) + { + std::snprintf(out, out_len, "%u hops", static_cast(node.hops_away)); + return true; + } + if (node.via_mqtt) + { + std::snprintf(out, out_len, "MQTT / NH %02X", static_cast(node.next_hop)); + return true; + } + std::snprintf(out, out_len, "NH %02X", static_cast(node.next_hop)); + return true; + } + + if (node.via_mqtt && node.hops_away != 0xFF) + { + std::snprintf(out, + out_len, + "Route MQTT / %u hops", + static_cast(node.hops_away)); + return true; + } + if (node.hops_away != 0xFF && node.next_hop != 0) + { + std::snprintf(out, + out_len, + "Route %u hops / NH %02X", + static_cast(node.hops_away), + static_cast(node.next_hop)); + return true; + } + if (node.hops_away != 0xFF) + { + std::snprintf(out, out_len, "Route %u hops", static_cast(node.hops_away)); + return true; + } + + if (node.via_mqtt) + { + std::snprintf(out, out_len, "Route MQTT / NH %02X", static_cast(node.next_hop)); + return true; + } + + std::snprintf(out, out_len, "Route NH %02X", static_cast(node.next_hop)); + return true; +} + +void build_status_line(const chat::contacts::NodeInfo& node, bool compact, char* out, size_t out_len) +{ + const char* state = node.is_ignored ? "Ignored" : "Active"; + const char* pki = node.key_manually_verified ? "Verified" + : (node.has_public_key ? "Known" : ""); + + if (compact) + { + if (pki[0] != '\0') + { + std::snprintf(out, out_len, "%s / %s", state, pki); + } + else + { + std::snprintf(out, out_len, "%s", state); + } + return; + } + + if (pki[0] != '\0') + { + std::snprintf(out, out_len, "State %s / %s", state, pki); + } + else + { + std::snprintf(out, out_len, "State %s", state); + } +} + +bool build_range_line(const chat::contacts::NodeInfo& node, + const GeoPoint& self_point, + bool compact, + char* out, + size_t out_len) +{ + const GeoPoint node_point = node_point_from_info(node); + if (!node_point.valid || !self_point.valid) + { + return false; + } + + const double meters = dashboard::haversine_m( + node_point.lat, node_point.lon, self_point.lat, self_point.lon); + char distance_buf[24]; + dashboard::format_distance(meters, distance_buf, sizeof(distance_buf)); + const float bearing = dashboard::bearing_between( + self_point.lat, self_point.lon, node_point.lat, node_point.lon); + + if (compact) + { + std::snprintf(out, out_len, "%s / %s", distance_buf, dashboard::compass_rose(bearing)); + } + else + { + std::snprintf(out, out_len, "Range %s / %s", distance_buf, dashboard::compass_rose(bearing)); + } + return true; +} + +bool build_altitude_line(const chat::contacts::NodeInfo& node, bool compact, char* out, size_t out_len) +{ if (!node.position.valid) { - lv_obj_add_flag(s_widgets.map_image, LV_OBJ_FLAG_HIDDEN); - set_label_text(s_widgets.map_label, "No map"); - return; + return false; } - double lat = static_cast(node.position.latitude_i) * 1e-7; - double lon = static_cast(node.position.longitude_i) * 1e-7; - - int tile_x = 0; - int tile_y = 0; - int offset_x = 0; - int offset_y = 0; - if (!latlng_to_tile(lat, lon, kNodeInfoMapZoom, tile_x, tile_y, offset_x, offset_y)) + const double accuracy_m = compute_accuracy_m(node.position); + if (!node.position.has_altitude && accuracy_m < 0.0) { - lv_obj_add_flag(s_widgets.map_image, LV_OBJ_FLAG_HIDDEN); - set_label_text(s_widgets.map_label, "No map"); - return; + return false; } - char path[96]; - uint8_t map_source = sanitize_map_source(app::configFacade().getConfig().map_source); - if (!build_base_tile_path(kNodeInfoMapZoom, tile_x, tile_y, map_source, path, sizeof(path))) + if (node.position.has_altitude && accuracy_m >= 0.0) { - lv_obj_add_flag(s_widgets.map_image, LV_OBJ_FLAG_HIDDEN); - set_label_text(s_widgets.map_label, "No map"); - return; + if (compact) + { + std::snprintf(out, + out_len, + "Alt %ldm / %.0fm", + static_cast(node.position.altitude), + accuracy_m); + } + else + { + std::snprintf(out, + out_len, + "Altitude %ld m / +/- %.0f m", + static_cast(node.position.altitude), + accuracy_m); + } + return true; } - lv_fs_file_t f; - lv_fs_res_t res = lv_fs_open(&f, path, LV_FS_MODE_RD); - if (res != LV_FS_RES_OK) + if (node.position.has_altitude) { - lv_obj_add_flag(s_widgets.map_image, LV_OBJ_FLAG_HIDDEN); - set_label_text(s_widgets.map_label, "No map"); - return; + if (compact) + { + std::snprintf(out, out_len, "Alt %ldm", static_cast(node.position.altitude)); + } + else + { + std::snprintf(out, out_len, "Altitude %ld m", static_cast(node.position.altitude)); + } + return true; } - lv_fs_close(&f); - lv_image_set_src(s_widgets.map_image, path); - lv_obj_set_size(s_widgets.map_image, kTileSize, kTileSize); - - int map_w = lv_obj_get_width(s_widgets.location_map); - int map_h = lv_obj_get_height(s_widgets.location_map); - int center_x = map_w / 2; - int center_y = map_h / 2; - int img_x = center_x - offset_x; - int img_y = center_y - offset_y; - lv_obj_set_pos(s_widgets.map_image, img_x, img_y); - lv_obj_clear_flag(s_widgets.map_image, LV_OBJ_FLAG_HIDDEN); - lv_obj_move_background(s_widgets.map_image); - set_label_text(s_widgets.map_label, ""); + if (compact) + { + std::snprintf(out, out_len, "Acc %.0fm", accuracy_m); + } + else + { + std::snprintf(out, out_len, "Accuracy +/- %.0f m", accuracy_m); + } + return true; } + +void update_overlay_text() +{ + if (!s_state.has_node) + { + return; + } + + const auto& node = s_state.node; + const GeoPoint node_point = node_point_from_info(node); + const ViewMetrics metrics = view_metrics(); + + char id_buf[24]; + format_node_id(node.node_id, id_buf, sizeof(id_buf)); + set_label_text(s_widgets.id_label, id_buf); + + char coord_buf[40]; + if (node_point.valid) + { + format_coord_label("LON", node_point.lon, coord_buf, sizeof(coord_buf)); + set_label_text(s_widgets.lon_label, coord_buf); + format_coord_label("LAT", node_point.lat, coord_buf, sizeof(coord_buf)); + set_label_text(s_widgets.lat_label, coord_buf); + } + else + { + set_label_text(s_widgets.lon_label, "LON --"); + set_label_text(s_widgets.lat_label, "LAT --"); + } + + set_top_bar_title(node); + + std::size_t line_count = 0; + char line[128]; + + build_protocol_line(node, metrics.compact, line, sizeof(line)); + append_info_line(line_count, line); + + build_signal_line(node, metrics.compact, line, sizeof(line)); + append_info_line(line_count, line); + + if (build_radio_line(node, metrics.compact, line, sizeof(line))) + { + append_info_line(line_count, line); + } + + if (build_route_line(node, metrics.compact, line, sizeof(line))) + { + append_info_line(line_count, line); + } + + build_status_line(node, metrics.compact, line, sizeof(line)); + append_info_line(line_count, line); + + if (build_range_line(node, s_state.self_point, metrics.compact, line, sizeof(line))) + { + append_info_line(line_count, line); + } + + if (build_altitude_line(node, metrics.compact, line, sizeof(line))) + { + append_info_line(line_count, line); + } + + format_age_short(node.last_seen, line, sizeof(line)); + append_info_line(line_count, line); + + hide_unused_info_lines(line_count); +} + +void position_overlay_widgets() +{ + const ViewMetrics metrics = view_metrics(); + if (!s_widgets.map_stage) + { + return; + } + + lv_obj_set_size(s_widgets.map_stage, metrics.width, metrics.height); + if (s_widgets.tile_layer) + { + lv_obj_set_size(s_widgets.tile_layer, metrics.width, metrics.height); + } + + const lv_coord_t left_scrim_w = clamp_coord(metrics.left_w + metrics.pad, 120, metrics.width); + lv_obj_set_pos(s_widgets.left_scrim, 0, 0); + lv_obj_set_size(s_widgets.left_scrim, left_scrim_w, metrics.height); + + const lv_coord_t right_scrim_x = clamp_coord(metrics.right_x - 18, 0, metrics.width); + lv_obj_set_pos(s_widgets.right_scrim, right_scrim_x, 0); + lv_obj_set_size(s_widgets.right_scrim, metrics.width - right_scrim_x, metrics.height); + + lv_obj_set_pos(s_widgets.id_label, metrics.pad, metrics.pad); + lv_obj_set_size(s_widgets.id_label, metrics.left_w, LV_SIZE_CONTENT); + + lv_obj_set_pos(s_widgets.lon_label, metrics.pad, metrics.height - metrics.pad - 34); + lv_obj_set_size(s_widgets.lon_label, metrics.left_w, LV_SIZE_CONTENT); + + lv_obj_set_pos(s_widgets.lat_label, metrics.pad, metrics.height - metrics.pad - 18); + lv_obj_set_size(s_widgets.lat_label, metrics.left_w, LV_SIZE_CONTENT); + + for (std::size_t index = 0; index < kNodeInfoInfoLineCount; ++index) + { + lv_obj_set_pos(s_widgets.info_labels[index], + metrics.right_x, + metrics.info_top + static_cast(index) * + (metrics.info_line_h + metrics.info_gap)); + lv_obj_set_size(s_widgets.info_labels[index], metrics.right_col_w, metrics.info_line_h); + } + + lv_obj_update_layout(s_widgets.no_position_label); + const lv_coord_t no_pos_w = lv_obj_get_width(s_widgets.no_position_label); + const lv_coord_t no_pos_h = lv_obj_get_height(s_widgets.no_position_label); + lv_obj_set_pos(s_widgets.no_position_label, + (metrics.width - no_pos_w) / 2, + (metrics.height - no_pos_h) / 2); + + const lv_coord_t zoom_x = metrics.width - metrics.pad - metrics.zoom_size; + const lv_coord_t zoom_out_y = metrics.height - metrics.pad - metrics.zoom_size; + const lv_coord_t zoom_in_y = zoom_out_y - metrics.zoom_size - metrics.zoom_gap; + lv_obj_set_size(s_widgets.zoom_in_btn, metrics.zoom_size, metrics.zoom_size); + lv_obj_set_size(s_widgets.zoom_out_btn, metrics.zoom_size, metrics.zoom_size); + lv_obj_set_pos(s_widgets.zoom_in_btn, zoom_x, zoom_in_y); + lv_obj_set_pos(s_widgets.zoom_out_btn, zoom_x, zoom_out_y); + lv_obj_center(s_widgets.zoom_in_label); + lv_obj_center(s_widgets.zoom_out_label); +} + +void position_circle_center(lv_obj_t* obj, lv_coord_t center_x, lv_coord_t center_y) +{ + if (!obj) + { + return; + } + lv_obj_set_pos(obj, + center_x - (lv_obj_get_width(obj) / 2), + center_y - (lv_obj_get_height(obj) / 2)); +} + +void render_map_tiles(const GeoPoint& node_point, const ViewMetrics& metrics) +{ + for (std::size_t index = 0; index < kNodeInfoTileCount; ++index) + { + set_hidden(s_widgets.tile_images[index], true); + } + + if (!node_point.valid) + { + return; + } + + double node_world_x = 0.0; + double node_world_y = 0.0; + latlng_to_world_pixels(node_point.lat, node_point.lon, s_state.zoom, node_world_x, node_world_y); + + const int center_tile_x = static_cast(std::floor(node_world_x / kTileSize)); + const int center_tile_y = static_cast(std::floor(node_world_y / kTileSize)); + const double base_x = static_cast(metrics.focus_x) - node_world_x; + const double base_y = static_cast(metrics.focus_y) - node_world_y; + const int max_tile_y = (1 << s_state.zoom) - 1; + const uint8_t map_source = sanitize_map_source(app::configFacade().getConfig().map_source); + + std::size_t tile_index = 0; + for (int dy = -1; dy <= 1; ++dy) + { + for (int dx = -1; dx <= 1; ++dx) + { + if (tile_index >= kNodeInfoTileCount) + { + return; + } + + const int tile_y = center_tile_y + dy; + if (tile_y < 0 || tile_y > max_tile_y) + { + ++tile_index; + continue; + } + + const int draw_tile_x = center_tile_x + dx; + const int tile_x = normalize_tile_x(draw_tile_x, s_state.zoom); + const lv_coord_t draw_x = + static_cast(std::lround(base_x + static_cast(draw_tile_x * kTileSize))); + const lv_coord_t draw_y = + static_cast(std::lround(base_y + static_cast(tile_y * kTileSize))); + + char path[96]; + if (!build_base_tile_path(s_state.zoom, tile_x, tile_y, map_source, path, sizeof(path)) || + !tile_exists(path)) + { + ++tile_index; + continue; + } + + std::strncpy(s_state.tile_paths[tile_index], path, sizeof(s_state.tile_paths[tile_index]) - 1); + s_state.tile_paths[tile_index][sizeof(s_state.tile_paths[tile_index]) - 1] = '\0'; + + lv_obj_t* image = s_widgets.tile_images[tile_index]; + lv_image_set_src(image, s_state.tile_paths[tile_index]); + lv_obj_set_size(image, kTileSize, kTileSize); + lv_obj_set_pos(image, draw_x, draw_y); + set_hidden(image, false); + ++tile_index; + } + } +} + +void clear_map_tiles() +{ + for (std::size_t index = 0; index < kNodeInfoTileCount; ++index) + { + if (s_widgets.tile_images[index]) + { + set_hidden(s_widgets.tile_images[index], true); + } + s_state.tile_paths[index][0] = '\0'; + } +} + +void render_connection_and_markers(const GeoPoint& node_point, const ViewMetrics& metrics) +{ + if (!node_point.valid) + { + set_hidden(s_widgets.connection_line, true); + set_hidden(s_widgets.marker_node_ring, true); + set_hidden(s_widgets.marker_node_dot, true); + set_hidden(s_widgets.marker_self_ring, true); + set_hidden(s_widgets.marker_self_dot, true); + set_hidden(s_widgets.distance_label, true); + return; + } + + position_circle_center(s_widgets.marker_node_ring, metrics.focus_x, metrics.focus_y); + position_circle_center(s_widgets.marker_node_dot, metrics.focus_x, metrics.focus_y); + set_hidden(s_widgets.marker_node_ring, false); + set_hidden(s_widgets.marker_node_dot, false); + + if (!s_state.self_point.valid) + { + set_hidden(s_widgets.connection_line, true); + set_hidden(s_widgets.marker_self_ring, true); + set_hidden(s_widgets.marker_self_dot, true); + set_hidden(s_widgets.distance_label, true); + return; + } + + double node_world_x = 0.0; + double node_world_y = 0.0; + double self_world_x = 0.0; + double self_world_y = 0.0; + latlng_to_world_pixels(node_point.lat, node_point.lon, s_state.zoom, node_world_x, node_world_y); + latlng_to_world_pixels(s_state.self_point.lat, s_state.self_point.lon, s_state.zoom, self_world_x, self_world_y); + + const double raw_x = static_cast(metrics.focus_x) + (self_world_x - node_world_x); + const double raw_y = static_cast(metrics.focus_y) + (self_world_y - node_world_y); + const lv_coord_t draw_x = static_cast(std::lround( + clamp_double(raw_x, static_cast(metrics.pad + 6), static_cast(metrics.right_x - 16)))); + const lv_coord_t draw_y = static_cast(std::lround( + clamp_double(raw_y, static_cast(metrics.pad + 6), static_cast(metrics.height - metrics.pad - 6)))); + + s_state.link_points[0].x = static_cast(metrics.focus_x); + s_state.link_points[0].y = static_cast(metrics.focus_y); + s_state.link_points[1].x = static_cast(draw_x); + s_state.link_points[1].y = static_cast(draw_y); + lv_line_set_points(s_widgets.connection_line, s_state.link_points, 2); + lv_obj_set_pos(s_widgets.connection_line, 0, 0); + set_hidden(s_widgets.connection_line, false); + + position_circle_center(s_widgets.marker_self_ring, draw_x, draw_y); + position_circle_center(s_widgets.marker_self_dot, draw_x, draw_y); + set_hidden(s_widgets.marker_self_ring, false); + set_hidden(s_widgets.marker_self_dot, false); + + char distance_buf[24]; + dashboard::format_distance( + dashboard::haversine_m(node_point.lat, node_point.lon, s_state.self_point.lat, s_state.self_point.lon), + distance_buf, + sizeof(distance_buf)); + const float bearing = dashboard::bearing_between( + s_state.self_point.lat, s_state.self_point.lon, node_point.lat, node_point.lon); + char distance_text[40]; + std::snprintf(distance_text, + sizeof(distance_text), + "%s / %s", + distance_buf, + dashboard::compass_rose(bearing)); + set_label_text(s_widgets.distance_label, distance_text); + lv_obj_update_layout(s_widgets.distance_label); + + lv_coord_t label_x = static_cast((metrics.focus_x + draw_x) / 2); + lv_coord_t label_y = static_cast((metrics.focus_y + draw_y) / 2) - 14; + if (std::abs(draw_x - metrics.focus_x) < 22 && std::abs(draw_y - metrics.focus_y) < 22) + { + label_y -= 18; + } + + const lv_coord_t label_w = lv_obj_get_width(s_widgets.distance_label); + const lv_coord_t label_h = lv_obj_get_height(s_widgets.distance_label); + label_x = clamp_coord(label_x - (label_w / 2), metrics.pad + 2, metrics.right_x - label_w - 10); + label_y = clamp_coord(label_y - (label_h / 2), metrics.pad + 2, metrics.height - label_h - metrics.pad); + lv_obj_set_pos(s_widgets.distance_label, label_x, label_y); + set_hidden(s_widgets.distance_label, false); +} + +void update_zoom_button_state(bool enabled) +{ + if (!s_widgets.zoom_in_btn || !s_widgets.zoom_out_btn) + { + return; + } + + if (enabled) + { + lv_obj_clear_state(s_widgets.zoom_in_btn, LV_STATE_DISABLED); + lv_obj_clear_state(s_widgets.zoom_out_btn, LV_STATE_DISABLED); + } + else + { + lv_obj_add_state(s_widgets.zoom_in_btn, LV_STATE_DISABLED); + lv_obj_add_state(s_widgets.zoom_out_btn, LV_STATE_DISABLED); + } +} + +void render_scene() +{ + position_overlay_widgets(); + update_overlay_text(); + + if (!s_state.has_node) + { + set_hidden(s_widgets.no_position_label, false); + update_zoom_button_state(false); + return; + } + + const GeoPoint node_point = node_point_from_info(s_state.node); + const ViewMetrics metrics = view_metrics(); + if (s_state.map_ready) + { + render_map_tiles(node_point, metrics); + } + else + { + clear_map_tiles(); + } + render_connection_and_markers(node_point, metrics); + + const bool has_position = node_point.valid; + set_hidden(s_widgets.no_position_label, has_position); + if (!has_position) + { + set_label_text(s_widgets.no_position_label, ::ui::i18n::tr("No position available")); + set_hidden(s_widgets.connection_line, true); + set_hidden(s_widgets.marker_node_ring, true); + set_hidden(s_widgets.marker_node_dot, true); + set_hidden(s_widgets.marker_self_ring, true); + set_hidden(s_widgets.marker_self_dot, true); + set_hidden(s_widgets.distance_label, true); + } + + update_zoom_button_state(has_position); +} + +void render_map_async(void* /*user_data*/) +{ + if (!s_widgets.root || !lv_obj_is_valid(s_widgets.root) || !s_state.has_node) + { + return; + } + + lv_obj_update_layout(s_widgets.root); + s_state.zoom = compute_initial_zoom(node_point_from_info(s_state.node), s_state.self_point, view_metrics()); + s_state.map_ready = true; + render_scene(); +} + +void on_zoom_button_clicked(lv_event_t* e) +{ + if (!s_state.has_node) + { + return; + } + + const intptr_t delta = reinterpret_cast(lv_event_get_user_data(e)); + const GeoPoint node_point = node_point_from_info(s_state.node); + if (!node_point.valid) + { + return; + } + + const int next_zoom = select_zoom_after_delta(node_point, s_state.zoom, static_cast(delta)); + if (next_zoom == s_state.zoom) + { + return; + } + + s_state.zoom = next_zoom; + render_scene(); +} + } // namespace NodeInfoWidgets create(lv_obj_t* parent) { - static const lv_point_precise_t kArrowPoints[] = { - {10, 0}, - {20, 20}, - {0, 20}, - {10, 0}, - }; - s_widgets = NodeInfoWidgets{}; + s_state = NodeInfoRuntimeState{}; + s_state.zoom = kDefaultZoom; + s_widgets.root = layout::create_root(parent); s_widgets.header = layout::create_header(s_widgets.root); s_widgets.content = layout::create_content(s_widgets.root); - s_widgets.top_row = layout::create_top_row(s_widgets.content); - s_widgets.info_card = layout::create_info_card(s_widgets.top_row); - s_widgets.info_header = layout::create_info_header(s_widgets.info_card); - s_widgets.info_footer = layout::create_info_footer(s_widgets.info_card); - s_widgets.location_card = layout::create_location_card(s_widgets.top_row); - s_widgets.location_header = layout::create_location_header(s_widgets.location_card); - s_widgets.location_map = layout::create_location_map(s_widgets.location_card); - s_widgets.location_coords = layout::create_location_coords(s_widgets.location_card); - s_widgets.location_updated = layout::create_location_updated(s_widgets.location_card); - s_widgets.link_panel = layout::create_link_panel(s_widgets.content); - s_widgets.link_header = layout::create_link_header(s_widgets.link_panel); - s_widgets.link_row_1 = layout::create_link_row(s_widgets.link_panel); - s_widgets.link_row_2 = layout::create_link_row(s_widgets.link_panel); - apply_root_style(s_widgets.root); - apply_row_style(s_widgets.content); - apply_row_style(s_widgets.top_row); - - // Header (TopBar) - align with shared widget - lv_obj_set_style_bg_opa(s_widgets.header, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(s_widgets.header, 0, 0); - lv_obj_set_style_pad_all(s_widgets.header, 0, 0); + lv_obj_set_style_bg_color(s_widgets.root, kColorBackdrop, 0); + lv_obj_set_style_bg_opa(s_widgets.root, LV_OPA_COVER, 0); + lv_obj_set_style_bg_color(s_widgets.content, kColorBackdropAlt, 0); + lv_obj_set_style_bg_opa(s_widgets.content, LV_OPA_COVER, 0); + make_plain(s_widgets.root); + make_plain(s_widgets.content); ::ui::widgets::TopBarConfig cfg; ::ui::widgets::top_bar_init(s_top_bar, s_widgets.header, cfg); - ::ui::widgets::top_bar_set_title(s_top_bar, ::ui::i18n::tr("NODE INFO")); - ui_update_top_bar_battery(s_top_bar); - s_widgets.back_btn = s_top_bar.back_btn; s_widgets.title_label = s_top_bar.title_label; s_widgets.battery_label = s_top_bar.right_label; - - if (s_top_bar.container) - { - lv_obj_set_style_border_width(s_top_bar.container, 1, 0); - lv_obj_set_style_border_color(s_top_bar.container, kColorCardBorder, 0); - lv_obj_set_style_border_side(s_top_bar.container, LV_BORDER_SIDE_BOTTOM, 0); - } - if (s_widgets.title_label) - { - lv_obj_set_style_text_color(s_widgets.title_label, kColorText, 0); - } - if (s_widgets.battery_label) - { - lv_obj_set_style_text_color(s_widgets.battery_label, kColorText, 0); - } if (s_widgets.back_btn) { - lv_obj_t* back_label = lv_obj_get_child(s_widgets.back_btn, 0); - if (back_label) - { - lv_obj_set_style_text_color(back_label, kColorText, 0); - s_widgets.back_label = back_label; - } + s_widgets.back_label = lv_obj_get_child(s_widgets.back_btn, 0); } + ::ui::widgets::top_bar_set_title(s_top_bar, ::ui::i18n::tr("NODE INFO")); + ui_update_top_bar_battery(s_top_bar); + apply_top_bar_style(); - // Info card - apply_card_style(s_widgets.info_card); - apply_row_style(s_widgets.info_header); - apply_info_bar_style(s_widgets.info_footer, 8); - - s_widgets.avatar_bg = lv_obj_create(s_widgets.info_header); - lv_obj_set_pos(s_widgets.avatar_bg, 10, 10); - lv_obj_set_size(s_widgets.avatar_bg, 46, 46); - lv_obj_set_style_radius(s_widgets.avatar_bg, LV_RADIUS_CIRCLE, 0); - lv_obj_set_style_bg_color(s_widgets.avatar_bg, kColorAvatarBg, 0); - lv_obj_set_style_bg_opa(s_widgets.avatar_bg, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(s_widgets.avatar_bg, 2, 0); - lv_obj_set_style_border_color(s_widgets.avatar_bg, kColorCardBorder, 0); - s_widgets.avatar_label = create_label(s_widgets.avatar_bg, "S", font_montserrat_24_safe(), lv_color_hex(0xFFFFFF)); - lv_obj_center(s_widgets.avatar_label); - - s_widgets.name_label = create_label(s_widgets.info_header, "ALFA-3", font_montserrat_22_safe(), kColorText); - lv_obj_set_pos(s_widgets.name_label, 62, 12); - lv_obj_set_size(s_widgets.name_label, 118, 24); - lv_label_set_long_mode(s_widgets.name_label, LV_LABEL_LONG_DOT); - - s_widgets.desc_label = - create_label(s_widgets.info_header, "Alpha Team Relay Node", &lv_font_montserrat_14, kColorTextMuted); - lv_obj_set_pos(s_widgets.desc_label, 62, 40); - lv_obj_set_size(s_widgets.desc_label, 118, 18); - lv_label_set_long_mode(s_widgets.desc_label, LV_LABEL_LONG_DOT); - - s_widgets.id_label = create_label(s_widgets.info_footer, "ID: !a1b2c3", font_montserrat_12_safe(), kColorText); - lv_obj_set_pos(s_widgets.id_label, 6, 4); - s_widgets.role_label = create_label(s_widgets.info_footer, "Role: -", font_montserrat_12_safe(), kColorText); - lv_obj_set_pos(s_widgets.role_label, 6, 18); - - // Location card - apply_card_style(s_widgets.location_card); - apply_title_bar_style(s_widgets.location_header); - s_widgets.location_title_label = - create_label(s_widgets.location_header, "Location", font_montserrat_16_safe(), kColorText); - lv_obj_set_pos(s_widgets.location_title_label, 10, 1); - - apply_map_style(s_widgets.location_map); + s_widgets.map_stage = lv_obj_create(s_widgets.content); + lv_obj_set_size(s_widgets.map_stage, LV_PCT(100), LV_PCT(100)); + lv_obj_set_pos(s_widgets.map_stage, 0, 0); + lv_obj_set_style_bg_color(s_widgets.map_stage, kColorBackdropAlt, 0); + lv_obj_set_style_bg_opa(s_widgets.map_stage, LV_OPA_COVER, 0); + make_plain(s_widgets.map_stage); #ifdef LV_OBJ_FLAG_CLIP_CHILDREN - lv_obj_add_flag(s_widgets.location_map, LV_OBJ_FLAG_CLIP_CHILDREN); + lv_obj_add_flag(s_widgets.map_stage, LV_OBJ_FLAG_CLIP_CHILDREN); #endif - s_widgets.map_image = lv_image_create(s_widgets.location_map); - lv_obj_set_size(s_widgets.map_image, kTileSize, kTileSize); - lv_obj_set_style_border_width(s_widgets.map_image, 0, 0); - lv_obj_set_style_pad_all(s_widgets.map_image, 0, 0); - lv_obj_add_flag(s_widgets.map_image, LV_OBJ_FLAG_HIDDEN); - lv_obj_move_background(s_widgets.map_image); + s_widgets.tile_layer = lv_obj_create(s_widgets.map_stage); + lv_obj_set_size(s_widgets.tile_layer, LV_PCT(100), LV_PCT(100)); + lv_obj_set_pos(s_widgets.tile_layer, 0, 0); + lv_obj_set_style_bg_opa(s_widgets.tile_layer, LV_OPA_TRANSP, 0); + make_plain(s_widgets.tile_layer); - s_widgets.map_label = - create_label(s_widgets.location_map, "", font_montserrat_12_safe(), kColorTextMuted); - lv_obj_center(s_widgets.map_label); + for (std::size_t index = 0; index < kNodeInfoTileCount; ++index) + { + s_widgets.tile_images[index] = lv_image_create(s_widgets.tile_layer); + lv_obj_set_size(s_widgets.tile_images[index], kTileSize, kTileSize); + lv_obj_set_style_border_width(s_widgets.tile_images[index], 0, 0); + lv_obj_set_style_pad_all(s_widgets.tile_images[index], 0, 0); + set_hidden(s_widgets.tile_images[index], true); + } - lv_obj_t* cross_h = lv_obj_create(s_widgets.location_map); - lv_obj_set_pos(cross_h, 103, 25); - lv_obj_set_size(cross_h, 40, 2); - lv_obj_set_style_bg_color(cross_h, kColorTextMuted, 0); - lv_obj_set_style_bg_opa(cross_h, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(cross_h, 0, 0); + s_widgets.left_scrim = lv_obj_create(s_widgets.map_stage); + s_widgets.right_scrim = lv_obj_create(s_widgets.map_stage); + apply_scrim_style(s_widgets.left_scrim, lv_color_hex(0x071A27), 132); + apply_scrim_style(s_widgets.right_scrim, lv_color_hex(0x25150D), 150); - lv_obj_t* cross_v = lv_obj_create(s_widgets.location_map); - lv_obj_set_pos(cross_v, 122, 6); - lv_obj_set_size(cross_v, 2, 40); - lv_obj_set_style_bg_color(cross_v, kColorTextMuted, 0); - lv_obj_set_style_bg_opa(cross_v, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(cross_v, 0, 0); + s_widgets.connection_line = lv_line_create(s_widgets.map_stage); + lv_obj_set_style_line_color(s_widgets.connection_line, kColorLink, 0); + lv_obj_set_style_line_width(s_widgets.connection_line, 2, 0); + lv_obj_set_style_line_rounded(s_widgets.connection_line, true, 0); + set_hidden(s_widgets.connection_line, true); - lv_obj_t* acc_circle = lv_obj_create(s_widgets.location_map); - lv_obj_set_pos(acc_circle, 103, 6); - lv_obj_set_size(acc_circle, 40, 40); - lv_obj_set_style_bg_opa(acc_circle, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(acc_circle, 1, 0); - lv_obj_set_style_border_color(acc_circle, kColorMapAccent, 0); - lv_obj_set_style_radius(acc_circle, LV_RADIUS_CIRCLE, 0); + s_widgets.marker_node_ring = lv_obj_create(s_widgets.map_stage); + s_widgets.marker_node_dot = lv_obj_create(s_widgets.map_stage); + s_widgets.marker_self_ring = lv_obj_create(s_widgets.map_stage); + s_widgets.marker_self_dot = lv_obj_create(s_widgets.map_stage); + apply_marker_style(s_widgets.marker_node_ring, 22, kColorNodeMarker, false); + apply_marker_style(s_widgets.marker_node_dot, 10, kColorNodeMarker, true); + apply_marker_style(s_widgets.marker_self_ring, 18, kColorSelfMarker, false); + apply_marker_style(s_widgets.marker_self_dot, 8, kColorSelfMarker, true); + set_hidden(s_widgets.marker_node_ring, true); + set_hidden(s_widgets.marker_node_dot, true); + set_hidden(s_widgets.marker_self_ring, true); + set_hidden(s_widgets.marker_self_dot, true); - lv_obj_t* arrow = lv_line_create(s_widgets.location_map); - lv_line_set_points(arrow, kArrowPoints, 4); - lv_obj_set_pos(arrow, 113, 16); - lv_obj_set_style_line_width(arrow, 2, 0); - lv_obj_set_style_line_color(arrow, kColorMapAccent, 0); - lv_obj_set_style_line_rounded(arrow, true, 0); + s_widgets.id_label = create_label(s_widgets.map_stage, "ID !000000", font_montserrat_18_safe(), kColorId); + lv_label_set_long_mode(s_widgets.id_label, LV_LABEL_LONG_DOT); - apply_info_bar_style(s_widgets.location_coords, 6); - s_widgets.coords_latlon_label = - create_label(s_widgets.location_coords, "35.65858, 139.74543", &lv_font_montserrat_14, kColorText); - lv_obj_set_pos(s_widgets.coords_latlon_label, 6, -1); - lv_obj_set_size(s_widgets.coords_latlon_label, 140, 14); - lv_label_set_long_mode(s_widgets.coords_latlon_label, LV_LABEL_LONG_DOT); + s_widgets.lon_label = create_label(s_widgets.map_stage, "LON --", font_montserrat_14_safe(), kColorLon); + s_widgets.lat_label = create_label(s_widgets.map_stage, "LAT --", font_montserrat_14_safe(), kColorLat); - s_widgets.coords_acc_label = - create_label(s_widgets.location_coords, "+/- 12 m", &lv_font_montserrat_14, kColorText); - lv_obj_set_pos(s_widgets.coords_acc_label, 150, -1); - lv_obj_set_size(s_widgets.coords_acc_label, 45, 14); - lv_obj_set_style_text_align(s_widgets.coords_acc_label, LV_TEXT_ALIGN_RIGHT, 0); + s_widgets.no_position_label = + create_label(s_widgets.map_stage, "No position available", font_montserrat_16_safe(), kColorMuted); + set_hidden(s_widgets.no_position_label, true); - s_widgets.coords_alt_label = - create_label(s_widgets.location_coords, "Alt: 43 m", &lv_font_montserrat_14, kColorText); - lv_obj_set_pos(s_widgets.coords_alt_label, 195, -1); - lv_obj_set_size(s_widgets.coords_alt_label, 46, 14); - lv_obj_set_style_text_align(s_widgets.coords_alt_label, LV_TEXT_ALIGN_RIGHT, 0); + s_widgets.distance_label = + create_label(s_widgets.map_stage, "", font_montserrat_12_safe(), kColorDistance); + set_hidden(s_widgets.distance_label, true); - apply_row_style(s_widgets.location_updated); - s_widgets.updated_label = - create_label(s_widgets.location_updated, "Updated: 2m ago", &lv_font_montserrat_14, kColorTextMuted); - lv_obj_set_size(s_widgets.updated_label, 246, 18); - lv_obj_set_style_text_align(s_widgets.updated_label, LV_TEXT_ALIGN_CENTER, 0); - lv_obj_align(s_widgets.updated_label, LV_ALIGN_CENTER, 0, -3); - lv_obj_add_flag(s_widgets.location_updated, LV_OBJ_FLAG_HIDDEN); + for (std::size_t index = 0; index < kNodeInfoInfoLineCount; ++index) + { + s_widgets.info_labels[index] = create_label( + s_widgets.map_stage, + "", + font_montserrat_12_safe(), + kInfoLineColors[index]); + lv_label_set_long_mode(s_widgets.info_labels[index], LV_LABEL_LONG_DOT); + set_hidden(s_widgets.info_labels[index], true); + } - // Link panel - apply_card_style(s_widgets.link_panel); - apply_title_bar_style(s_widgets.link_header); - s_widgets.link_title_label = create_label(s_widgets.link_header, "Link", font_montserrat_16_safe(), kColorText); - lv_obj_set_pos(s_widgets.link_title_label, 10, 1); - lv_obj_set_size(s_widgets.link_title_label, 440, 18); - lv_label_set_long_mode(s_widgets.link_title_label, LV_LABEL_LONG_DOT); + s_widgets.zoom_in_btn = lv_btn_create(s_widgets.map_stage); + s_widgets.zoom_in_label = lv_label_create(s_widgets.zoom_in_btn); + lv_label_set_text(s_widgets.zoom_in_label, "+"); + s_widgets.zoom_out_btn = lv_btn_create(s_widgets.map_stage); + s_widgets.zoom_out_label = lv_label_create(s_widgets.zoom_out_btn); + lv_label_set_text(s_widgets.zoom_out_label, "-"); + apply_zoom_button_style(s_widgets.zoom_in_btn, s_widgets.zoom_in_label); + apply_zoom_button_style(s_widgets.zoom_out_btn, s_widgets.zoom_out_label); + lv_obj_add_event_cb(s_widgets.zoom_in_btn, + on_zoom_button_clicked, + LV_EVENT_CLICKED, + reinterpret_cast(static_cast(+1))); + lv_obj_add_event_cb(s_widgets.zoom_out_btn, + on_zoom_button_clicked, + LV_EVENT_CLICKED, + reinterpret_cast(static_cast(-1))); + update_zoom_button_state(false); - apply_row_style(s_widgets.link_row_1); - lv_obj_set_pos(s_widgets.link_row_1, 0, 22); - s_widgets.link_rssi_label = - create_label(s_widgets.link_row_1, "RSSI: -112 dBm", font_montserrat_12_safe(), kColorText); - lv_obj_set_pos(s_widgets.link_rssi_label, 10, -3); - s_widgets.link_snr_label = - create_label(s_widgets.link_row_1, "SNR: 7.5 dB", font_montserrat_12_safe(), kColorText); - lv_obj_set_pos(s_widgets.link_snr_label, 140, -3); - s_widgets.link_ch_label = - create_label(s_widgets.link_row_1, "Ch: 478.875", font_montserrat_12_safe(), kColorText); - lv_obj_set_pos(s_widgets.link_ch_label, 250, -3); - s_widgets.link_sf_label = - create_label(s_widgets.link_row_1, "SF: 7", font_montserrat_12_safe(), kColorText); - lv_obj_set_pos(s_widgets.link_sf_label, 330, -3); - s_widgets.link_bw_label = - create_label(s_widgets.link_row_1, "BW: 125k", font_montserrat_12_safe(), kColorText); - lv_obj_set_pos(s_widgets.link_bw_label, 390, -3); - - apply_row_style(s_widgets.link_row_2); - lv_obj_set_pos(s_widgets.link_row_2, 0, 38); - s_widgets.link_hop_label = - create_label(s_widgets.link_row_2, "Hop: 2", font_montserrat_12_safe(), kColorTextMuted); - lv_obj_set_pos(s_widgets.link_hop_label, 10, -3); - s_widgets.link_last_heard_label = - create_label(s_widgets.link_row_2, "Last heard: 18s", font_montserrat_12_safe(), kColorTextMuted); - lv_obj_set_pos(s_widgets.link_last_heard_label, 140, -3); + lv_obj_update_layout(s_widgets.root); + position_overlay_widgets(); return s_widgets; } @@ -733,7 +1704,9 @@ void destroy() { lv_obj_del(s_widgets.root); } + s_widgets = NodeInfoWidgets{}; + s_state = NodeInfoRuntimeState{}; s_top_bar = ::ui::widgets::TopBar{}; } @@ -744,218 +1717,13 @@ const NodeInfoWidgets& widgets() void set_node_info(const chat::contacts::NodeInfo& node) { - std::string name = node.display_name; - if (name.empty()) - { - name = node.short_name; - } - if (name.empty()) - { - name = "Unknown"; - } - - const char* long_name = node.long_name; - if (!long_name || long_name[0] == '\0' || name == long_name) - { - long_name = ""; - } - - char avatar_text[2] = "?"; - if (!name.empty() && std::isalnum(static_cast(name[0]))) - { - avatar_text[0] = static_cast(std::toupper(static_cast(name[0]))); - avatar_text[1] = '\0'; - } - - char id_buf[24]; - format_node_id(node.node_id, id_buf, sizeof(id_buf)); - - char role_buf[32]; - const std::string role_text = ::ui::i18n::format("Role: %s", role_to_text(node.role)); - snprintf(role_buf, sizeof(role_buf), "%s", role_text.c_str()); - - set_label_text(s_widgets.avatar_label, avatar_text); - ::ui::i18n::set_content_label_text_raw(s_widgets.name_label, name.c_str()); - ::ui::i18n::set_content_label_text_raw(s_widgets.desc_label, long_name); - set_label_text(s_widgets.id_label, id_buf); - set_label_text(s_widgets.role_label, role_buf); - - // Position / location - if (node.position.valid) - { - double lat = static_cast(node.position.latitude_i) * 1e-7; - double lon = static_cast(node.position.longitude_i) * 1e-7; - - char latlon_buf[48]; - snprintf(latlon_buf, sizeof(latlon_buf), "%.5f, %.5f", lat, lon); - - double acc_m = compute_accuracy_m(node.position); - char acc_buf[24]; - if (acc_m >= 0.0) - { - snprintf(acc_buf, sizeof(acc_buf), "+/- %.0f m", acc_m); - } - else - { - snprintf(acc_buf, sizeof(acc_buf), "+/- -"); - } - - char alt_buf[24]; - if (node.position.has_altitude) - { - const std::string text = - ::ui::i18n::format("Alt: %ld m", static_cast(node.position.altitude)); - snprintf(alt_buf, sizeof(alt_buf), "%s", text.c_str()); - } - else - { - snprintf(alt_buf, sizeof(alt_buf), "%s", ::ui::i18n::tr("Alt: -")); - } - - set_label_text(s_widgets.coords_latlon_label, latlon_buf); - set_label_text(s_widgets.coords_acc_label, acc_buf); - set_label_text(s_widgets.coords_alt_label, alt_buf); - set_label_text(s_widgets.map_label, ""); - } - else - { - set_label_text(s_widgets.coords_latlon_label, "No position"); - set_label_text(s_widgets.coords_acc_label, "+/- -"); - set_label_text(s_widgets.coords_alt_label, ::ui::i18n::tr("Alt: -")); - set_label_text(s_widgets.map_label, "No map"); - } - - update_location_map(node); - - char link_title[48]; - if (node.protocol == chat::contacts::NodeProtocolType::Meshtastic) - { - snprintf(link_title, sizeof(link_title), "%s", ::ui::i18n::tr("Link / Meshtastic")); - } - else if (node.protocol == chat::contacts::NodeProtocolType::MeshCore) - { - snprintf(link_title, sizeof(link_title), "%s", ::ui::i18n::tr("Link / MeshCore")); - } - else - { - snprintf(link_title, sizeof(link_title), "%s", ::ui::i18n::tr("Link")); - } - set_label_text(s_widgets.link_title_label, link_title); - - // Link info (best-effort) - char rssi_buf[24]; - if (std::isnan(node.rssi)) - { - snprintf(rssi_buf, sizeof(rssi_buf), "%s", ::ui::i18n::tr("RSSI: -")); - } - else - { - const std::string text = ::ui::i18n::format("RSSI: %.0f dBm", node.rssi); - snprintf(rssi_buf, sizeof(rssi_buf), "%s", text.c_str()); - } - set_label_text(s_widgets.link_rssi_label, rssi_buf); - char snr_buf[24]; - if (std::isnan(node.snr)) - { - snprintf(snr_buf, sizeof(snr_buf), "%s", ::ui::i18n::tr("SNR: -")); - } - else - { - const std::string text = ::ui::i18n::format("SNR: %.1f dB", node.snr); - snprintf(snr_buf, sizeof(snr_buf), "%s", text.c_str()); - } - set_label_text(s_widgets.link_snr_label, snr_buf); - char ch_buf[32]; - char sf_buf[16]; - char bw_buf[16]; - format_radio_params(ch_buf, sizeof(ch_buf), sf_buf, sizeof(sf_buf), bw_buf, sizeof(bw_buf)); - set_label_text(s_widgets.link_ch_label, ch_buf); - set_label_text(s_widgets.link_sf_label, sf_buf); - set_label_text(s_widgets.link_bw_label, bw_buf); - - char last_heard_buf[64]; - format_age(::ui::i18n::tr("Last heard:"), node.last_seen, last_heard_buf, sizeof(last_heard_buf)); - char hop_buf[64]; - if (node.hops_away != 0xFF) - { - if (node.next_hop != 0) - { - snprintf(hop_buf, - sizeof(hop_buf), - "Hop: %u / NH: %02X", - static_cast(node.hops_away), - static_cast(node.next_hop)); - } - else - { - const std::string text = - ::ui::i18n::format("Hop: %u", static_cast(node.hops_away)); - snprintf(hop_buf, sizeof(hop_buf), "%s", text.c_str()); - } - set_label_text(s_widgets.link_hop_label, hop_buf); - } - else if (node.next_hop != 0) - { - const std::string text = - ::ui::i18n::format("Hop: - / NH: %02X", static_cast(node.next_hop)); - snprintf(hop_buf, sizeof(hop_buf), "%s", text.c_str()); - set_label_text(s_widgets.link_hop_label, hop_buf); - } - else - { - set_label_text(s_widgets.link_hop_label, ::ui::i18n::tr("Hop: -")); - } - - char status_buf[96]; - const char* mqtt_label = node.via_mqtt ? "MQTT" : "LoRa"; - const char* ignored_label = node.is_ignored ? "Ignored" : "Active"; - snprintf(status_buf, sizeof(status_buf), "%s / %s", mqtt_label, ignored_label); - set_label_text(s_widgets.link_last_heard_label, status_buf); - - if (node.has_public_key || node.key_manually_verified || node.has_device_metrics || node.hw_model != 0 || - node.has_macaddr || node.last_seen != 0) - { - char desc_buf[128]; - char pk_buf[20]; - if (node.key_manually_verified) - { - snprintf(pk_buf, sizeof(pk_buf), "%s", ::ui::i18n::tr("PKI: verified")); - } - else if (node.has_public_key) - { - snprintf(pk_buf, sizeof(pk_buf), "%s", ::ui::i18n::tr("PKI: known")); - } - else - { - snprintf(pk_buf, sizeof(pk_buf), "%s", ::ui::i18n::tr("PKI: -")); - } - - if (node.has_device_metrics && node.device_metrics.has_battery_level) - { - snprintf(desc_buf, - sizeof(desc_buf), - "HW:%u BAT:%u%% %s", - static_cast(node.hw_model), - static_cast(node.device_metrics.battery_level), - pk_buf); - } - else - { - snprintf(desc_buf, - sizeof(desc_buf), - "HW:%u %s %s", - static_cast(node.hw_model), - node.has_macaddr ? "MAC" : "NO-MAC", - pk_buf); - } - set_label_text(s_widgets.updated_label, desc_buf); - lv_obj_clear_flag(s_widgets.location_updated, LV_OBJ_FLAG_HIDDEN); - } - else - { - set_label_text(s_widgets.updated_label, last_heard_buf); - lv_obj_clear_flag(s_widgets.location_updated, LV_OBJ_FLAG_HIDDEN); - } + s_state.node = node; + s_state.has_node = true; + s_state.self_point = resolve_self_position(); + s_state.map_ready = false; + s_state.zoom = kDefaultZoom; + render_scene(); + lv_async_call(render_map_async, nullptr); } } // namespace ui diff --git a/modules/ui_shared/src/ui/screens/node_info/node_info_page_layout.cpp b/modules/ui_shared/src/ui/screens/node_info/node_info_page_layout.cpp index 9c429484..6cef8011 100644 --- a/modules/ui_shared/src/ui/screens/node_info/node_info_page_layout.cpp +++ b/modules/ui_shared/src/ui/screens/node_info/node_info_page_layout.cpp @@ -1,66 +1,10 @@ -/** - * UI Wireframe / Layout Tree (Node Info - matches provided mock) - * -------------------------------------------------------------------- - * - * Root (COLUMN) - * +------------------------------------------------------------------+ - * | Header (TopBar) [< Back] NODE INFO [Battery] | - * +------------------------------------------------------------------+ - * | TopRow (ROW, grow) | - * | +-------------------------+ +---------------------------------+ | - * | | Info Card (left) | | Location Card (right) | | - * | | +---------------------+ | | +---------------------------+ | | - * | | | Avatar (S) | | | | Title: Location | | | - * | | | Name: ALFA-3 | | | +---------------------------+ | | - * | | | Desc: Relay Node | | | | Map Placeholder | | | - * | | +---------------------+ | | | [Map image] | | | - * | | | ID: !a1b2c3 | | | +---------------------------+ | | - * | | | Role: Router | | | | Lat,Lon +/-Acc Alt | | | - * | | +---------------------+ | | +---------------------------+ | | - * | | | | | Updated: 2m ago | | | - * | +-------------------------+ | +---------------------------+ | | - * | +---------------------------------+ | - * | | - * | Link Panel (full width) | - * | +------------------------------------------------------------+ | - * | | Title: Link | | - * | +------------------------------------------------------------+ | - * | | RSSI: -112 SNR: 7.5 Ch: 478.875 SF: 7 BW: 125k | | - * | +------------------------------------------------------------+ | - * | | Hop: 2 Last heard: 18s | | - * | +------------------------------------------------------------+ | - * +------------------------------------------------------------------+ - * - * Tree view: - * Root(COL) - * - Header - * - Content(COL) - * - TopRow(ROW, grow=1) - * - InfoCard(COL) - * - InfoHeader(ROW) - * - InfoFooter(ROW) - * - LocationCard(COL, grow=1) - * - LocationHeader - * - LocationMap(grow=1) - * - LocationCoords - * - LocationUpdated - * - LinkPanel(COL) - * - LinkHeader - * - LinkRow1 - * - LinkRow2 - * - * Preconditions: - * - Root uses LV_FLEX_FLOW_COLUMN. - * - TopRow uses LV_FLEX_FLOW_ROW. - * - LinkPanel uses LV_FLEX_FLOW_COLUMN. - */ - /** * @file node_info_page_layout.cpp - * @brief Node info page layout + * @brief Node info page layout helpers */ #include "ui/screens/node_info/node_info_page_layout.h" + #include "ui/page/page_profile.h" #include "ui/widgets/top_bar.h" @@ -73,8 +17,36 @@ namespace layout namespace { -constexpr lv_coord_t kScreenWidth = 480; -constexpr lv_coord_t kBaseContentHeight = 192; + +lv_coord_t resolve_parent_width(lv_obj_t* parent) +{ + if (parent) + { + const lv_coord_t width = lv_obj_get_width(parent); + if (width > 0) + { + return width; + } + } + + const lv_coord_t width = lv_display_get_physical_horizontal_resolution(nullptr); + return width > 0 ? width : 320; +} + +lv_coord_t resolve_parent_height(lv_obj_t* parent) +{ + if (parent) + { + const lv_coord_t height = lv_obj_get_height(parent); + if (height > 0) + { + return height; + } + } + + const lv_coord_t height = lv_display_get_physical_vertical_resolution(nullptr); + return height > 0 ? height : 240; +} lv_coord_t top_bar_height() { @@ -83,196 +55,45 @@ lv_coord_t top_bar_height() : static_cast(::ui::widgets::kTopBarHeight); } -lv_coord_t screen_height() -{ - return top_bar_height() + kBaseContentHeight; -} -constexpr int kContentPadX = 8; -constexpr int kContentPadY = 4; -constexpr int kCardGapX = 8; -constexpr int kRowGap = 8; - -constexpr int kTopRowX = kContentPadX; -constexpr int kTopRowY = kContentPadY; -constexpr int kTopRowWidth = kScreenWidth - (kContentPadX * 2); -constexpr int kTopRowHeight = 118; - -constexpr int kInfoCardWidth = 190; -constexpr int kInfoCardHeight = 118; -constexpr int kLocationCardWidth = 266; -constexpr int kLocationCardHeight = 118; - -constexpr int kLinkPanelX = kContentPadX; -constexpr int kLinkPanelY = kTopRowY + kTopRowHeight + kRowGap; -constexpr int kLinkPanelWidth = kTopRowWidth; -constexpr int kLinkPanelHeight = 60; - -constexpr int kLocationHeaderHeight = 22; -constexpr int kLocationMapHeight = 52; -constexpr int kLocationCoordsHeight = 18; -constexpr int kLocationUpdatedHeight = 18; - -constexpr int kInfoHeaderHeight = 70; -constexpr int kInfoFooterHeight = 36; - -constexpr int kLinkHeaderHeight = 20; -constexpr int kLinkRowHeight = 16; - -void make_non_scrollable(lv_obj_t* obj) +void make_plain(lv_obj_t* obj) { lv_obj_clear_flag(obj, LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_scrollbar_mode(obj, LV_SCROLLBAR_MODE_OFF); + lv_obj_set_style_pad_all(obj, 0, 0); + lv_obj_set_style_border_width(obj, 0, 0); + lv_obj_set_style_radius(obj, 0, 0); } + } // namespace lv_obj_t* create_root(lv_obj_t* parent) { lv_obj_t* root = lv_obj_create(parent); - lv_obj_set_size(root, kScreenWidth, screen_height()); + lv_obj_set_size(root, resolve_parent_width(parent), resolve_parent_height(parent)); lv_obj_set_pos(root, 0, 0); - lv_obj_set_style_pad_all(root, 0, 0); - make_non_scrollable(root); + make_plain(root); return root; } lv_obj_t* create_header(lv_obj_t* root) { lv_obj_t* header = lv_obj_create(root); - lv_obj_set_size(header, kScreenWidth, top_bar_height()); + lv_obj_set_size(header, lv_obj_get_width(root), top_bar_height()); lv_obj_set_pos(header, 0, 0); - make_non_scrollable(header); + make_plain(header); return header; } lv_obj_t* create_content(lv_obj_t* root) { + const lv_coord_t header_h = top_bar_height(); lv_obj_t* content = lv_obj_create(root); - lv_obj_set_size(content, kScreenWidth, kBaseContentHeight); - lv_obj_set_pos(content, 0, top_bar_height()); - lv_obj_set_style_pad_all(content, 0, 0); - make_non_scrollable(content); + lv_obj_set_size(content, lv_obj_get_width(root), lv_obj_get_height(root) - header_h); + lv_obj_set_pos(content, 0, header_h); + make_plain(content); return content; } -lv_obj_t* create_top_row(lv_obj_t* content) -{ - lv_obj_t* row = lv_obj_create(content); - lv_obj_set_size(row, kTopRowWidth, kTopRowHeight); - lv_obj_set_pos(row, kTopRowX, kTopRowY); - lv_obj_set_style_pad_all(row, 0, 0); - make_non_scrollable(row); - return row; -} - -lv_obj_t* create_info_card(lv_obj_t* top_row) -{ - lv_obj_t* card = lv_obj_create(top_row); - lv_obj_set_size(card, kInfoCardWidth, kInfoCardHeight); - lv_obj_set_pos(card, 0, 0); - lv_obj_set_style_pad_all(card, 0, 0); - make_non_scrollable(card); - return card; -} - -lv_obj_t* create_info_header(lv_obj_t* info_card) -{ - lv_obj_t* header = lv_obj_create(info_card); - lv_obj_set_size(header, kInfoCardWidth, kInfoHeaderHeight); - lv_obj_set_pos(header, 0, 0); - lv_obj_set_style_pad_all(header, 0, 0); - make_non_scrollable(header); - return header; -} - -lv_obj_t* create_info_footer(lv_obj_t* info_card) -{ - lv_obj_t* footer = lv_obj_create(info_card); - lv_obj_set_size(footer, 170, kInfoFooterHeight); - lv_obj_set_pos(footer, 10, 70); - lv_obj_set_style_pad_all(footer, 0, 0); - make_non_scrollable(footer); - return footer; -} - -lv_obj_t* create_location_card(lv_obj_t* top_row) -{ - lv_obj_t* card = lv_obj_create(top_row); - lv_obj_set_size(card, kLocationCardWidth, kLocationCardHeight); - lv_obj_set_pos(card, kInfoCardWidth + kCardGapX, 0); - lv_obj_set_style_pad_all(card, 0, 0); - make_non_scrollable(card); - return card; -} - -lv_obj_t* create_location_header(lv_obj_t* location_card) -{ - lv_obj_t* header = lv_obj_create(location_card); - lv_obj_set_size(header, kLocationCardWidth, kLocationHeaderHeight); - lv_obj_set_pos(header, 0, 0); - lv_obj_set_style_pad_all(header, 0, 0); - make_non_scrollable(header); - return header; -} - -lv_obj_t* create_location_map(lv_obj_t* location_card) -{ - lv_obj_t* map = lv_obj_create(location_card); - lv_obj_set_size(map, 246, kLocationMapHeight); - lv_obj_set_pos(map, 10, 30); - lv_obj_set_style_pad_all(map, 0, 0); - make_non_scrollable(map); - return map; -} - -lv_obj_t* create_location_coords(lv_obj_t* location_card) -{ - lv_obj_t* coords = lv_obj_create(location_card); - lv_obj_set_size(coords, 246, kLocationCoordsHeight); - lv_obj_set_pos(coords, 10, 86); - lv_obj_set_style_pad_all(coords, 0, 0); - make_non_scrollable(coords); - return coords; -} - -lv_obj_t* create_location_updated(lv_obj_t* location_card) -{ - lv_obj_t* updated = lv_obj_create(location_card); - lv_obj_set_size(updated, 246, kLocationUpdatedHeight); - lv_obj_set_pos(updated, 10, 106); - lv_obj_set_style_pad_all(updated, 0, 0); - make_non_scrollable(updated); - return updated; -} - -lv_obj_t* create_link_panel(lv_obj_t* content) -{ - lv_obj_t* panel = lv_obj_create(content); - lv_obj_set_size(panel, kLinkPanelWidth, kLinkPanelHeight); - lv_obj_set_pos(panel, kLinkPanelX, kLinkPanelY); - lv_obj_set_style_pad_all(panel, 0, 0); - make_non_scrollable(panel); - return panel; -} - -lv_obj_t* create_link_header(lv_obj_t* link_panel) -{ - lv_obj_t* header = lv_obj_create(link_panel); - lv_obj_set_size(header, kLinkPanelWidth, kLinkHeaderHeight); - lv_obj_set_pos(header, 0, 0); - lv_obj_set_style_pad_all(header, 0, 0); - make_non_scrollable(header); - return header; -} - -lv_obj_t* create_link_row(lv_obj_t* link_panel) -{ - lv_obj_t* row = lv_obj_create(link_panel); - lv_obj_set_size(row, kLinkPanelWidth, kLinkRowHeight); - lv_obj_set_style_pad_all(row, 0, 0); - make_non_scrollable(row); - return row; -} - } // namespace layout } // namespace ui } // namespace node_info diff --git a/modules/ui_shared/src/ui/screens/pc_link/pc_link_page_runtime.cpp b/modules/ui_shared/src/ui/screens/pc_link/pc_link_page_runtime.cpp index 8db6e449..2c88cc85 100644 --- a/modules/ui_shared/src/ui/screens/pc_link/pc_link_page_runtime.cpp +++ b/modules/ui_shared/src/ui/screens/pc_link/pc_link_page_runtime.cpp @@ -216,16 +216,16 @@ void enter(const shell::Host* host, lv_obj_t* parent) lv_obj_center(stack); lv_obj_t* title = lv_label_create(stack); - ::ui::i18n::set_label_text(title, page_subtitle()); lv_obj_set_style_text_font(title, &lv_font_montserrat_18, 0); + ::ui::i18n::set_label_text(title, page_subtitle()); s_status_label = lv_label_create(stack); - ::ui::i18n::set_label_text(s_status_label, "Waiting for host..."); lv_obj_set_style_text_font(s_status_label, &lv_font_montserrat_18, 0); + ::ui::i18n::set_label_text(s_status_label, "Waiting for host..."); s_count_label = lv_label_create(stack); - ::ui::i18n::set_label_text(s_count_label, "RX: 0 TX: 0"); lv_obj_set_style_text_font(s_count_label, &lv_font_montserrat_16, 0); + ::ui::i18n::set_label_text(s_count_label, "RX: 0 TX: 0"); if (!s_timer) { diff --git a/modules/ui_shared/src/ui/screens/usb/usb_page_runtime.cpp b/modules/ui_shared/src/ui/screens/usb/usb_page_runtime.cpp index cb967c3e..45c9241e 100644 --- a/modules/ui_shared/src/ui/screens/usb/usb_page_runtime.cpp +++ b/modules/ui_shared/src/ui/screens/usb/usb_page_runtime.cpp @@ -272,26 +272,26 @@ void enter(const shell::Host* host, lv_obj_t* parent) if (!platform::ui::device::card_ready()) { lv_obj_t* error_label = lv_label_create(s_content); - ::ui::i18n::set_label_text(error_label, "SD Card Not Found\nPlease insert SD card"); - lv_obj_center(error_label); lv_obj_set_style_text_font(error_label, &lv_font_montserrat_18, LV_PART_MAIN); lv_obj_set_style_text_color(error_label, lv_color_hex(0xCC0000), LV_PART_MAIN); + ::ui::i18n::set_label_text(error_label, "SD Card Not Found\nPlease insert SD card"); + lv_obj_center(error_label); return; } s_status_label = lv_label_create(s_content); - ::ui::i18n::set_label_text(s_status_label, "Initializing..."); - lv_obj_center(s_status_label); lv_obj_set_style_text_font(s_status_label, &lv_font_montserrat_18, LV_PART_MAIN); lv_obj_set_style_text_color(s_status_label, lv_color_hex(0x3A2A1A), LV_PART_MAIN); lv_obj_set_style_text_align(s_status_label, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); + ::ui::i18n::set_label_text(s_status_label, "Initializing..."); + lv_obj_center(s_status_label); lv_obj_t* info_label = lv_label_create(s_content); - ::ui::i18n::set_label_text(info_label, "Press Back to exit USB mode"); - lv_obj_align(info_label, LV_ALIGN_BOTTOM_MID, 0, -20); lv_obj_set_style_text_font(info_label, &lv_font_montserrat_14, LV_PART_MAIN); lv_obj_set_style_text_color(info_label, lv_color_hex(0x6A5646), LV_PART_MAIN); lv_obj_set_style_text_opa(info_label, LV_OPA_80, LV_PART_MAIN); + ::ui::i18n::set_label_text(info_label, "Press Back to exit USB mode"); + lv_obj_align(info_label, LV_ALIGN_BOTTOM_MID, 0, -20); update_status_label(); diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/meshtastic/mt_adapter.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/meshtastic/mt_adapter.h index 46b1b56b..5f481a6a 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/meshtastic/mt_adapter.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/meshtastic/mt_adapter.h @@ -197,6 +197,7 @@ class MtAdapter : public chat::IMeshAdapter static constexpr uint8_t MAX_RETRIES = 3; static constexpr uint32_t NODEINFO_INTERVAL_MS = 3 * 60 * 60 * 1000; static constexpr uint32_t NODEINFO_REPLY_SUPPRESS_MS = 12 * 60 * 60 * 1000; + static constexpr uint32_t NODEINFO_REANNOUNCE_SUPPRESS_MS = 60 * 1000; static constexpr uint32_t POSITION_REPLY_SUPPRESS_MS = 3 * 60 * 1000; static constexpr uint32_t PKI_BACKOFF_MS = 5 * 60 * 1000; static constexpr size_t MAX_APP_QUEUE = 10; @@ -230,6 +231,10 @@ class MtAdapter : public chat::IMeshAdapter bool want_ack_flag, bool want_response); void maybeBroadcastNodeInfo(uint32_t now_ms); + void maybeBroadcastNodeInfoAfterPeerAnnouncement(uint32_t from_node, + uint32_t now_ms, + ChannelId channel, + bool from_mqtt); void configureRadio(); void initNodeIdentity(); void updateChannelKeys(); diff --git a/platform/esp/arduino_common/src/app_runtime_support.cpp b/platform/esp/arduino_common/src/app_runtime_support.cpp index e0697224..c10ede6c 100644 --- a/platform/esp/arduino_common/src/app_runtime_support.cpp +++ b/platform/esp/arduino_common/src/app_runtime_support.cpp @@ -1,21 +1,85 @@ #include "platform/esp/arduino_common/app_runtime_support.h" +#include +#include + #include "app/app_config.h" #include "app/app_facade_access.h" #include "app/app_facades.h" #include "ble/ble_manager.h" +#include "board/BoardBase.h" #include "chat/usecase/chat_service.h" #include "chat/usecase/contact_service.h" #include "platform/esp/arduino_common/app_tasks.h" #include "platform/esp/arduino_common/device_identity.h" #include "platform/esp/arduino_common/hostlink/hostlink_service.h" +#include "platform/ui/settings_store.h" #include "sys/event_bus.h" #include "team/usecase/team_pairing_service.h" #include "team/usecase/team_service.h" #include "team/usecase/team_track_sampler.h" +#include "ui/localization.h" +#include "ui/widgets/system_notification.h" namespace platform::esp::arduino_common { +namespace +{ + +void triggerNodeInfoFeedback(app::IAppFacade& app_context) +{ + BoardBase* board = app_context.getBoard(); + if (!board) + { + return; + } + + if (platform::ui::settings_store::get_bool("settings", "vibration_enabled", true)) + { + board->vibrator(); + } + + board->playMessageTone(); +} + +std::string resolveNodeInfoName(app::IAppFacade& app_context, const sys::NodeInfoUpdateEvent& node_event) +{ + std::string name = app_context.getContactService().getContactName(node_event.node_id); + if (!name.empty()) + { + return name; + } + + if (node_event.short_name[0] != '\0') + { + return std::string(node_event.short_name); + } + + if (node_event.long_name[0] != '\0') + { + return std::string(node_event.long_name); + } + + char fallback[16]; + std::snprintf(fallback, sizeof(fallback), "%08lX", static_cast(node_event.node_id)); + return fallback; +} + +void notifyNodeInfoUpdate(app::IAppFacade& app_context, const sys::NodeInfoUpdateEvent& node_event) +{ + if (node_event.node_id == 0 || node_event.node_id == app_context.getSelfNodeId() || node_event.is_ignored) + { + return; + } + + triggerNodeInfoFeedback(app_context); + + const std::string message = + ::ui::i18n::format("Node info: %s", resolveNodeInfoName(app_context, node_event).c_str()); + ::ui::SystemNotification::show(message.c_str(), 3000); +} + +} // namespace BackgroundTaskStartResult startBackgroundTasks(LoraBoard* board, chat::IMeshAdapter* adapter) { @@ -134,6 +198,7 @@ bool dispatchEvent(app::IAppFacade& app_context, sys::Event* event) update.device_metrics = node_event->device_metrics; } app_context.getContactService().applyNodeUpdate(node_event->node_id, update); + notifyNodeInfoUpdate(app_context, *node_event); delete event; return true; } diff --git a/platform/esp/arduino_common/src/chat/infra/meshtastic/mt_adapter.cpp b/platform/esp/arduino_common/src/chat/infra/meshtastic/mt_adapter.cpp index 9d7816f5..fae478e4 100644 --- a/platform/esp/arduino_common/src/chat/infra/meshtastic/mt_adapter.cpp +++ b/platform/esp/arduino_common/src/chat/infra/meshtastic/mt_adapter.cpp @@ -2142,6 +2142,14 @@ void MtAdapter::processReceivedPacket(const uint8_t* data, size_t size) { node_last_channel_[header.from] = channel_id; } + if (nodeinfo_decoded) + { + maybeBroadcastNodeInfoAfterPeerAnnouncement( + header.from, + millis(), + channel_id, + (header.flags & chat::meshtastic::PACKET_FLAGS_VIA_MQTT_MASK) != 0); + } if (want_ack_flag && to_us) { if (sendRoutingAck(header.from, header.id, header.channel, psk, psk_len)) @@ -2702,6 +2710,10 @@ bool MtAdapter::sendNodeInfoTo(uint32_t dest, bool want_response, ChannelId chan } bool ok = transmitWirePacket(wire_buffer, wire_size); + if (ok && dest == 0xFFFFFFFF) + { + last_nodeinfo_ms_ = millis(); + } LORA_LOG("[LORA] TX nodeinfo id=%08lX len=%u ok=%d\n", (unsigned long)msg_id, (unsigned)wire_size, @@ -2844,6 +2856,39 @@ void MtAdapter::maybeBroadcastNodeInfo(uint32_t now_ms) } } +void MtAdapter::maybeBroadcastNodeInfoAfterPeerAnnouncement(uint32_t from_node, + uint32_t now_ms, + ChannelId channel, + bool from_mqtt) +{ + if (!ready_ || from_mqtt || from_node == 0 || from_node == node_id_) + { + return; + } + + if (last_nodeinfo_ms_ != 0 && + (now_ms - last_nodeinfo_ms_) < NODEINFO_REANNOUNCE_SUPPRESS_MS) + { + LORA_LOG("[LORA] TX nodeinfo announce suppressed from=%08lX age=%lu\n", + (unsigned long)from_node, + (unsigned long)(now_ms - last_nodeinfo_ms_)); + return; + } + + if (sendNodeInfoTo(0xFFFFFFFF, false, channel)) + { + LORA_LOG("[LORA] TX nodeinfo announce after peer from=%08lX ch=%u\n", + (unsigned long)from_node, + static_cast(channel)); + } + else + { + LORA_LOG("[LORA] TX nodeinfo announce fail after peer from=%08lX ch=%u\n", + (unsigned long)from_node, + static_cast(channel)); + } +} + void MtAdapter::configureRadio() { if (!board_.isRadioOnline()) diff --git a/platform/esp/arduino_common/src/chat/internal/blob_store_io.cpp b/platform/esp/arduino_common/src/chat/internal/blob_store_io.cpp index cc9cfb63..cce3396a 100644 --- a/platform/esp/arduino_common/src/chat/internal/blob_store_io.cpp +++ b/platform/esp/arduino_common/src/chat/internal/blob_store_io.cpp @@ -7,6 +7,7 @@ #include #include +#include namespace chat { @@ -15,6 +16,11 @@ namespace infra namespace { +std::string makeTempSdPath(const char* path) +{ + return std::string(path ? path : "") + ".tmp"; +} + bool isValidPrefKey(const char* key) { return key && key[0] != '\0'; @@ -117,12 +123,18 @@ bool saveRawBlobToSd(const char* path, const uint8_t* data, size_t len) return false; } - if (SD.exists(path)) + const std::string temp_path = makeTempSdPath(path); + if (temp_path.empty()) { - SD.remove(path); + return false; } - File file = SD.open(path, FILE_WRITE); + if (SD.exists(temp_path.c_str())) + { + SD.remove(temp_path.c_str()); + } + + File file = SD.open(temp_path.c_str(), FILE_WRITE); if (!file) { return false; @@ -134,7 +146,24 @@ bool saveRawBlobToSd(const char* path, const uint8_t* data, size_t len) ok = (file.write(data, len) == len); } file.close(); - return ok; + if (!ok) + { + SD.remove(temp_path.c_str()); + return false; + } + + if (SD.exists(path)) + { + SD.remove(path); + } + + if (!SD.rename(temp_path.c_str(), path)) + { + SD.remove(temp_path.c_str()); + return false; + } + + return true; } bool loadRawBlobFromPreferences(const char* ns, const char* key, std::vector& out)