diff --git a/builds/esp_idf/ESP_IDF_COMPONENT_SOURCES.cmake b/builds/esp_idf/ESP_IDF_COMPONENT_SOURCES.cmake index 1efc1429..e3613c06 100644 --- a/builds/esp_idf/ESP_IDF_COMPONENT_SOURCES.cmake +++ b/builds/esp_idf/ESP_IDF_COMPONENT_SOURCES.cmake @@ -187,6 +187,7 @@ set(TRAILMATE_ESP_IDF_UI_SHARED_SOURCES "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/components/air_status_footer.cpp" "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/components/floating_search_box.cpp" "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/components/info_card.cpp" + "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/components/screen_saver_overlay.cpp" "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/components/shortcut_help_modal.cpp" "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/components/two_pane_layout.cpp" "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/components/two_pane_nav.cpp" diff --git a/docs/RETICULUM_LXMF_RUNTIME_ALIGNMENT_PLAN.md b/docs/RETICULUM_LXMF_RUNTIME_ALIGNMENT_PLAN.md index 19f79552..17c9a371 100644 --- a/docs/RETICULUM_LXMF_RUNTIME_ALIGNMENT_PLAN.md +++ b/docs/RETICULUM_LXMF_RUNTIME_ALIGNMENT_PLAN.md @@ -104,6 +104,12 @@ Required side-effect policy: - LoRa TX must be reserved for local product behavior and explicitly throttled protocol needs. Public Wi-Fi background discovery must not consume LoRa airtime by default. +- Local LXMF identity announces are product discoverability traffic. When LoRa + and a Wi-Fi gateway are both enabled, a successful announce attempt may be + sent on any available carrier, but the pending announce state is not complete + until every enabled/configured carrier has accepted the delivery and + propagation announces. If one carrier is not ready, retry at a low frequency + instead of treating the first carrier's success as full discoverability. Current ESP policy constants such as a small Wi-Fi socket read budget, a limited number of Reticulum ingress packets per poll, a multi-second Wi-Fi discovery diff --git a/docs/RETICULUM_MODE_USER_GUIDE.md b/docs/RETICULUM_MODE_USER_GUIDE.md index ac2c318b..81c4b2e0 100644 --- a/docs/RETICULUM_MODE_USER_GUIDE.md +++ b/docs/RETICULUM_MODE_USER_GUIDE.md @@ -116,16 +116,38 @@ In Reticulum mode, the Contacts page uses these filters: - `Groups`: locally configured Reticulum shared group destinations. - `Ignored`: nodes hidden from the normal contact lists. +For Reticulum peers, the list title follows the same model MeshChat exposes: +saved nickname first, then the LXMF announce display name, then the short +hash fallback. Identity hash and LXMF address are identity/address details, not +the primary contact-list name. + `Groups` are not discovered from the Reticulum network. They are explicit local configuration. This matters because a Reticulum shared destination must be known before Trail Mate can send to it or accept inbound group packets for it. Contacts also supports the same floating search interaction used by the Network view. Press `/` or `s` while focus is on the Contacts page to search the current -Contacts, Nearby, Groups, or Ignored list. For normal discovered nodes across -Meshtastic, MeshCore, and Reticulum, the filter matches `short_name`. The search -only changes the visible list; it does not modify the SD-backed node store, -contacts, groups, or ignored state. +Contacts, Nearby, Groups, or Ignored list. Meshtastic and MeshCore searches +match node short names; Reticulum searches also match the visible display name, +LXMF destination hash, and identity hash. The search only changes the visible +list; it does not modify the SD-backed node store, contacts, groups, or ignored +state. + +Press `f` in Contacts or Chat to hide or show the filter column. This is useful +on the small screen when Reticulum display names are longer than the default +list column can comfortably show. + +In Reticulum mode, press `a` in Contacts to add a peer by LXMF Address. The +dialog accepts either a plain 32-character destination hash or the MeshChat-style +`lxmf@...` form; spaces, `:`, `-`, and `_` inside the hash are ignored. Because +this manual entry only contains the delivery destination hash, Trail Mate stores +it as a local contact/node projection first. A full `lxmf_addresses.tsv` row is +only written after a real LXMF delivery announce or path response provides the +identity hash and public keys needed for a verifiable address-book record. + +In Chat, press `/` or `s` to search conversation/contact names in the current +Direct, Broadcast, or Team list. The Chat search is a conversation-list filter; +it does not search message body history. ## SD Card Requirement diff --git a/docs/protocol_runtime_budget_policy.md b/docs/protocol_runtime_budget_policy.md index 53221d71..74123cd8 100644 --- a/docs/protocol_runtime_budget_policy.md +++ b/docs/protocol_runtime_budget_policy.md @@ -19,6 +19,10 @@ When the screen is on, realtime processing is limited to: Ordinary public announces and public discovery traffic are not realtime while the user is interacting with the device. They may be delayed for seconds or tens of seconds and replayed during an idle or screen-off maintenance window. +The exception is lightweight peer-name projection: verified LXMF delivery +announces may publish one coalesced contact-store update at a low awake-screen +rate so Contacts and Chat can show the sender's display name without waiting +for SD or NVS persistence. ## Required Boundaries @@ -35,6 +39,9 @@ tens of seconds and replayed during an idle or screen-off maintenance window. `record_announce()` and `record_lxmf_address()` on ESP Arduino are queueing APIs for runtime RX callers; they must not perform TSV upserts or other SD file I/O on the RX caller task. +- Peer-name projection is not persistence. It may run while the screen is on, + but it must be queue-backed and rate-limited; it must not trigger SD, TSV, or + NVS writes. - Peer cache persistence is dirty/coalesced. Code must not force `maybePersistPeers(true)` from RX paths, and non-forced peer dirty marking must not flush NVS from `mesh_task`. @@ -55,7 +62,8 @@ Reticulum runtime may: - Replay deferred discovery packets under the discovery sample budget. - Let the Reticulum directory worker persist one coalesced announce or LXMF address record to SD per slice. -- Publish deferred peer projections to the contact store. +- Publish deferred peer projections to the contact store at the faster + maintenance-window rate. The maintenance window still has finite budgets. It must not drain unbounded network queues in one cycle. diff --git a/docs/specification/ESP_UI_LIFECYCLE_SPEC.md b/docs/specification/ESP_UI_LIFECYCLE_SPEC.md new file mode 100644 index 00000000..76fab629 --- /dev/null +++ b/docs/specification/ESP_UI_LIFECYCLE_SPEC.md @@ -0,0 +1,42 @@ +# ESP UI Lifecycle Spec + +Trail Mate ESP targets share one user-visible UI lifecycle. Board profiles may +adapt hardware operations such as backlight, keyboard light, touch IRQ, and +display sleep, but they must not redefine which product page appears for boot +or wake. + +## Boot + +- Normal startup must show the Trail Mate boot UI before the main menu shell is + marked ready. +- The boot UI synchronous present policy is shared by pager, TDeck, and + T-Display style ESP targets. It must not be disabled with board-specific + preprocessor branches. +- Target startup code may choose the initial boot log text, but it must not skip + the boot screen only because the target is Arduino, ESP-IDF, TDeck, pager, or + T-Display. + +## Screen Sleep And Wake + +- Screen sleep has two separate meanings: + - physical display sleep, owned by the platform or board runtime; + - transient screen-saver visibility, owned by shared UI. +- Waking from physical screen sleep must show the shared screen-saver overlay + first on pager, TDeck, and T-Display style ESP targets. +- Wake input must not directly enter the main menu unless it is explicitly the + product's "enter from saver" action. +- `enterFromScreenSaver()` is the boundary for leaving the transient saver and + returning to the normal UI. +- Platform screen-sleep runtimes must not create board-local LVGL screen-saver + pages, labels, or styles. They may only call the shared screen-saver hooks and + manage hardware brightness/sleep state. + +## Threading + +- Non-LVGL tasks must not call LVGL screen-saver or menu functions directly. + They must post through the platform UI dispatcher or call platform hooks that + are known to run on the LVGL task. +- Shared screen-saver rendering belongs in `ui_shared`; platform runtimes may + request an immediate present only when they are already in a LVGL-safe input + path. + diff --git a/modules/core_chat/include/chat/domain/contact_types.h b/modules/core_chat/include/chat/domain/contact_types.h index 85bc4c7b..8ccd9e8f 100644 --- a/modules/core_chat/include/chat/domain/contact_types.h +++ b/modules/core_chat/include/chat/domain/contact_types.h @@ -155,7 +155,7 @@ struct NodeInfoBase uint8_t hops_away = 0xFF; // 0xFF = unknown uint8_t channel = 0xFF; // Meshtastic channel index, 0xFF = unknown bool is_contact; // true if user has assigned a nickname - std::string display_name; // nickname if contact, short_name otherwise + std::string display_name; // nickname, announce/long name, then short_name fallback NodeProtocolType protocol; NodeRoleType role; uint8_t hw_model = 0; diff --git a/modules/core_chat/src/usecase/contact_service.cpp b/modules/core_chat/src/usecase/contact_service.cpp index b48b5581..e7d39326 100644 --- a/modules/core_chat/src/usecase/contact_service.cpp +++ b/modules/core_chat/src/usecase/contact_service.cpp @@ -27,6 +27,52 @@ namespace contacts #define CONTACT_SERVICE_LOG(...) #endif +namespace +{ + +bool has_text(const char* text) +{ + return text && text[0] != '\0'; +} + +bool same_text(const char* lhs, const char* rhs) +{ + if (!lhs || !rhs) + { + return lhs == rhs; + } + return std::strcmp(lhs, rhs) == 0; +} + +std::string preferred_node_name(const char* short_name, const char* long_name) +{ + if (has_text(long_name) && !same_text(long_name, short_name)) + { + return std::string(long_name); + } + if (has_text(short_name)) + { + return std::string(short_name); + } + if (has_text(long_name)) + { + return std::string(long_name); + } + return std::string(); +} + +std::string preferred_node_name(const NodeInfo& node) +{ + if (!node.display_name.empty() && + !same_text(node.display_name.c_str(), node.short_name)) + { + return node.display_name; + } + return preferred_node_name(node.short_name, node.long_name); +} + +} // namespace + ContactService::ContactService(INodeStore& node_store, IContactStore& contact_store) : node_store_(node_store), contact_store_(contact_store), cache_timestamp_(0) { @@ -114,7 +160,7 @@ std::string ContactService::getContactName(uint32_t node_id) const { if (node.node_id == node_id) { - return std::string(node.short_name); + return preferred_node_name(node); } } @@ -123,7 +169,7 @@ std::string ContactService::getContactName(uint32_t node_id) const { if (entry.node_id == node_id) { - return std::string(entry.short_name); + return preferred_node_name(entry.short_name, entry.long_name); } } @@ -366,17 +412,15 @@ void ContactService::buildCache() const info.is_contact = std::find(contact_ids.begin(), contact_ids.end(), entry.node_id) != contact_ids.end(); - if (info.is_contact) + const std::string nickname = + info.is_contact ? contact_store_.getNickname(entry.node_id) : std::string(); + if (!nickname.empty()) { - info.display_name = contact_store_.getNickname(entry.node_id); - if (info.display_name.empty()) - { - info.display_name = std::string(info.short_name); - } + info.display_name = nickname; } else { - info.display_name = std::string(info.short_name); + info.display_name = preferred_node_name(info.short_name, info.long_name); } cached_nodes_.push_back(info); diff --git a/modules/core_sys/include/platform/ui/screen_runtime.h b/modules/core_sys/include/platform/ui/screen_runtime.h index 18f5ea28..0f449a9b 100644 --- a/modules/core_sys/include/platform/ui/screen_runtime.h +++ b/modules/core_sys/include/platform/ui/screen_runtime.h @@ -12,6 +12,9 @@ struct Hooks int (*read_unread_count)() = nullptr; void (*show_main_menu)() = nullptr; void (*on_wake_from_sleep)() = nullptr; + void (*show_screen_saver)() = nullptr; + void (*hide_screen_saver)() = nullptr; + void (*present_screen_saver)() = nullptr; }; uint32_t clamp_timeout_ms(uint32_t timeout_ms); diff --git a/modules/ui_shared/include/ui/components/floating_search_box.h b/modules/ui_shared/include/ui/components/floating_search_box.h index 000453a7..5cfee6da 100644 --- a/modules/ui_shared/include/ui/components/floating_search_box.h +++ b/modules/ui_shared/include/ui/components/floating_search_box.h @@ -22,6 +22,7 @@ struct Config { const char* title = "Search"; const char* initial_text = ""; + const char* accepted_chars = nullptr; uint16_t max_length = 31; lv_coord_t width = 280; lv_coord_t height = 150; diff --git a/modules/ui_shared/include/ui/components/screen_saver_overlay.h b/modules/ui_shared/include/ui/components/screen_saver_overlay.h new file mode 100644 index 00000000..21832f42 --- /dev/null +++ b/modules/ui_shared/include/ui/components/screen_saver_overlay.h @@ -0,0 +1,22 @@ +#pragma once + +#include +#include + +namespace ui::components::screen_saver_overlay +{ + +struct Hooks +{ + bool (*format_time)(char* out, std::size_t out_len) = nullptr; + int (*read_unread_count)() = nullptr; +}; + +void init(const Hooks& hooks); +void refresh(); +void show(); +void hide(); +bool is_visible(); +void present_now(std::uint8_t frame_count = 2, std::uint32_t frame_delay_ms = 16); + +} // namespace ui::components::screen_saver_overlay diff --git a/modules/ui_shared/include/ui/menu/menu_runtime.h b/modules/ui_shared/include/ui/menu/menu_runtime.h index 95d47692..dc794cf3 100644 --- a/modules/ui_shared/include/ui/menu/menu_runtime.h +++ b/modules/ui_shared/include/ui/menu/menu_runtime.h @@ -36,7 +36,6 @@ struct Hooks void init(lv_obj_t* screen_root, lv_obj_t* main_screen, lv_obj_t* menu_panel, const Hooks& hooks); void showWatchFace(); -void onWakeFromSleep(); void setMenuActive(bool active); bool handleWalkieKey(char key, int state); bool handleShortcutKey(char key, int state); diff --git a/modules/ui_shared/include/ui/screens/chat/chat_message_list_components.h b/modules/ui_shared/include/ui/screens/chat/chat_message_list_components.h index da7caecb..4915250f 100644 --- a/modules/ui_shared/include/ui/screens/chat/chat_message_list_components.h +++ b/modules/ui_shared/include/ui/screens/chat/chat_message_list_components.h @@ -13,6 +13,7 @@ #include "chat_message_list_input.h" #include "lvgl.h" #include "ui/components/air_status_footer.h" +#include "ui/components/floating_search_box.h" #include "ui/widgets/top_bar.h" #include #include @@ -58,6 +59,7 @@ class ChatMessageListScreen size_t getItemCount() const { return items_.size(); } lv_obj_t* getItemButton(size_t index) const; int getSelectedIndex() const { return selected_index_; } + bool isFilterPanelVisible() const { return filter_panel_visible_; } private: enum class TimerDomain @@ -109,6 +111,9 @@ class ChatMessageListScreen int selected_index_ = -1; FilterMode filter_mode_ = FilterMode::Direct; + bool filter_panel_visible_ = true; + char search_query_[32] = {}; + ::ui::components::floating_search_box::State search_box_{}; void (*action_cb_)(ActionIntent intent, const chat::ConversationId& conv, @@ -138,12 +143,22 @@ class ChatMessageListScreen void updateListItem(size_t index, const chat::ConversationMeta& conv); void updateFilterHighlight(); void setFilterMode(FilterMode mode); + bool searchActive() const; + bool conversationMatchesSearch(const chat::ConversationMeta& conv) const; + void buildFilteredConversations(std::vector& out) const; + void openSearchModal(); + void toggleFilterPanel(); + void applyFilterPanelVisibility(); static void item_event_cb(lv_event_t* e); static void list_back_event_cb(lv_event_t* e); static void item_focused_cb(lv_event_t* e); static void filter_focus_cb(lv_event_t* e); static void filter_click_cb(lv_event_t* e); + static void page_shortcut_cb(lv_event_t* e); + static void search_apply_cb(const char* text, void* user_data); + static void search_clear_cb(void* user_data); + static void search_cancel_cb(void* user_data); static void debug_touch_event_cb(lv_event_t* e); static void async_action_cb(void* user_data); static void on_root_deleted(lv_event_t* e); diff --git a/modules/ui_shared/include/ui/screens/contacts/contacts_state.h b/modules/ui_shared/include/ui/screens/contacts/contacts_state.h index d9367fe0..9e77045e 100644 --- a/modules/ui_shared/include/ui/screens/contacts/contacts_state.h +++ b/modules/ui_shared/include/ui/screens/contacts/contacts_state.h @@ -95,6 +95,7 @@ struct ContactsPageState bool rendered_mode_valid = false; ContactsMode rendered_mode = ContactsMode::Contacts; char rendered_search_query[32] = {}; + bool filter_panel_visible = true; // Data (using forward declaration, full type in .cpp) std::vector contacts_list; @@ -109,6 +110,7 @@ struct ContactsPageState // Modal windows ::ui::components::floating_search_box::State search_box; + ::ui::components::floating_search_box::State lxmf_address_box; lv_obj_t* add_edit_modal = nullptr; lv_obj_t* add_edit_textarea = nullptr; lv_obj_t* add_edit_error_label = nullptr; diff --git a/modules/ui_shared/src/ui/components/floating_search_box.cpp b/modules/ui_shared/src/ui/components/floating_search_box.cpp index 54bddf59..29f8e163 100644 --- a/modules/ui_shared/src/ui/components/floating_search_box.cpp +++ b/modules/ui_shared/src/ui/components/floating_search_box.cpp @@ -238,6 +238,10 @@ bool open(State& state, lv_obj_t* parent, const Config& config) lv_textarea_set_max_length(state.textarea, config.max_length < kMaxSearchText ? config.max_length : kMaxSearchText); + if (config.accepted_chars) + { + lv_textarea_set_accepted_chars(state.textarea, config.accepted_chars); + } lv_textarea_set_text(state.textarea, config.initial_text ? config.initial_text : ""); lv_obj_set_width(state.textarea, LV_PCT(100)); lv_obj_align(state.textarea, LV_ALIGN_TOP_MID, 0, compact ? 28 : 34); diff --git a/modules/ui_shared/src/ui/components/screen_saver_overlay.cpp b/modules/ui_shared/src/ui/components/screen_saver_overlay.cpp new file mode 100644 index 00000000..224e8366 --- /dev/null +++ b/modules/ui_shared/src/ui/components/screen_saver_overlay.cpp @@ -0,0 +1,166 @@ +#include "ui/components/screen_saver_overlay.h" + +#include + +#include "lvgl.h" +#include "sys/clock.h" +#include "ui/localization.h" + +#if !defined(LV_FONT_MONTSERRAT_36) || !LV_FONT_MONTSERRAT_36 +#define lv_font_montserrat_36 lv_font_montserrat_28 +#endif + +#if !defined(LV_FONT_MONTSERRAT_20) || !LV_FONT_MONTSERRAT_20 +#define lv_font_montserrat_20 lv_font_montserrat_14 +#endif + +namespace ui::components::screen_saver_overlay +{ +namespace +{ + +constexpr uint32_t kColorWarmBg = 0xF6E6C6; +constexpr uint32_t kColorText = 0x6B4A1E; +constexpr uint32_t kColorTextDim = 0x8A6A3A; + +Hooks s_hooks{}; +lv_obj_t* s_root = nullptr; +lv_obj_t* s_time_label = nullptr; +lv_obj_t* s_unread_label = nullptr; +lv_obj_t* s_hint_label = nullptr; + +void set_label_texts(int unread) +{ + if (s_unread_label != nullptr) + { + ::ui::i18n::set_label_text_fmt(s_unread_label, "Unread: %d", unread); + } + if (s_hint_label != nullptr) + { + ::ui::i18n::set_label_text(s_hint_label, "Press SPACE to resume"); + } +} + +void create_if_needed() +{ + if (s_root != nullptr) + { + return; + } + + lv_obj_t* parent = lv_screen_active(); + if (parent == nullptr) + { + return; + } + + s_root = lv_obj_create(parent); + lv_obj_set_size(s_root, LV_PCT(100), LV_PCT(100)); + lv_obj_align(s_root, LV_ALIGN_CENTER, 0, 0); + lv_obj_set_style_bg_color(s_root, lv_color_hex(kColorWarmBg), 0); + lv_obj_set_style_bg_opa(s_root, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(s_root, 0, 0); + lv_obj_set_style_radius(s_root, 0, 0); + lv_obj_set_style_pad_all(s_root, 0, 0); + lv_obj_clear_flag(s_root, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_clear_flag(s_root, LV_OBJ_FLAG_CLICKABLE); + + s_time_label = lv_label_create(s_root); + lv_obj_set_style_text_color(s_time_label, lv_color_hex(kColorText), 0); + lv_obj_set_style_text_font(s_time_label, &lv_font_montserrat_36, 0); + lv_label_set_text(s_time_label, "--:--"); + lv_obj_align(s_time_label, LV_ALIGN_CENTER, 0, -26); + + s_unread_label = lv_label_create(s_root); + lv_obj_set_style_text_color(s_unread_label, lv_color_hex(kColorText), 0); + lv_obj_set_style_text_font(s_unread_label, &lv_font_montserrat_20, 0); + lv_obj_align(s_unread_label, LV_ALIGN_CENTER, 0, 10); + + s_hint_label = lv_label_create(s_root); + lv_obj_set_style_text_color(s_hint_label, lv_color_hex(kColorTextDim), 0); + lv_obj_set_style_text_font(s_hint_label, &lv_font_montserrat_14, 0); + lv_obj_align(s_hint_label, LV_ALIGN_CENTER, 0, 40); + + set_label_texts(0); + lv_obj_add_flag(s_root, LV_OBJ_FLAG_HIDDEN); +} + +} // namespace + +void init(const Hooks& hooks) +{ + s_hooks = hooks; + create_if_needed(); +} + +void refresh() +{ + create_if_needed(); + if (s_root == nullptr || s_time_label == nullptr || s_unread_label == nullptr) + { + return; + } + + char time_buf[16] = "--:--"; + if (s_hooks.format_time == nullptr || + !s_hooks.format_time(time_buf, sizeof(time_buf))) + { + std::snprintf(time_buf, sizeof(time_buf), "--:--"); + } + lv_label_set_text(s_time_label, time_buf); + + const int unread = s_hooks.read_unread_count != nullptr ? s_hooks.read_unread_count() : 0; + set_label_texts(unread); +} + +void show() +{ + create_if_needed(); + if (s_root == nullptr) + { + return; + } + refresh(); + lv_obj_clear_flag(s_root, LV_OBJ_FLAG_HIDDEN); + lv_obj_move_foreground(s_root); +} + +void hide() +{ + if (s_root == nullptr) + { + return; + } + lv_obj_add_flag(s_root, LV_OBJ_FLAG_HIDDEN); +} + +bool is_visible() +{ + return s_root != nullptr && !lv_obj_has_flag(s_root, LV_OBJ_FLAG_HIDDEN); +} + +void present_now(std::uint8_t frame_count, std::uint32_t frame_delay_ms) +{ + if (frame_count == 0) + { + frame_count = 1; + } + for (std::uint8_t frame = 0; frame < frame_count; ++frame) + { + if (s_root != nullptr) + { + lv_obj_invalidate(s_root); + } + if (lv_obj_t* top = lv_layer_top()) + { + lv_obj_invalidate(top); + } + lv_refr_now(nullptr); + if (frame + 1U < frame_count && frame_delay_ms != 0) + { + sys::sleep_ms(frame_delay_ms); + } + } +} + +} // namespace ui::components::screen_saver_overlay diff --git a/modules/ui_shared/src/ui/menu/menu_runtime.cpp b/modules/ui_shared/src/ui/menu/menu_runtime.cpp index 678ef872..2536b3ac 100644 --- a/modules/ui_shared/src/ui/menu/menu_runtime.cpp +++ b/modules/ui_shared/src/ui/menu/menu_runtime.cpp @@ -748,14 +748,6 @@ void showWatchFace() updateWatchFaceTime(); } -void onWakeFromSleep() -{ -#if defined(ESP_PLATFORM) - ESP_LOGI(kTag, "onWakeFromSleep"); -#endif - showWatchFace(); -} - void setMenuActive(bool active) { s_runtime.menu_active = active; diff --git a/modules/ui_shared/src/ui/presentation_sources/chat_presentation_source.cpp b/modules/ui_shared/src/ui/presentation_sources/chat_presentation_source.cpp index e8667dc4..a0d15e84 100644 --- a/modules/ui_shared/src/ui/presentation_sources/chat_presentation_source.cpp +++ b/modules/ui_shared/src/ui/presentation_sources/chat_presentation_source.cpp @@ -8,6 +8,7 @@ #include #include #include +#include namespace ui::presentation_sources { @@ -24,18 +25,92 @@ void copyString(ui::FixedText& out, const std::string& text) ui::copyText(out, text.c_str()); } -void copyNodeLabel(ui::FixedText<32>& out, ::chat::NodeId node_id) +std::string formatNodeLabel(::chat::NodeId node_id) { if (node_id == 0) { - ui::copyText(out, "Unknown"); - return; + return std::string("Unknown"); } char buffer[16]{}; std::snprintf(buffer, sizeof(buffer), "%04lX", static_cast(node_id & 0xFFFFU)); - ui::copyText(out, buffer); + return std::string(buffer); +} + +void copyNodeLabel(ui::FixedText<32>& out, ::chat::NodeId node_id) +{ + const std::string label = formatNodeLabel(node_id); + ui::copyText(out, label.c_str()); +} + +std::string contactDisplayName(const ::chat::contacts::ContactService* contacts, + ::chat::NodeId node_id) +{ + if (node_id == 0) + { + return std::string("Unknown"); + } + + if (contacts != nullptr) + { + const auto* node = contacts->getNodeInfo(node_id); + if (node != nullptr) + { + if (!node->display_name.empty()) + { + return node->display_name; + } + if (node->long_name[0] != '\0') + { + return std::string(node->long_name); + } + if (node->short_name[0] != '\0') + { + return std::string(node->short_name); + } + } + + const std::string contact = contacts->getContactName(node_id); + if (!contact.empty()) + { + return contact; + } + } + + return formatNodeLabel(node_id); +} + +std::string conversationTitle(const ::chat::ConversationMeta& meta, + const ::chat::contacts::ContactService* contacts) +{ + if (meta.id.peer != 0) + { + return contactDisplayName(contacts, meta.id.peer); + } + + ::chat::ReticulumPeerIdentity identity = meta.reticulum_identity; + if (!::chat::hasReticulumDestinationIdentity(identity)) + { + identity = meta.id.reticulum_identity; + } + if (contacts != nullptr && + ::chat::hasReticulumDestinationIdentity(identity)) + { + ::chat::NodeId node_id = 0; + if (contacts->findNodeIdByReticulumDestinationHash( + identity.destination_hash, + &node_id)) + { + return contactDisplayName(contacts, node_id); + } + } + + if (!meta.name.empty()) + { + return meta.name; + } + return formatNodeLabel(meta.id.peer); } bool isValidCoordinate(double lat, double lon) @@ -57,12 +132,9 @@ void copyParticipantLabel(ui::FixedText<32>& out, } if (contacts != nullptr) { - const std::string contact = contacts->getContactName(node_id); - if (!contact.empty()) - { - ui::copyText(out, contact.c_str()); - return; - } + const std::string contact = contactDisplayName(contacts, node_id); + ui::copyText(out, contact.c_str()); + return; } copyNodeLabel(out, node_id); } @@ -336,7 +408,7 @@ bool ChatPresentationSource::buildChatWorkspaceSnapshot( row.unread_count = meta.unread < 0 ? 0U : static_cast(meta.unread); row.last_timestamp = meta.last_timestamp; row.selected = row.id == request.selected; - copyString(row.title, meta.name); + copyString(row.title, conversationTitle(meta, contact_service_)); copyString(row.subtitle, meta.preview); } @@ -401,15 +473,8 @@ bool ChatPresentationSource::buildChatWorkspaceSnapshot( else if (contact_service_ != nullptr) { const std::string contact = - contact_service_->getContactName(message.from); - if (!contact.empty()) - { - ui::copyText(row.sender_label, contact.c_str()); - } - else - { - copyNodeLabel(row.sender_label, message.from); - } + contactDisplayName(contact_service_, message.from); + ui::copyText(row.sender_label, contact.c_str()); } else { diff --git a/modules/ui_shared/src/ui/screens/chat/chat_message_list_components.cpp b/modules/ui_shared/src/ui/screens/chat/chat_message_list_components.cpp index 37389da6..1f561f9d 100644 --- a/modules/ui_shared/src/ui/screens/chat/chat_message_list_components.cpp +++ b/modules/ui_shared/src/ui/screens/chat/chat_message_list_components.cpp @@ -16,8 +16,10 @@ #include "ui/ui_common.h" #include +#include #include #include +#include #ifndef CHAT_MESSAGE_LIST_LOG_ENABLE #define CHAT_MESSAGE_LIST_LOG_ENABLE 1 @@ -104,6 +106,57 @@ static bool conversation_identity_list_equal(const std::vector text_len) + { + return false; + } + for (std::size_t start = 0; start + query_len <= text_len; ++start) + { + bool match = true; + for (std::size_t offset = 0; offset < query_len; ++offset) + { + const auto lhs = static_cast(text[start + offset]); + const auto rhs = static_cast(query[offset]); + if (std::tolower(lhs) != std::tolower(rhs)) + { + match = false; + break; + } + } + if (match) + { + return true; + } + } + return false; +} + +static bool is_search_shortcut_key(uint32_t key) +{ + return key == '/' || key == 's' || key == 'S'; +} + +static bool is_filter_toggle_shortcut_key(uint32_t key) +{ + return key == 'f' || key == 'F'; +} + static const char* touch_event_name(lv_event_code_t code) { switch (code) @@ -284,10 +337,12 @@ ChatMessageListScreen::ChatMessageListScreen(lv_obj_t* parent) if (container_) { lv_obj_add_event_cb(container_, on_root_deleted, LV_EVENT_DELETE, this); + lv_obj_add_event_cb(container_, page_shortcut_cb, LV_EVENT_KEY, this); lv_obj_add_event_cb(container_, debug_touch_event_cb, LV_EVENT_PRESSED, this); lv_obj_add_event_cb(container_, debug_touch_event_cb, LV_EVENT_CLICKED, this); if (list_panel_) { + lv_obj_add_event_cb(list_panel_, page_shortcut_cb, LV_EVENT_KEY, this); lv_obj_add_event_cb(list_panel_, debug_touch_event_cb, LV_EVENT_PRESSED, this); lv_obj_add_event_cb(list_panel_, debug_touch_event_cb, LV_EVENT_CLICKED, this); } @@ -302,6 +357,7 @@ ChatMessageListScreen::ChatMessageListScreen(lv_obj_t* parent) lv_obj_add_event_cb(direct_btn_, filter_focus_cb, LV_EVENT_FOCUSED, this); } lv_obj_add_event_cb(direct_btn_, filter_click_cb, LV_EVENT_CLICKED, this); + lv_obj_add_event_cb(direct_btn_, page_shortcut_cb, LV_EVENT_KEY, this); lv_obj_add_event_cb(direct_btn_, debug_touch_event_cb, LV_EVENT_PRESSED, this); lv_obj_add_event_cb(direct_btn_, debug_touch_event_cb, LV_EVENT_CLICKED, this); lv_obj_add_event_cb(direct_btn_, debug_touch_event_cb, LV_EVENT_FOCUSED, this); @@ -314,6 +370,7 @@ ChatMessageListScreen::ChatMessageListScreen(lv_obj_t* parent) lv_obj_add_event_cb(broadcast_btn_, filter_focus_cb, LV_EVENT_FOCUSED, this); } lv_obj_add_event_cb(broadcast_btn_, filter_click_cb, LV_EVENT_CLICKED, this); + lv_obj_add_event_cb(broadcast_btn_, page_shortcut_cb, LV_EVENT_KEY, this); lv_obj_add_event_cb(broadcast_btn_, debug_touch_event_cb, LV_EVENT_PRESSED, this); lv_obj_add_event_cb(broadcast_btn_, debug_touch_event_cb, LV_EVENT_CLICKED, this); lv_obj_add_event_cb(broadcast_btn_, debug_touch_event_cb, LV_EVENT_FOCUSED, this); @@ -326,12 +383,14 @@ ChatMessageListScreen::ChatMessageListScreen(lv_obj_t* parent) lv_obj_add_event_cb(team_btn_, filter_focus_cb, LV_EVENT_FOCUSED, this); } lv_obj_add_event_cb(team_btn_, filter_click_cb, LV_EVENT_CLICKED, this); + lv_obj_add_event_cb(team_btn_, page_shortcut_cb, LV_EVENT_KEY, this); lv_obj_add_event_cb(team_btn_, debug_touch_event_cb, LV_EVENT_PRESSED, this); lv_obj_add_event_cb(team_btn_, debug_touch_event_cb, LV_EVENT_CLICKED, this); lv_obj_add_event_cb(team_btn_, debug_touch_event_cb, LV_EVENT_FOCUSED, this); lv_obj_add_event_cb(team_btn_, debug_touch_event_cb, LV_EVENT_DEFOCUSED, this); } updateFilterHighlight(); + applyFilterPanelVisibility(); disable_touch_click_focus_recursive(container_); if (container_ && !lv_obj_is_valid(container_)) @@ -370,6 +429,7 @@ ChatMessageListScreen::ChatMessageListScreen(lv_obj_t* parent) ChatMessageListScreen::~ChatMessageListScreen() { + ::ui::components::floating_search_box::close(search_box_); if (container_ && lv_obj_is_valid(container_)) { lv_obj_del(container_); @@ -550,6 +610,161 @@ void ChatMessageListScreen::updateBatteryFromBoard() ui_update_top_bar_battery(top_bar_); } +bool ChatMessageListScreen::searchActive() const +{ + return search_query_[0] != '\0'; +} + +bool ChatMessageListScreen::conversationMatchesSearch( + const chat::ConversationMeta& conv) const +{ + if (!searchActive()) + { + return true; + } + + char peer_hex[16] = {}; + std::snprintf(peer_hex, + sizeof(peer_hex), + "%08lX", + static_cast(conv.id.peer)); + return contains_ci(conv.name.c_str(), search_query_) || + contains_ci(peer_hex, search_query_); +} + +void ChatMessageListScreen::buildFilteredConversations( + std::vector& out) const +{ + out.clear(); + out.reserve(convs_.size()); + for (const auto& conv : convs_) + { + bool mode_match = false; + if (is_team_conversation(conv.id)) + { + mode_match = filter_mode_ == FilterMode::Team; + } + else if (filter_mode_ == FilterMode::Direct && conv.id.peer != 0) + { + mode_match = true; + } + else if (filter_mode_ == FilterMode::Broadcast && conv.id.peer == 0) + { + mode_match = true; + } + + if (mode_match && conversationMatchesSearch(conv)) + { + out.push_back(conv); + } + } +} + +void ChatMessageListScreen::applyFilterPanelVisibility() +{ + if (!filter_panel_) + { + return; + } + if (filter_panel_visible_) + { + lv_obj_clear_flag(filter_panel_, LV_OBJ_FLAG_HIDDEN); + } + else + { + lv_obj_add_flag(filter_panel_, LV_OBJ_FLAG_HIDDEN); + } + if (use_group_navigation()) + { + chat::ui::message_list::input::on_ui_refreshed(&input_controller_); + } +} + +void ChatMessageListScreen::toggleFilterPanel() +{ + if (!guard_ || !guard_->alive) + { + return; + } + filter_panel_visible_ = !filter_panel_visible_; + applyFilterPanelVisibility(); + if (use_group_navigation()) + { + chat::ui::message_list::input::focus_list(&input_controller_); + } +} + +void ChatMessageListScreen::search_apply_cb(const char* text, void* user_data) +{ + auto* screen = static_cast(user_data); + if (!screen || !screen->guard_ || !screen->guard_->alive) + { + return; + } + std::snprintf(screen->search_query_, + sizeof(screen->search_query_), + "%s", + text ? text : ""); + screen->selected_index_ = -1; + screen->rebuildList(); + if (use_group_navigation()) + { + chat::ui::message_list::input::focus_list(&screen->input_controller_); + } +} + +void ChatMessageListScreen::search_clear_cb(void* user_data) +{ + auto* screen = static_cast(user_data); + if (!screen || !screen->guard_ || !screen->guard_->alive) + { + return; + } + screen->search_query_[0] = '\0'; + screen->selected_index_ = -1; + screen->rebuildList(); + if (use_group_navigation()) + { + chat::ui::message_list::input::focus_list(&screen->input_controller_); + } +} + +void ChatMessageListScreen::search_cancel_cb(void* user_data) +{ + auto* screen = static_cast(user_data); + if (!screen || !screen->guard_ || !screen->guard_->alive) + { + return; + } + if (use_group_navigation()) + { + chat::ui::message_list::input::focus_list(&screen->input_controller_); + } +} + +void ChatMessageListScreen::openSearchModal() +{ + if (::ui::components::floating_search_box::is_open(search_box_)) + { + ::ui::components::floating_search_box::focus(search_box_); + return; + } + + ::ui::components::floating_search_box::Config config{}; + config.title = "Search contacts"; + config.initial_text = search_query_; + config.max_length = sizeof(search_query_) - 1U; + config.restore_group = input_controller_.group(); + config.callbacks.apply = search_apply_cb; + config.callbacks.clear = search_clear_cb; + config.callbacks.cancel = search_cancel_cb; + config.callbacks.user_data = this; + (void)::ui::components::floating_search_box::open( + search_box_, + container_ ? container_ : lv_screen_active(), + config); +} + // ------------------------------------------------ // Core logic: rebuild list (behavior unchanged) // ------------------------------------------------ @@ -575,26 +790,7 @@ void ChatMessageListScreen::rebuildList() selected_index_ = -1; std::vector filtered; - filtered.reserve(convs_.size()); - for (const auto& conv : convs_) - { - if (is_team_conversation(conv.id)) - { - if (filter_mode_ == FilterMode::Team) - { - filtered.push_back(conv); - } - continue; - } - if (filter_mode_ == FilterMode::Direct && conv.id.peer != 0) - { - filtered.push_back(conv); - } - else if (filter_mode_ == FilterMode::Broadcast && conv.id.peer == 0) - { - filtered.push_back(conv); - } - } + buildFilteredConversations(filtered); for (const auto& conv : filtered) { @@ -632,6 +828,7 @@ void ChatMessageListScreen::rebuildList() // ----- Events (unchanged) ----- lv_obj_add_event_cb(item.btn, item_event_cb, LV_EVENT_CLICKED, this); + lv_obj_add_event_cb(item.btn, page_shortcut_cb, LV_EVENT_KEY, this); if (use_group_navigation()) { lv_obj_add_event_cb(item.btn, item_focused_cb, LV_EVENT_FOCUSED, this); @@ -657,7 +854,7 @@ void ChatMessageListScreen::rebuildList() { lv_obj_t* placeholder = chat::ui::layout::create_placeholder(list_panel_); chat::ui::message_list::styles::apply_label_placeholder(placeholder); - ::ui::i18n::set_label_text(placeholder, "No messages"); + ::ui::i18n::set_label_text(placeholder, searchActive() ? "No matches" : "No messages"); ::ui::fonts::apply_localized_font(placeholder, lv_label_get_text(placeholder), ::ui::fonts::ui_chrome_font()); } @@ -689,6 +886,7 @@ void ChatMessageListScreen::rebuildList() chat::ui::message_list::styles::apply_label_name(back_label); lv_obj_center(back_label); lv_obj_add_event_cb(list_back_btn_, list_back_event_cb, LV_EVENT_CLICKED, this); + lv_obj_add_event_cb(list_back_btn_, page_shortcut_cb, LV_EVENT_KEY, this); if (use_group_navigation()) { lv_obj_add_event_cb(list_back_btn_, item_focused_cb, LV_EVENT_FOCUSED, this); @@ -729,26 +927,7 @@ bool ChatMessageListScreen::updateListInPlace(const std::vector filtered; - filtered.reserve(convs.size()); - for (const auto& conv : convs) - { - if (is_team_conversation(conv.id)) - { - if (filter_mode_ == FilterMode::Team) - { - filtered.push_back(conv); - } - continue; - } - if (filter_mode_ == FilterMode::Direct && conv.id.peer != 0) - { - filtered.push_back(conv); - } - else if (filter_mode_ == FilterMode::Broadcast && conv.id.peer == 0) - { - filtered.push_back(conv); - } - } + buildFilteredConversations(filtered); if (filtered.size() != items_.size()) { @@ -909,6 +1088,34 @@ void ChatMessageListScreen::filter_click_cb(lv_event_t* e) } } +void ChatMessageListScreen::page_shortcut_cb(lv_event_t* e) +{ + auto* screen = static_cast(lv_event_get_user_data(e)); + if (!screen || !screen->guard_ || !screen->guard_->alive || + lv_event_get_code(e) != LV_EVENT_KEY) + { + return; + } + if (::ui::components::floating_search_box::is_open(screen->search_box_)) + { + return; + } + + const uint32_t key = lv_event_get_key(e); + if (is_search_shortcut_key(key)) + { + screen->openSearchModal(); + lv_event_stop_processing(e); + return; + } + if (is_filter_toggle_shortcut_key(key)) + { + screen->toggleFilterPanel(); + lv_event_stop_processing(e); + return; + } +} + void ChatMessageListScreen::debug_touch_event_cb(lv_event_t* e) { auto* screen = static_cast(lv_event_get_user_data(e)); @@ -1075,6 +1282,7 @@ void ChatMessageListScreen::handle_root_deleted() action_cb_ = nullptr; action_cb_user_data_ = nullptr; + ::ui::components::floating_search_box::close(search_box_); if (use_group_navigation()) { chat::ui::message_list::input::cleanup(&input_controller_); diff --git a/modules/ui_shared/src/ui/screens/chat/chat_message_list_input.cpp b/modules/ui_shared/src/ui/screens/chat/chat_message_list_input.cpp index 5e208a8c..f1f3be1b 100644 --- a/modules/ui_shared/src/ui/screens/chat/chat_message_list_input.cpp +++ b/modules/ui_shared/src/ui/screens/chat/chat_message_list_input.cpp @@ -35,8 +35,13 @@ static lv_obj_t* get_top_back_button(void* ctx) return screen ? screen->getBackButton() : nullptr; } -static size_t get_filter_count(void* /*ctx*/) +static size_t get_filter_count(void* ctx) { + auto* screen = static_cast(ctx); + if (!screen || !screen->isFilterPanelVisible()) + { + return 0; + } return 3; } @@ -60,7 +65,7 @@ static lv_obj_t* get_filter_button(void* ctx, size_t index) static int get_preferred_filter_index(void* ctx) { auto* screen = static_cast(ctx); - if (!screen) return -1; + if (!screen || !screen->isFilterPanelVisible()) return -1; if (lv_obj_t* btn = screen->getDirectButton()) { if (lv_obj_has_state(btn, LV_STATE_CHECKED)) return 0; diff --git a/modules/ui_shared/src/ui/screens/chat/chat_ui_controller.cpp b/modules/ui_shared/src/ui/screens/chat/chat_ui_controller.cpp index f67fb283..05014b5d 100644 --- a/modules/ui_shared/src/ui/screens/chat/chat_ui_controller.cpp +++ b/modules/ui_shared/src/ui/screens/chat/chat_ui_controller.cpp @@ -931,7 +931,10 @@ void UiController::normalizeConversationNames(std::vector(identity.destination_hash[0]), + static_cast(identity.destination_hash[1]), + static_cast(identity.destination_hash[2]), + static_cast(identity.destination_hash[3])); +} + +static void make_lxmf_short_name(const chat::ReticulumPeerIdentity& identity, + uint32_t node_id, + char* out, + size_t out_len) +{ + if (!out || out_len == 0) + { + return; + } + if (chat::hasReticulumDestinationIdentity(identity) && out_len >= 9) + { + std::snprintf(out, + out_len, + "%02X%02X%02X%02X", + static_cast(identity.destination_hash[0]), + static_cast(identity.destination_hash[1]), + static_cast(identity.destination_hash[2]), + static_cast(identity.destination_hash[3])); + return; + } + std::snprintf(out, out_len, "%04X", static_cast(node_id & 0xFFFF)); +} + +static bool existing_contact_matches(uint32_t node_id) +{ + for (const auto& node : g_contacts_state.contacts_list) + { + if (node.node_id == node_id && node.is_contact) + { + return true; + } + } + return false; +} + static bool contains_ci(const char* text, const char* query) { if (!query || query[0] == '\0') @@ -998,6 +1094,16 @@ static bool is_search_shortcut_key(uint32_t key) return key == '/' || key == 's' || key == 'S'; } +static bool is_add_lxmf_shortcut_key(uint32_t key) +{ + return key == 'a' || key == 'A'; +} + +static bool is_filter_toggle_shortcut_key(uint32_t key) +{ + return key == 'f' || key == 'F'; +} + static bool is_help_shortcut_key(uint32_t key) { return key == 'h' || key == 'H'; @@ -1011,12 +1117,37 @@ static void bind_page_shortcuts(lv_obj_t* obj) } } +static void apply_filter_panel_visibility() +{ + if (!g_contacts_state.filter_panel) + { + return; + } + if (g_contacts_state.filter_panel_visible) + { + lv_obj_clear_flag(g_contacts_state.filter_panel, LV_OBJ_FLAG_HIDDEN); + } + else + { + lv_obj_add_flag(g_contacts_state.filter_panel, LV_OBJ_FLAG_HIDDEN); + } + contacts_input_on_ui_refreshed(); +} + +static void toggle_filter_panel_visibility() +{ + g_contacts_state.filter_panel_visible = !g_contacts_state.filter_panel_visible; + apply_filter_panel_visibility(); + contacts_focus_to_list(); +} + // ---------------- Panel creation (public API) ---------------- void create_filter_panel(lv_obj_t* parent) { // Structure + styles handled in layout/styles contacts::ui::layout::create_filter_panel(parent); + g_contacts_state.filter_panel_visible = true; // Bind events: // - Rotate in Filter column triggers FOCUSED -> switch mode + refresh @@ -1071,6 +1202,7 @@ void create_filter_panel(lv_obj_t* parent) // Keep highlight consistent with mode using CHECKED state // (visual-only; does not change behavior) refresh_filter_checked_state(); + apply_filter_panel_visibility(); } // ---------------- Filter handlers (unchanged behavior) ---------------- @@ -1403,6 +1535,7 @@ static void modal_close(lv_obj_t*& modal_obj) static bool is_any_modal_open() { return ::ui::components::floating_search_box::is_open(g_contacts_state.search_box) || + ::ui::components::floating_search_box::is_open(g_contacts_state.lxmf_address_box) || g_contacts_state.add_edit_modal != nullptr || g_contacts_state.reticulum_group_modal != nullptr || g_contacts_state.del_confirm_modal != nullptr || @@ -1468,6 +1601,136 @@ static void open_search_modal() config); } +static void on_lxmf_address_apply(const char* text, void* /*user_data*/) +{ + if (!g_contacts_state.contact_service) + { + ::ui::feedback::show_notice("Contacts unavailable", 1800); + contacts_focus_to_list(); + return; + } + + chat::ReticulumPeerIdentity identity{}; + char error[64] = {}; + const char* address_text = skip_lxmf_prefix(text); + if (!chat::parseReticulumDestinationHashText(address_text, + &identity, + error, + sizeof(error))) + { + ::ui::feedback::show_notice(error[0] != '\0' ? error : "Invalid LXMF address", + 2200); + contacts_focus_to_list(); + return; + } + + uint32_t node_id = 0; + if (!g_contacts_state.contact_service->findNodeIdByReticulumDestinationHash( + identity.destination_hash, + &node_id)) + { + node_id = chat::reticulum::nodeIdFromDestinationHash(identity.destination_hash); + } + + const chat::contacts::NodeInfo* existing_node = find_node_by_id(node_id); + char short_name[10] = {}; + char nickname[13] = {}; + char generated_nickname[13] = {}; + make_lxmf_short_name(identity, node_id, short_name, sizeof(short_name)); + make_lxmf_contact_nickname(identity, generated_nickname, sizeof(generated_nickname)); + std::snprintf(nickname, sizeof(nickname), "%s", generated_nickname); + if (existing_node) + { + const std::string existing_name = node_display_name_for_contacts(*existing_node); + if (!existing_name.empty() && existing_name.size() <= 12U) + { + std::snprintf(nickname, sizeof(nickname), "%s", existing_name.c_str()); + } + } + + chat::contacts::NodeUpdate update{}; + update.short_name = short_name; + char fallback_long_name[13] = {}; + if (!existing_node || existing_node->long_name[0] == '\0') + { + std::snprintf(fallback_long_name, sizeof(fallback_long_name), "%s", nickname); + update.long_name = fallback_long_name; + } + update.has_last_seen = true; + update.last_seen = current_timestamp_seconds(); + update.has_protocol = true; + update.protocol = static_cast(chat::contacts::NodeProtocolType::Reticulum); + update.has_role = true; + update.role = static_cast(chat::contacts::NodeRoleType::Client); + update.reticulum_identity = identity; + g_contacts_state.contact_service->applyNodeUpdate(node_id, update); + + refresh_contacts_data(); + const bool was_contact = existing_contact_matches(node_id); + bool added = was_contact || g_contacts_state.contact_service->addContact(node_id, nickname); + if (!added && std::strcmp(nickname, generated_nickname) != 0) + { + added = g_contacts_state.contact_service->addContact(node_id, generated_nickname); + } + if (!added) + { + ::ui::feedback::show_notice("Contact save failed", 2200); + contacts_focus_to_list(); + return; + } + + clear_search_query(); + g_contacts_state.current_mode = ContactsMode::Contacts; + g_contacts_state.current_page = 0; + g_contacts_state.selected_index = -1; + refresh_contacts_data(); + refresh_ui(); + ::ui::feedback::show_notice("Contact added", 1400); + contacts_focus_to_list(); +} + +static void on_lxmf_address_clear(void* /*user_data*/) +{ + contacts_focus_to_list(); +} + +static void on_lxmf_address_cancel(void* /*user_data*/) +{ + contacts_focus_to_list(); +} + +static void open_lxmf_address_modal() +{ + if (::ui::components::floating_search_box::is_open(g_contacts_state.lxmf_address_box)) + { + ::ui::components::floating_search_box::focus(g_contacts_state.lxmf_address_box); + return; + } + if (!uses_reticulum_filter_profile()) + { + ::ui::feedback::show_notice("LXMF address is Reticulum only", 1800); + return; + } + if (is_any_modal_open()) + { + return; + } + + ::ui::components::floating_search_box::Config config{}; + config.title = "Add LXMF Address"; + config.initial_text = ""; + config.accepted_chars = kLxmfAddressAcceptedChars; + config.max_length = chat::kReticulumPeerHashSize * 2U + 5U; + config.restore_group = contacts_input_get_group(); + config.callbacks.apply = on_lxmf_address_apply; + config.callbacks.clear = on_lxmf_address_clear; + config.callbacks.cancel = on_lxmf_address_cancel; + (void)::ui::components::floating_search_box::open( + g_contacts_state.lxmf_address_box, + g_contacts_state.root ? g_contacts_state.root : lv_screen_active(), + config); +} + static void contacts_handle_page_shortcut(lv_event_t* event) { if (!event || lv_event_get_code(event) != LV_EVENT_KEY) @@ -1481,6 +1744,18 @@ static void contacts_handle_page_shortcut(lv_event_t* event) lv_event_stop_processing(event); return; } + if (is_add_lxmf_shortcut_key(key)) + { + open_lxmf_address_modal(); + lv_event_stop_processing(event); + return; + } + if (is_filter_toggle_shortcut_key(key)) + { + toggle_filter_panel_visibility(); + lv_event_stop_processing(event); + return; + } if (is_help_shortcut_key(key)) { open_contacts_help_modal(); @@ -1506,18 +1781,20 @@ static void open_contacts_help_modal() return; } - ::ui::components::shortcut_help_modal::Row rows[5] = { - {"S", "/", "Search names"}, - {"Enter", nullptr, "Open selected item"}, - {"Back", nullptr, "Return or close"}, - {"H", nullptr, "Close help"}, - {"Groups", nullptr, "Add opens group config"}, - }; - std::size_t row_count = 4; - + ::ui::components::shortcut_help_modal::Row rows[7] = {}; + std::size_t row_count = 0; + rows[row_count++] = {"S", "/", "Search names"}; if (uses_reticulum_filter_profile()) { - row_count = 5; + rows[row_count++] = {"A", nullptr, "Add LXMF address"}; + } + rows[row_count++] = {"F", nullptr, "Show or hide filters"}; + rows[row_count++] = {"Enter", nullptr, "Open selected item"}; + rows[row_count++] = {"Back", nullptr, "Return or close"}; + rows[row_count++] = {"H", nullptr, "Close help"}; + if (uses_reticulum_filter_profile()) + { + rows[row_count++] = {"Groups", nullptr, "Add opens group config"}; } ::ui::components::shortcut_help_modal::Config config{}; @@ -4073,6 +4350,7 @@ void refresh_ui() void cleanup_modals() { ::ui::components::floating_search_box::close(g_contacts_state.search_box); + ::ui::components::floating_search_box::close(g_contacts_state.lxmf_address_box); if (g_contacts_state.empty_label != nullptr) { if (lv_obj_is_valid(g_contacts_state.empty_label)) diff --git a/modules/ui_shared/src/ui/screens/contacts/contacts_page_input.cpp b/modules/ui_shared/src/ui/screens/contacts/contacts_page_input.cpp index 1e11ba26..5aab9f5c 100644 --- a/modules/ui_shared/src/ui/screens/contacts/contacts_page_input.cpp +++ b/modules/ui_shared/src/ui/screens/contacts/contacts_page_input.cpp @@ -74,6 +74,10 @@ static lv_obj_t* get_top_back_button(void* /*ctx*/) static size_t get_filter_count(void* /*ctx*/) { + if (!contacts::ui::g_contacts_state.filter_panel_visible) + { + return 0; + } return contacts::ui::uses_reticulum_filter_profile() ? 4U : 6U; } @@ -118,6 +122,10 @@ static lv_obj_t* get_filter_button(void* /*ctx*/, size_t index) static int get_preferred_filter_index(void* /*ctx*/) { + if (!contacts::ui::g_contacts_state.filter_panel_visible) + { + return -1; + } return mode_to_index(contacts::ui::g_contacts_state.current_mode); } diff --git a/modules/ui_shared/src/ui/screens/contacts/contacts_page_runtime.cpp b/modules/ui_shared/src/ui/screens/contacts/contacts_page_runtime.cpp index bbc96e91..0ceb609a 100644 --- a/modules/ui_shared/src/ui/screens/contacts/contacts_page_runtime.cpp +++ b/modules/ui_shared/src/ui/screens/contacts/contacts_page_runtime.cpp @@ -95,10 +95,6 @@ void contacts_top_bar_back(void*) lv_timer_del(g_contacts_state.discover_scan_timer); g_contacts_state.discover_scan_timer = nullptr; } - if (g_contacts_state.root) - { - lv_obj_add_flag(g_contacts_state.root, LV_OBJ_FLAG_HIDDEN); - } request_exit(); } diff --git a/modules/ui_shared/src/ui/startup_shell.cpp b/modules/ui_shared/src/ui/startup_shell.cpp index 57eee955..07a0e45e 100644 --- a/modules/ui_shared/src/ui/startup_shell.cpp +++ b/modules/ui_shared/src/ui/startup_shell.cpp @@ -9,6 +9,7 @@ #include "platform/ui/time_runtime.h" #include "sys/clock.h" #include "ui/app_runtime.h" +#include "ui/components/screen_saver_overlay.h" #include "ui/localization.h" #include "ui/menu/menu_layout.h" #include "ui/menu/menu_runtime.h" @@ -21,18 +22,17 @@ namespace ui::startup_shell { + +bool format_menu_time(char* out, size_t out_len); + namespace { ui::menu::MenuModel s_ux_menu_model; #ifndef TRAIL_MATE_BOOT_UI_SYNC_PRESENT -#if defined(ARDUINO) -#define TRAIL_MATE_BOOT_UI_SYNC_PRESENT 0 -#else #define TRAIL_MATE_BOOT_UI_SYNC_PRESENT 1 #endif -#endif #if TRAIL_MATE_BOOT_UI_SYNC_PRESENT constexpr uint8_t kBootPresentFrameCount = 4; @@ -92,6 +92,19 @@ void present_boot_overlay_now() #endif } +void present_screen_saver_now() +{ + ui::components::screen_saver_overlay::present_now(); +} + +void initScreenSaverOverlay() +{ + ui::components::screen_saver_overlay::Hooks overlay_hooks{}; + overlay_hooks.format_time = format_menu_time; + overlay_hooks.read_unread_count = ui::status::get_total_unread; + ui::components::screen_saver_overlay::init(overlay_hooks); +} + } // namespace bool format_menu_time(char* out, size_t out_len) @@ -165,7 +178,10 @@ platform::ui::screen::Hooks buildScreenSleepHooks(const Hooks& hooks) runtime_hooks.format_time = format_menu_time; runtime_hooks.read_unread_count = ui::status::get_total_unread; runtime_hooks.show_main_menu = hooks.show_main_menu; - runtime_hooks.on_wake_from_sleep = ui::menu_runtime::onWakeFromSleep; + runtime_hooks.on_wake_from_sleep = ui::components::screen_saver_overlay::show; + runtime_hooks.show_screen_saver = ui::components::screen_saver_overlay::show; + runtime_hooks.hide_screen_saver = ui::components::screen_saver_overlay::hide; + runtime_hooks.present_screen_saver = present_screen_saver_now; return runtime_hooks; } @@ -213,6 +229,7 @@ void initializeShell(const Hooks& hooks) ui::menu_runtime::init( lv_screen_active(), main_screen, ui::menu_layout::menuPanel(), buildMenuRuntimeHooks(hooks)); + initScreenSaverOverlay(); if (hooks.set_max_brightness) { diff --git a/modules/ui_shared/src/ui/startup_ui_shell.cpp b/modules/ui_shared/src/ui/startup_ui_shell.cpp index 1da88b2a..84320e1c 100644 --- a/modules/ui_shared/src/ui/startup_ui_shell.cpp +++ b/modules/ui_shared/src/ui/startup_ui_shell.cpp @@ -8,11 +8,13 @@ #include "platform/ui/time_runtime.h" #include "sys/clock.h" #include "ui/app_runtime.h" +#include "ui/components/screen_saver_overlay.h" #include "ui/localization.h" #include "ui/menu/menu_layout.h" #include "ui/menu/menu_runtime.h" #include "ui/runtime/ui_feedback.h" #include "ui/ui_boot.h" +#include "ui/ui_status.h" #include "ui_lvgl_ux_packs/ux/ux_menu_provider.h" namespace ui::startup_ui_shell @@ -24,12 +26,8 @@ bool s_shell_initialized = false; ui::menu::MenuModel s_ux_menu_model; #ifndef TRAIL_MATE_BOOT_UI_SYNC_PRESENT -#if defined(ARDUINO) -#define TRAIL_MATE_BOOT_UI_SYNC_PRESENT 0 -#else #define TRAIL_MATE_BOOT_UI_SYNC_PRESENT 1 #endif -#endif #if TRAIL_MATE_BOOT_UI_SYNC_PRESENT constexpr uint8_t kBootPresentFrameCount = 4; @@ -115,6 +113,14 @@ ui::menu_runtime::Hooks build_menu_runtime_hooks() return hooks; } +void init_screen_saver_overlay() +{ + ui::components::screen_saver_overlay::Hooks overlay_hooks{}; + overlay_hooks.format_time = format_menu_time; + overlay_hooks.read_unread_count = ui::status::get_total_unread; + ui::components::screen_saver_overlay::init(overlay_hooks); +} + bool lock_ui(const Hooks& hooks) { return hooks.lock_ui ? hooks.lock_ui(hooks.lock_timeout_ms) : true; @@ -184,6 +190,7 @@ bool initializeMenuSkeleton(const Hooks& hooks) ui::menu_layout::init(options); ui::menu_runtime::init( lv_screen_active(), main_screen, ui::menu_layout::menuPanel(), build_menu_runtime_hooks()); + init_screen_saver_overlay(); s_shell_initialized = true; unlock_ui(hooks); diff --git a/modules/ui_shared/tests/test_chat_presentation_source.cpp b/modules/ui_shared/tests/test_chat_presentation_source.cpp index 65b9a08e..b0a058a5 100644 --- a/modules/ui_shared/tests/test_chat_presentation_source.cpp +++ b/modules/ui_shared/tests/test_chat_presentation_source.cpp @@ -315,6 +315,17 @@ int main() ::chat::contacts::ContactService contacts(node_store, contact_store); setNodePosition(contacts, mesh.self_node_id, 312345678, 1219876543); setNodePosition(contacts, 1234, 313000000, 1220000000); + contacts.updateNodeInfo(1234, + "04D2", + "Ada Mesh", + 0.0f, + 0.0f, + 1700000000U, + 0, + ::chat::contacts::kNodeRoleUnknown, + 0xFF, + 0, + 0xFF); ui::presentation_sources::RuntimeChatActionSink sink(service); ui::presentation_sources::ChatPresentationSource source( @@ -362,7 +373,7 @@ int main() assert(snapshot.conversations[0].id == ada); assert(snapshot.conversations[0].selected); assert(snapshot.conversations[0].last_timestamp != 0); - assert(std::strcmp(snapshot.conversations[0].title.c_str(), "04D2") == 0); + assert(std::strcmp(snapshot.conversations[0].title.c_str(), "Ada Mesh") == 0); assert(snapshot.message_count == 1); assert(snapshot.messages[0].conversation == ada); assert(snapshot.messages[0].outgoing); @@ -431,6 +442,17 @@ int main() incoming.to = 0xFFFFFFFFUL; incoming.msg_id = 900; incoming.text = "broadcast hello"; + contacts.updateNodeInfo(0x648144D4, + "44D4", + "Mother", + 0.0f, + 0.0f, + 1700000000U, + 0, + ::chat::contacts::kNodeRoleUnknown, + 0xFF, + 0, + 0xFF); mesh.incoming.push_back(incoming); service.processIncoming(); @@ -442,7 +464,7 @@ int main() assert(snapshot.messages[0].conversation == broadcast); assert(!snapshot.messages[0].outgoing); assert(snapshot.messages[0].sender_node_id == 0x648144D4); - assert(std::strcmp(snapshot.messages[0].sender_label.c_str(), "44D4") == 0); + assert(std::strcmp(snapshot.messages[0].sender_label.c_str(), "Mother") == 0); service.setActiveProtocol(::chat::MeshProtocol::MeshCore); ::chat::MeshIncomingText unknown_meshcore_incoming{}; diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_adapter.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_adapter.h index 14d8e2c1..e398e204 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_adapter.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_adapter.h @@ -80,10 +80,12 @@ class LxmfAdapter : public IMeshAdapter static constexpr uint32_t kAnnounceIntervalMs = 120000; static constexpr uint32_t kInitialAnnounceDelayMs = 1500; + static constexpr uint32_t kPendingAnnounceRetryMs = 30000; static constexpr uint8_t kMaxIngressPacketsPerPoll = 2; static constexpr uint32_t kDiscoverySampleIntervalMs = 10000; static constexpr uint32_t kRxSummaryIntervalMs = 5000; static constexpr uint32_t kAnnounceRebroadcastIntervalMs = 60000; + static constexpr uint32_t kPeerProjectionScreenIntervalMs = 2000; static constexpr uint32_t kPeerProjectionSleepIntervalMs = 250; static constexpr std::size_t kPendingPeerProjectionDepth = 24; static constexpr uint32_t kPeerPersistSleepIntervalMs = 15000; @@ -130,6 +132,7 @@ class LxmfAdapter : public IMeshAdapter std::string user_long_name_; std::string user_short_name_; uint32_t last_announce_ms_ = 0; + uint32_t last_announce_attempt_ms_ = 0; uint32_t last_lora_discovery_sample_ms_ = 0; uint32_t last_wifi_discovery_sample_ms_ = 0; uint32_t last_rx_summary_ms_ = 0; @@ -171,6 +174,7 @@ class LxmfAdapter : public IMeshAdapter void maybeAnnounce(); bool sendAnnounce(LocalDestinationKind kind = LocalDestinationKind::Delivery, reticulum::PacketContext context = reticulum::PacketContext::None); + bool lastAnnounceTxReachedRequiredInterfaces(bool sent) const; bool handleAnnouncePacket(const uint8_t* raw_packet, size_t raw_len, const reticulum::ParsedPacket& packet, reticulum::interfaces::InterfaceKind ingress_interface, diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/reticulum/reticulum_interfaces.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/reticulum/reticulum_interfaces.h index a2eee3c6..7bc4239a 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/reticulum/reticulum_interfaces.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/reticulum/reticulum_interfaces.h @@ -38,6 +38,24 @@ struct RxPacket InterfaceKind interface_kind = InterfaceKind::LoRa; }; +struct TxResult +{ + bool lora_required = false; + bool lora_ready = false; + bool lora_ok = false; + bool wifi_required = false; + bool wifi_ready = false; + bool wifi_ok = false; + + bool sent() const { return lora_ok || wifi_ok; } + bool reachedRequiredInterfaces() const + { + return sent() && + (!lora_required || lora_ok) && + (!wifi_required || wifi_ok); + } +}; + class LoRaReticulumInterface { public: @@ -143,12 +161,14 @@ class ReticulumInterfaceSet float lastRxRssi() const; float lastRxSnr() const; const RxMeta& lastRxMeta() const { return last_rx_meta_; } + const TxResult& lastTxResult() const { return last_tx_result_; } private: LoRaReticulumInterface lora_; WifiGatewayReticulumInterface wifi_; MeshConfig config_{}; RxMeta last_rx_meta_{}; + TxResult last_tx_result_{}; bool has_last_rx_meta_ = false; uint8_t next_poll_index_ = 0; diff --git a/platform/esp/arduino_common/include/screen_sleep.h b/platform/esp/arduino_common/include/screen_sleep.h index 5f46f14a..a516e6d6 100644 --- a/platform/esp/arduino_common/include/screen_sleep.h +++ b/platform/esp/arduino_common/include/screen_sleep.h @@ -14,6 +14,9 @@ struct ScreenSleepHooks int (*read_unread_count)() = nullptr; void (*show_main_menu)() = nullptr; void (*on_wake_from_sleep)() = nullptr; + void (*show_screen_saver)() = nullptr; + void (*hide_screen_saver)() = nullptr; + void (*present_screen_saver)() = nullptr; }; /** Clamp timeout into the allowed persisted range. Values below the minimum fall back to the default. */ diff --git a/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_adapter.cpp b/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_adapter.cpp index 95280267..381b775e 100644 --- a/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_adapter.cpp +++ b/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_adapter.cpp @@ -1198,12 +1198,15 @@ bool LxmfAdapter::broadcastSelfIdentity() announce_pending_ = true; const bool delivery_ok = sendAnnounce(LocalDestinationKind::Delivery); + const bool delivery_complete = lastAnnounceTxReachedRequiredInterfaces(delivery_ok); const bool propagation_ok = sendAnnounce(LocalDestinationKind::Propagation); + const bool propagation_complete = lastAnnounceTxReachedRequiredInterfaces(propagation_ok); if (delivery_ok || propagation_ok) { last_announce_ms_ = millis(); } - announce_pending_ = !(delivery_ok && propagation_ok); + last_announce_attempt_ms_ = millis(); + announce_pending_ = !(delivery_complete && propagation_complete); return delivery_ok || propagation_ok; } @@ -1279,6 +1282,7 @@ void LxmfAdapter::applyConfig(const MeshConfig& config) static_cast(peers_.size())); } last_announce_ms_ = millis(); + last_announce_attempt_ms_ = 0; announce_pending_ = !config_.reticulum_anonymous_peer; } @@ -1286,6 +1290,7 @@ void LxmfAdapter::setUserInfo(const char* long_name, const char* short_name) { user_long_name_ = (long_name && long_name[0] != '\0') ? long_name : ""; user_short_name_ = (short_name && short_name[0] != '\0') ? short_name : ""; + last_announce_attempt_ms_ = 0; announce_pending_ = true; } @@ -1338,7 +1343,7 @@ LxmfAdapter::RuntimeBudget LxmfAdapter::makeRuntimeBudget() const budget.deferred_discovery_limit = 0; budget.allow_public_discovery = false; budget.allow_persistence = false; - budget.allow_peer_projection = false; + budget.allow_peer_projection = !screen_runtime::is_saver_active(); budget.allow_announce_tx = true; budget.phase = screen_runtime::is_saver_active() ? "saver" : "screen"; return budget; @@ -1618,24 +1623,39 @@ void LxmfAdapter::maybeAnnounce() if (config_.reticulum_anonymous_peer) { announce_pending_ = false; + last_announce_attempt_ms_ = 0; return; } - if (!announce_pending_ && (millis() - last_announce_ms_) < kAnnounceIntervalMs) + const uint32_t now_ms = millis(); + if (!announce_pending_ && (now_ms - last_announce_ms_) < kAnnounceIntervalMs) { return; } - if (announce_pending_ && (millis() - last_announce_ms_) < kInitialAnnounceDelayMs) + if (announce_pending_) { - return; + const bool first_attempt = last_announce_attempt_ms_ == 0; + const uint32_t wait_ms = first_attempt ? kInitialAnnounceDelayMs : kPendingAnnounceRetryMs; + const uint32_t basis_ms = first_attempt ? last_announce_ms_ : last_announce_attempt_ms_; + if ((now_ms - basis_ms) < wait_ms) + { + return; + } } const bool delivery_ok = sendAnnounce(LocalDestinationKind::Delivery); + const bool delivery_complete = lastAnnounceTxReachedRequiredInterfaces(delivery_ok); const bool propagation_ok = sendAnnounce(LocalDestinationKind::Propagation); + const bool propagation_complete = lastAnnounceTxReachedRequiredInterfaces(propagation_ok); + last_announce_attempt_ms_ = now_ms; if (delivery_ok || propagation_ok) { - last_announce_ms_ = millis(); + last_announce_ms_ = now_ms; + } + announce_pending_ = !(delivery_complete && propagation_complete); + if (!announce_pending_) + { + last_announce_attempt_ms_ = 0; } - announce_pending_ = !(delivery_ok && propagation_ok); } bool LxmfAdapter::sendAnnounce(LocalDestinationKind kind, @@ -1761,15 +1781,21 @@ bool LxmfAdapter::sendAnnounce(LocalDestinationKind kind, destination_hex, sizeof(destination_hex)); const bool sent = routeAndSendPacket(packet, packet_len, false); + const auto& tx_result = interfaces_.lastTxResult(); const char* display_name = effectiveDisplayName(); - Serial.printf("[LXMF][AnnounceTX] kind=%s context=%u dest=%s name=%s app_len=%u packet_len=%u ok=%u\n", + Serial.printf("[LXMF][AnnounceTX] kind=%s context=%u dest=%s name=%s app_len=%u packet_len=%u ok=%u complete=%u lora=%u/%u wifi=%u/%u\n", localDestinationKindLabel(kind), static_cast(context), destination_hex, (display_name && display_name[0] != '\0') ? display_name : "", static_cast(app_data_len), static_cast(packet_len), - sent ? 1U : 0U); + sent ? 1U : 0U, + tx_result.reachedRequiredInterfaces() ? 1U : 0U, + tx_result.lora_ok ? 1U : 0U, + tx_result.lora_required ? 1U : 0U, + tx_result.wifi_ok ? 1U : 0U, + tx_result.wifi_required ? 1U : 0U); if (!sent) { return false; @@ -1778,6 +1804,15 @@ bool LxmfAdapter::sendAnnounce(LocalDestinationKind kind, return true; } +bool LxmfAdapter::lastAnnounceTxReachedRequiredInterfaces(bool sent) const +{ + if (!sent) + { + return false; + } + return interfaces_.lastTxResult().reachedRequiredInterfaces(); +} + bool LxmfAdapter::handleAnnouncePacket(const uint8_t* raw_packet, size_t raw_len, const reticulum::ParsedPacket& packet, reticulum::interfaces::InterfaceKind ingress_interface, @@ -5584,12 +5619,11 @@ void LxmfAdapter::pumpPendingPeerUpdates() const uint32_t now_ms = millis(); const bool maintenance_window = screen_runtime::is_sleeping() && !screen_runtime::is_saver_active(); - if (!maintenance_window) - { - return; - } + const uint32_t interval_ms = maintenance_window + ? kPeerProjectionSleepIntervalMs + : kPeerProjectionScreenIntervalMs; if (last_peer_projection_ms_ != 0 && - (now_ms - last_peer_projection_ms_) < kPeerProjectionSleepIntervalMs) + (now_ms - last_peer_projection_ms_) < interval_ms) { return; } diff --git a/platform/esp/arduino_common/src/chat/infra/reticulum/reticulum_interfaces.cpp b/platform/esp/arduino_common/src/chat/infra/reticulum/reticulum_interfaces.cpp index 0554d544..df0e006e 100644 --- a/platform/esp/arduino_common/src/chat/infra/reticulum/reticulum_interfaces.cpp +++ b/platform/esp/arduino_common/src/chat/infra/reticulum/reticulum_interfaces.cpp @@ -671,6 +671,7 @@ bool ReticulumInterfaceSet::hasReadyInterface() const bool ReticulumInterfaceSet::sendPacket(const uint8_t* data, size_t len) { + last_tx_result_ = {}; if (!data || len == 0) { return false; @@ -678,22 +679,29 @@ bool ReticulumInterfaceSet::sendPacket(const uint8_t* data, size_t len) maintain(); - bool lora_ok = false; - bool wifi_ok = false; - if (loraAllowed() && lora_.isReady()) + last_tx_result_.lora_required = loraAllowed(); + last_tx_result_.lora_ready = last_tx_result_.lora_required && lora_.isReady(); + last_tx_result_.wifi_required = wifiAllowed() && wifi_.isConfigured(); + last_tx_result_.wifi_ready = last_tx_result_.wifi_required && wifi_.isReady(); + + if (last_tx_result_.lora_ready) { - lora_ok = lora_.sendPacket(data, len); + last_tx_result_.lora_ok = lora_.sendPacket(data, len); } - if (wifiAllowed() && wifi_.isReady()) + if (last_tx_result_.wifi_ready) { - wifi_ok = wifi_.sendPacket(data, len); + last_tx_result_.wifi_ok = wifi_.sendPacket(data, len); } - const bool sent = lora_ok || wifi_ok; - Serial.printf("[Reticulum][IF][TX] raw_len=%u lora=%u wifi=%u sent=%u\n", + const bool sent = last_tx_result_.sent(); + Serial.printf("[Reticulum][IF][TX] raw_len=%u lora_req=%u lora_ready=%u lora=%u wifi_req=%u wifi_ready=%u wifi=%u sent=%u\n", static_cast(len), - lora_ok ? 1U : 0U, - wifi_ok ? 1U : 0U, + last_tx_result_.lora_required ? 1U : 0U, + last_tx_result_.lora_ready ? 1U : 0U, + last_tx_result_.lora_ok ? 1U : 0U, + last_tx_result_.wifi_required ? 1U : 0U, + last_tx_result_.wifi_ready ? 1U : 0U, + last_tx_result_.wifi_ok ? 1U : 0U, sent ? 1U : 0U); return sent; } diff --git a/platform/esp/arduino_common/src/platform_ui_screen_runtime.cpp b/platform/esp/arduino_common/src/platform_ui_screen_runtime.cpp index 126ed400..4677be78 100644 --- a/platform/esp/arduino_common/src/platform_ui_screen_runtime.cpp +++ b/platform/esp/arduino_common/src/platform_ui_screen_runtime.cpp @@ -12,6 +12,9 @@ ScreenSleepHooks adapt_hooks(const platform::ui::screen::Hooks& hooks) adapted.read_unread_count = hooks.read_unread_count; adapted.show_main_menu = hooks.show_main_menu; adapted.on_wake_from_sleep = hooks.on_wake_from_sleep; + adapted.show_screen_saver = hooks.show_screen_saver; + adapted.hide_screen_saver = hooks.hide_screen_saver; + adapted.present_screen_saver = hooks.present_screen_saver; return adapted; } diff --git a/platform/esp/arduino_common/src/screen_sleep.cpp b/platform/esp/arduino_common/src/screen_sleep.cpp index e01f66b2..470766ef 100644 --- a/platform/esp/arduino_common/src/screen_sleep.cpp +++ b/platform/esp/arduino_common/src/screen_sleep.cpp @@ -5,9 +5,6 @@ #include "screen_sleep.h" -#include -#include - #include "board/BoardBase.h" #include "display/DisplayConfig.h" #include "freertos/FreeRTOS.h" @@ -15,7 +12,6 @@ #include "freertos/task.h" #include "lvgl.h" #include "platform/ui/settings_store.h" -#include "ui/localization.h" namespace { @@ -41,10 +37,6 @@ uint32_t s_screen_sleep_disable_depth = 0; uint8_t s_saved_screen_brightness = DEVICE_MAX_BRIGHTNESS_LEVEL; uint8_t s_saved_keyboard_brightness = 127; bool s_screen_saver_active = false; -lv_obj_t* s_screen_saver_layer = nullptr; -lv_obj_t* s_screen_saver_time_label = nullptr; -lv_obj_t* s_screen_saver_unread_label = nullptr; -lv_obj_t* s_screen_saver_hint_label = nullptr; lv_timer_t* s_screen_saver_timer = nullptr; uint32_t readPersistedScreenTimeoutMs() @@ -83,22 +75,36 @@ uint32_t cachedScreenTimeoutMs() return timeout_ms; } -bool formatScreenSaverTime(char* out, size_t out_len) +void screen_saver_timer_cb(lv_timer_t* timer); + +void show_screen_saver_layer() { - if (s_hooks.format_time) + if (s_hooks.show_screen_saver) { - return s_hooks.format_time(out, out_len); + s_hooks.show_screen_saver(); } - return false; } -int readUnreadCount() +void present_screen_saver_layer() { - if (s_hooks.read_unread_count) + if (s_hooks.present_screen_saver) { - return s_hooks.read_unread_count(); + s_hooks.present_screen_saver(); + } +} + +void restart_screen_saver_timer() +{ + if (s_screen_saver_timer == nullptr) + { + s_screen_saver_timer = lv_timer_create(screen_saver_timer_cb, kScreenSaverDurationMs, nullptr); + } + else + { + lv_timer_set_period(s_screen_saver_timer, kScreenSaverDurationMs); + lv_timer_reset(s_screen_saver_timer); + lv_timer_resume(s_screen_saver_timer); } - return 0; } void notifyWakeFromSleep() @@ -111,11 +117,7 @@ void notifyWakeFromSleep() void hide_screen_saver_layer() { - if (!s_screen_saver_layer) - { - return; - } - lv_obj_add_flag(s_screen_saver_layer, LV_OBJ_FLAG_HIDDEN); + if (s_hooks.hide_screen_saver) s_hooks.hide_screen_saver(); if (s_screen_saver_timer) { lv_timer_pause(s_screen_saver_timer); @@ -132,37 +134,6 @@ void refresh_active_screen() lv_obj_invalidate(active); } -void refresh_screen_saver_locale_labels(int unread) -{ - if (s_screen_saver_unread_label != nullptr) - { - ::ui::i18n::set_label_text_fmt(s_screen_saver_unread_label, "Unread: %d", unread); - } - - if (s_screen_saver_hint_label != nullptr) - { - ::ui::i18n::set_label_text(s_screen_saver_hint_label, "Press SPACE to resume"); - } -} - -void screen_saver_refresh() -{ - if (!s_screen_saver_layer || !s_screen_saver_time_label || !s_screen_saver_unread_label) - { - return; - } - - char time_buf[16] = "--:--"; - if (!formatScreenSaverTime(time_buf, sizeof(time_buf))) - { - snprintf(time_buf, sizeof(time_buf), "--:--"); - } - lv_label_set_text(s_screen_saver_time_label, time_buf); - - const int unread = readUnreadCount(); - refresh_screen_saver_locale_labels(unread); -} - void screen_saver_timer_cb(lv_timer_t* /*timer*/) { if (s_activity_mutex != nullptr) @@ -183,43 +154,6 @@ void screen_saver_timer_cb(lv_timer_t* /*timer*/) board.enterScreenSleep(); } -void init_screen_saver() -{ - if (s_screen_saver_layer) - { - return; - } - - s_screen_saver_layer = lv_obj_create(lv_screen_active()); - lv_obj_set_size(s_screen_saver_layer, LV_PCT(100), LV_PCT(100)); - lv_obj_align(s_screen_saver_layer, LV_ALIGN_CENTER, 0, 0); - lv_obj_set_style_bg_color(s_screen_saver_layer, lv_color_hex(0xF6E6C6), 0); - lv_obj_set_style_bg_opa(s_screen_saver_layer, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(s_screen_saver_layer, 0, 0); - lv_obj_clear_flag(s_screen_saver_layer, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_clear_flag(s_screen_saver_layer, LV_OBJ_FLAG_CLICKABLE); - - s_screen_saver_time_label = lv_label_create(s_screen_saver_layer); - lv_obj_set_style_text_color(s_screen_saver_time_label, lv_color_hex(0x6B4A1E), 0); - lv_obj_set_style_text_font(s_screen_saver_time_label, &lv_font_montserrat_36, 0); - lv_label_set_text(s_screen_saver_time_label, "--:--"); - lv_obj_align(s_screen_saver_time_label, LV_ALIGN_CENTER, 0, -26); - - s_screen_saver_unread_label = lv_label_create(s_screen_saver_layer); - lv_obj_set_style_text_color(s_screen_saver_unread_label, lv_color_hex(0x6B4A1E), 0); - lv_obj_set_style_text_font(s_screen_saver_unread_label, &lv_font_montserrat_20, 0); - lv_obj_align(s_screen_saver_unread_label, LV_ALIGN_CENTER, 0, 10); - - s_screen_saver_hint_label = lv_label_create(s_screen_saver_layer); - lv_obj_set_style_text_color(s_screen_saver_hint_label, lv_color_hex(0x8A6A3A), 0); - lv_obj_set_style_text_font(s_screen_saver_hint_label, &lv_font_montserrat_14, 0); - lv_obj_align(s_screen_saver_hint_label, LV_ALIGN_CENTER, 0, 40); - - refresh_screen_saver_locale_labels(0); - - lv_obj_add_flag(s_screen_saver_layer, LV_OBJ_FLAG_HIDDEN); -} - void screenSleepTask(void* pvParameters) { (void)pvParameters; @@ -239,7 +173,7 @@ void screenSleepTask(void* pvParameters) const bool sleep_disabled = s_screen_sleep_disable_depth > 0; if (sleep_disabled) { - if (s_screen_sleeping) + if (s_screen_sleeping && !s_screen_saver_active) { s_screen_sleeping = false; board.exitScreenSleep(); @@ -265,7 +199,8 @@ void screenSleepTask(void* pvParameters) board.setBrightness(0); board.enterScreenSleep(); } - else if (s_screen_sleeping && time_since_activity < current_timeout) + else if (s_screen_sleeping && !s_screen_saver_active && + time_since_activity < current_timeout) { s_screen_sleeping = false; board.exitScreenSleep(); @@ -329,7 +264,6 @@ void setScreenSleepTimeout(uint32_t timeout_ms) void initScreenSleepRuntime(const ScreenSleepHooks& hooks) { s_hooks = hooks; - init_screen_saver(); if (s_activity_mutex == nullptr) { @@ -401,11 +335,6 @@ void wakeScreenSaver() return; } - if (!s_screen_saver_layer) - { - return; - } - bool was_sleeping = false; if (s_activity_mutex != nullptr) { @@ -422,34 +351,32 @@ void wakeScreenSaver() } } - screen_saver_refresh(); - lv_obj_clear_flag(s_screen_saver_layer, LV_OBJ_FLAG_HIDDEN); - lv_obj_move_foreground(s_screen_saver_layer); if (was_sleeping) { board.exitScreenSleep(); + board.setBrightness(0); + if (board.hasKeyboard()) + { + board.keyboardSetBrightness(0); + } + } + else + { + s_saved_screen_brightness = board.getBrightness(); + } + show_screen_saver_layer(); + present_screen_saver_layer(); + + if (was_sleeping) + { board.setBrightness(s_saved_screen_brightness); if (board.hasKeyboard()) { board.keyboardSetBrightness(s_saved_keyboard_brightness); } } - else - { - s_saved_screen_brightness = board.getBrightness(); - board.setBrightness(s_saved_screen_brightness); - } - if (s_screen_saver_timer == nullptr) - { - s_screen_saver_timer = lv_timer_create(screen_saver_timer_cb, kScreenSaverDurationMs, nullptr); - } - else - { - lv_timer_set_period(s_screen_saver_timer, kScreenSaverDurationMs); - lv_timer_reset(s_screen_saver_timer); - lv_timer_resume(s_screen_saver_timer); - } + restart_screen_saver_timer(); } void enterFromScreenSaver() @@ -561,7 +488,7 @@ void updateUserActivity() } if (s_screen_sleeping) { - s_screen_sleeping = false; + s_screen_saver_active = true; woke_from_sleep = true; restore_sleep_state = true; } @@ -585,5 +512,7 @@ void updateUserActivity() if (woke_from_sleep) { notifyWakeFromSleep(); + present_screen_saver_layer(); + restart_screen_saver_timer(); } } diff --git a/platform/esp/idf_common/include/platform/esp/idf_common/ui_dispatcher.h b/platform/esp/idf_common/include/platform/esp/idf_common/ui_dispatcher.h index d1d6bd69..c9a2ac65 100644 --- a/platform/esp/idf_common/include/platform/esp/idf_common/ui_dispatcher.h +++ b/platform/esp/idf_common/include/platform/esp/idf_common/ui_dispatcher.h @@ -22,13 +22,17 @@ namespace platform::esp::idf_common::ui_dispatcher enum class Event : std::uint8_t { None = 0, - WakeFromSleep, // → onWakeFromSleep → showWatchFace - ShowMainMenu, // → showMainMenu + WakeFromSleep, // → show_screen_saver/on_wake_from_sleep + ShowScreenSaver, // → show_screen_saver + HideScreenSaver, // → hide_screen_saver + ShowMainMenu, // → showMainMenu }; struct Hooks { void (*on_wake_from_sleep)() = nullptr; + void (*show_screen_saver)() = nullptr; + void (*hide_screen_saver)() = nullptr; void (*show_main_menu)() = nullptr; }; diff --git a/platform/esp/idf_common/src/screen_sleep.cpp b/platform/esp/idf_common/src/screen_sleep.cpp index 892266d8..a0243b90 100644 --- a/platform/esp/idf_common/src/screen_sleep.cpp +++ b/platform/esp/idf_common/src/screen_sleep.cpp @@ -39,8 +39,8 @@ constexpr uint32_t kScreenTimeoutMaxMs = 300000; constexpr uint32_t kScreenTimeoutDefaultMs = 60000; constexpr uint32_t kScreenTimeoutMaxBleSecs = 900; constexpr uint32_t kTaskPeriodMs = 250; +constexpr uint32_t kScreenSaverDurationMs = 3000; -ScreenSleepHooks s_hooks{}; SemaphoreHandle_t s_mutex = nullptr; TaskHandle_t s_task = nullptr; uint32_t s_timeout_ms = kScreenTimeoutDefaultMs; @@ -49,6 +49,7 @@ uint32_t s_last_user_activity_ms = 0; bool s_screen_sleeping = false; bool s_screen_sleep_disabled = false; bool s_screen_saver_active = false; +uint32_t s_screen_saver_started_ms = 0; uint8_t s_saved_screen_brightness = DEVICE_MAX_BRIGHTNESS_LEVEL; bool auto_sleep_supported() @@ -95,13 +96,27 @@ void load_timeout_if_needed_locked() s_timeout_loaded = true; } -void wake_display_locked() +void restore_display_hardware_locked() { platform::ui::device::set_screen_brightness(s_saved_screen_brightness); s_last_user_activity_ms = now_ms(); + ESP_LOGI(kTag, "Display wake"); +} + +void wake_screen_saver_locked() +{ + restore_display_hardware_locked(); + s_screen_sleeping = true; + s_screen_saver_active = true; + s_screen_saver_started_ms = now_ms(); +} + +void enter_ui_locked() +{ + restore_display_hardware_locked(); s_screen_sleeping = false; s_screen_saver_active = false; - ESP_LOGI(kTag, "Display wake"); + s_screen_saver_started_ms = 0; } void sleep_display_locked() @@ -121,6 +136,12 @@ void notify_wake() platform::esp::idf_common::ui_dispatcher::Event::WakeFromSleep); } +void notify_hide_saver() +{ + platform::esp::idf_common::ui_dispatcher::post( + platform::esp::idf_common::ui_dispatcher::Event::HideScreenSaver); +} + bool wake_requested_by_touch_irq_locked() { #if defined(TRAIL_MATE_ESP_BOARD_TAB5) @@ -137,23 +158,32 @@ void screen_sleep_task(void*) while (true) { bool should_notify_wake = false; + bool should_hide_saver = false; ensure_mutex(); if (s_mutex && xSemaphoreTake(s_mutex, portMAX_DELAY) == pdTRUE) { load_timeout_if_needed_locked(); - const uint32_t elapsed = now_ms() - s_last_user_activity_ms; + const uint32_t now = now_ms(); + const uint32_t elapsed = now - s_last_user_activity_ms; + const bool saver_expired = + s_screen_saver_active && (now - s_screen_saver_started_ms >= kScreenSaverDurationMs); if (s_screen_sleep_disabled) { - if (s_screen_sleeping) + if (s_screen_sleeping && !s_screen_saver_active) { - wake_display_locked(); + wake_screen_saver_locked(); should_notify_wake = true; } } - else if (wake_requested_by_touch_irq_locked()) + else if (saver_expired) + { + sleep_display_locked(); + should_hide_saver = true; + } + else if (!s_screen_saver_active && wake_requested_by_touch_irq_locked()) { ESP_LOGI(kTag, "Display wake requested by board touch interrupt"); - wake_display_locked(); + wake_screen_saver_locked(); should_notify_wake = true; } else if (auto_sleep_supported() && (s_screen_sleeping == false) && elapsed >= s_timeout_ms) @@ -166,6 +196,10 @@ void screen_sleep_task(void*) { notify_wake(); } + if (should_hide_saver) + { + notify_hide_saver(); + } vTaskDelay(pdMS_TO_TICKS(kTaskPeriodMs)); } } @@ -218,12 +252,13 @@ void initScreenSleepRuntime(const ScreenSleepHooks& hooks) { platform::esp::idf_common::ui_dispatcher::Hooks dispatch_hooks{}; dispatch_hooks.on_wake_from_sleep = hooks.on_wake_from_sleep; + dispatch_hooks.show_screen_saver = hooks.show_screen_saver; + dispatch_hooks.hide_screen_saver = hooks.hide_screen_saver; dispatch_hooks.show_main_menu = hooks.show_main_menu; platform::esp::idf_common::ui_dispatcher::init(dispatch_hooks); (void)platform::esp::idf_common::ui_dispatcher::ensure_drain_timer(); } - s_hooks = hooks; ensure_mutex(); if (s_mutex && xSemaphoreTake(s_mutex, portMAX_DELAY) == pdTRUE) @@ -287,12 +322,30 @@ bool isScreenSaverActive() } void wakeScreenSaver() { - updateUserActivity(); + bool should_notify_wake = false; + ensure_mutex(); + if (s_mutex && xSemaphoreTake(s_mutex, portMAX_DELAY) == pdTRUE) + { + wake_screen_saver_locked(); + should_notify_wake = true; + xSemaphoreGive(s_mutex); + } + if (should_notify_wake) + { + notify_wake(); + } } void enterFromScreenSaver() { - updateUserActivity(); + ensure_mutex(); + if (s_mutex && xSemaphoreTake(s_mutex, portMAX_DELAY) == pdTRUE) + { + enter_ui_locked(); + xSemaphoreGive(s_mutex); + } + platform::esp::idf_common::ui_dispatcher::post( + platform::esp::idf_common::ui_dispatcher::Event::HideScreenSaver); platform::esp::idf_common::ui_dispatcher::post( platform::esp::idf_common::ui_dispatcher::Event::ShowMainMenu); } @@ -306,7 +359,7 @@ void updateUserActivity() s_last_user_activity_ms = now_ms(); if (s_screen_sleeping || s_screen_saver_active) { - wake_display_locked(); + wake_screen_saver_locked(); woke_from_sleep = true; } xSemaphoreGive(s_mutex); @@ -327,7 +380,7 @@ void disableScreenSleep() s_last_user_activity_ms = now_ms(); if (s_screen_sleeping) { - wake_display_locked(); + wake_screen_saver_locked(); woke = true; } xSemaphoreGive(s_mutex); @@ -367,6 +420,9 @@ ScreenSleepHooks adapt_hooks(const Hooks& hooks) adapted.read_unread_count = hooks.read_unread_count; adapted.show_main_menu = hooks.show_main_menu; adapted.on_wake_from_sleep = hooks.on_wake_from_sleep; + adapted.show_screen_saver = hooks.show_screen_saver; + adapted.hide_screen_saver = hooks.hide_screen_saver; + adapted.present_screen_saver = hooks.present_screen_saver; return adapted; } diff --git a/platform/esp/idf_common/src/ui_dispatcher.cpp b/platform/esp/idf_common/src/ui_dispatcher.cpp index 30e09bb3..d035a71f 100644 --- a/platform/esp/idf_common/src/ui_dispatcher.cpp +++ b/platform/esp/idf_common/src/ui_dispatcher.cpp @@ -27,6 +27,48 @@ Hooks s_hooks{}; QueueHandle_t s_queue = nullptr; lv_timer_t* s_drain_timer = nullptr; +void dispatch(Event event) +{ + switch (event) + { + case Event::WakeFromSleep: + if (s_hooks.show_screen_saver) + { + s_hooks.show_screen_saver(); + } + else if (s_hooks.on_wake_from_sleep) + { + s_hooks.on_wake_from_sleep(); + } + break; + + case Event::ShowScreenSaver: + if (s_hooks.show_screen_saver) + { + s_hooks.show_screen_saver(); + } + break; + + case Event::HideScreenSaver: + if (s_hooks.hide_screen_saver) + { + s_hooks.hide_screen_saver(); + } + break; + + case Event::ShowMainMenu: + if (s_hooks.show_main_menu) + { + s_hooks.show_main_menu(); + } + break; + + case Event::None: + default: + break; + } +} + } // namespace void init(const Hooks& hooks) @@ -65,26 +107,7 @@ void drain() Event event = Event::None; while (xQueueReceive(s_queue, &event, 0) == pdTRUE) { - switch (event) - { - case Event::WakeFromSleep: - if (s_hooks.on_wake_from_sleep) - { - s_hooks.on_wake_from_sleep(); - } - break; - - case Event::ShowMainMenu: - if (s_hooks.show_main_menu) - { - s_hooks.show_main_menu(); - } - break; - - case Event::None: - default: - break; - } + dispatch(event); } } @@ -105,18 +128,7 @@ void drain_timer_cb(lv_timer_t* /*timer*/) return; } - switch (event) - { - case Event::WakeFromSleep: - if (s_hooks.on_wake_from_sleep) s_hooks.on_wake_from_sleep(); - break; - case Event::ShowMainMenu: - if (s_hooks.show_main_menu) s_hooks.show_main_menu(); - break; - case Event::None: - default: - break; - } + dispatch(event); } void* ensure_drain_timer(std::uint32_t period_ms)