diff --git a/builds/esp_idf/ESP_IDF_COMPONENT_SOURCES.cmake b/builds/esp_idf/ESP_IDF_COMPONENT_SOURCES.cmake index eee86c3e..e3cf18f6 100644 --- a/builds/esp_idf/ESP_IDF_COMPONENT_SOURCES.cmake +++ b/builds/esp_idf/ESP_IDF_COMPONENT_SOURCES.cmake @@ -225,6 +225,7 @@ set(TRAILMATE_ESP_IDF_UI_SHARED_SOURCES "${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" + "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/components/bottom_bar.cpp" "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/components/two_pane_styles.cpp" "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/formatters.cpp" "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/loop_shell.cpp" diff --git a/cmake/TrailMateLinuxSources.cmake b/cmake/TrailMateLinuxSources.cmake index 13a66985..a95729c3 100644 --- a/cmake/TrailMateLinuxSources.cmake +++ b/cmake/TrailMateLinuxSources.cmake @@ -381,6 +381,7 @@ set(TRAIL_MATE_LINUX_UI_SHELL_SOURCES "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/components/info_card.cpp" "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/components/two_pane_layout.cpp" "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/components/two_pane_nav.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/components/bottom_bar.cpp" "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/components/two_pane_styles.cpp" # modules/ui_shared �?shell / menu / page "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/app_runtime.cpp" diff --git a/docs/uiux/components/bottom_bar.md b/docs/uiux/components/bottom_bar.md new file mode 100644 index 00000000..3e21f59b --- /dev/null +++ b/docs/uiux/components/bottom_bar.md @@ -0,0 +1,61 @@ +# Shared bottom bar and popup menu + +The former filter/list pages use full-width content and a right-aligned bottom +Menu. Tracker, Contacts, Chat and Settings integrate through the existing shared +navigation adapter. Extensions and Network use the same visual components with +their own page focus callbacks. Map uses the same bar and popup implementation +for its Menu and Team controls. + +Geocaching already has full-width content with horizontal section tabs. It does +not have a permanent filter column to remove. + +## Responsibilities + +`ui::components::bottom_bar` provides a bar factory, content-sized buttons, +flat menu-item styling and translated-text measurement. Its `Menu` owns the +temporary outside-click surface and controls upward placement, viewport bounds, +scrolling, dismissal and a single optional submenu. It contains no route, +contact, chat, radio or settings logic. + +Pages retain ownership of their existing filter widgets and business state. +`Menu::init` adopts that panel and parks it outside the flex layout while closed; +it does not create duplicate filter buttons. The navigation adapter supplies +activation and focus callbacks, preserving the selected category and item. +The default page focus is the content, so entering a page does not open Menu. + +The design uses composition with small function-pointer callbacks. There is no +virtual page hierarchy, global menu registry, `std::function` allocation or +duplicated page-specific popup implementation. `MenuOptions` lets Map retain +its existing keyboard shortcuts and anchor action dispatch while sharing popup +layout and outside-click handling. + +## Input and lifetime + +- A bar button toggles its menu. Clicking outside closes it. +- Filter activation runs the existing page action and returns focus to content. +- Escape/Back/Left dismiss the current menu level before leaving the page. +- The shared navigation controller retains keyboard and encoder groups. +- Map retains its direct layer picker and independent conditional Team menu. +- Page teardown resets the menu before deleting its root. + +Contacts and Chat build their former air-status information only when Info is +selected. Info has a touch Back button and uses the same submenu mechanism. +There are no persistent footer labels or periodic footer hydration on those +pages. Closing Info destroys its transient widgets. + +The menu stores widget pointers and callback state, without protocol buffers, +file metadata caches or copied business models. The outside-click surface is +allocated only while a menu is open. Existing filter widgets remain retained; +this refactor does not claim that their memory has been eliminated. + +## Verification + +`tests/tracker_card_view` includes a real-LVGL bottom-bar contract at 240, 320 +and 480 pixel widths. It checks recovered content width, upward placement, +translated-text bounds, exactly-once filter activation, selected-category focus, +lazy Info construction, submenu return, Escape and restored widget counts. + +`tests/site/two-pane-layout.test.mjs` exercises all six former two-pane pages +from the production sources compiled to WASM on Pager, T-Deck and Wio L2. +`tracker-page.test.mjs` exercises recording and route cards through the new +category menu. Native preview evidence is not a physical-device capture. diff --git a/docs/uiux/pages/map_taskbar.md b/docs/uiux/pages/map_taskbar.md index c99d2081..49706e7a 100644 --- a/docs/uiux/pages/map_taskbar.md +++ b/docs/uiux/pages/map_taskbar.md @@ -3,23 +3,37 @@ Status: implemented in the shared Map runtime; native syntax validation passes. Touch layout and the new navigation integration still require acceptance. +The bar, popup sizing, flat rows and outside-click surface use the +[shared bottom-bar component](../components/bottom_bar.md). Map supplies its +actions and preserves its layer-submenu keyboard behavior. + The bottom bar keeps minus, zoom level and plus at the left. A flexible spacer places Pos, Help, Team and Menu at the right, in that order. The bar does not scroll horizontally. Location-picking mode retains its existing Pick/Cancel actions instead of exposing unrelated tools. Menu opens upward above its button and contains the contour visibility toggle, -current map layer, POI search, Load navigation and Load track. These invoke the +Map Layer, Search, Load navigation and Load track. These invoke the existing layer, contour, search and file-loading behavior. Navigation selects a saved navigation KML and activates it for route following; recorded tracks remain a separate choice. -Team is a separate upward menu next to Menu. It directly exposes the existing +Map Layer opens a compact second-level picker with OSM, Terrain and Satellite. +It marks the current source and selects the requested source directly. A Back +row returns to the main menu; keyboard Right enters and Left/Escape returns. +Only the current menu level has widgets, keeping the map visible and avoiding +two simultaneous popup trees on small screens. The L shortcut still cycles +layers. Search opens the existing place/POI picker with a shorter menu label. + +Team is visible only while the device is in a team with a valid team ID. It is +a separate upward menu next to Menu and directly exposes the existing member-selection and member-track actions. Member controls are created when Team opens rather than maintained in a permanently visible side rail. Only one upward menu is open at a time. Repeating its button or tapping the map outside dismisses it. Keyboard Escape/Back and the M shortcut remain supported. +Popup width follows its translated labels, capped at the available map width. +Rows use a flat style with a highlight for focus, press and current selection. Popup height is constrained to the map area above the taskbar, with vertical scrolling when necessary. Popup controls are destroyed on dismissal. Pos and Help stay accessible while a popup is open. diff --git a/docs/uiux/pages/tracker_item_cards.md b/docs/uiux/pages/tracker_item_cards.md index 21c66696..8421fb61 100644 --- a/docs/uiux/pages/tracker_item_cards.md +++ b/docs/uiux/pages/tracker_item_cards.md @@ -3,6 +3,11 @@ Tracker uses one reusable card for Tracks, Routes and Navigation. It retains the filename index but reads display metadata only for the selected file. +Tracks, Routes and the compact localized `Nav` choice are in the shared bottom +Menu. The card uses the full content width. Recording actions share that bar; +Menu remains available in Routes. Navigation forms and details keep full labels. +See [the shared component design](../components/bottom_bar.md). + The title uses the KML Document name, falling back to the first named Placemark. XML entities, namespace prefixes and CDATA are supported. Empty names fall back to the filename without its extension. Installed shared assets use diff --git a/modules/ui_shared/include/ui/components/air_status_footer.h b/modules/ui_shared/include/ui/components/air_status_footer.h index 98484131..5b03089a 100644 --- a/modules/ui_shared/include/ui/components/air_status_footer.h +++ b/modules/ui_shared/include/ui/components/air_status_footer.h @@ -14,5 +14,7 @@ struct Footer Footer create(lv_obj_t* parent); void refresh(Footer& footer); +// Build the same information on demand inside a bottom-menu Info page. +void populate_info(lv_obj_t* parent); } // namespace ui::components::air_status_footer diff --git a/modules/ui_shared/include/ui/components/bottom_bar.h b/modules/ui_shared/include/ui/components/bottom_bar.h new file mode 100644 index 00000000..f5ea8a64 --- /dev/null +++ b/modules/ui_shared/include/ui/components/bottom_bar.h @@ -0,0 +1,54 @@ +#pragma once + +#include "lvgl.h" + +namespace ui::components::bottom_bar +{ +// A page owns its widgets. These helpers only provide shared taskbar/menu chrome. +lv_obj_t* create(lv_obj_t* parent); +lv_obj_t* add_button(lv_obj_t* bar, const char* text, lv_event_cb_t callback = nullptr, void* context = nullptr); +void style_item(lv_obj_t* item); +void fit_menu(lv_obj_t* panel, lv_coord_t available_width); + +struct MenuOptions +{ + bool anchor_toggles = true; + bool handle_back = true; +}; + +// Reuse the page's existing filter widgets instead of retaining a second set of +// menu items. The temporary outside-click surface exists only while open. +class Menu +{ + public: + using Changed = void (*)(void* context, bool open); + void init(lv_obj_t* root, lv_obj_t* anchor, lv_obj_t* content, + Changed changed = nullptr, void* context = nullptr, MenuOptions options = {}); + void reset(); + void open(); + void close(); + void toggle(); + bool is_open() const { return layer_ != nullptr; } + lv_obj_t* button() const { return anchor_; } + lv_obj_t* content() const { return content_; } + bool in_submenu() const { return parent_content_ != nullptr; } + void show_submenu(lv_obj_t* content); + bool back(); + + private: + static void anchor_event(lv_event_t* event); + static void outside_event(lv_event_t* event); + static void key_event(lv_event_t* event); + void present(); + void prepare_keys(lv_obj_t* object); + void park(lv_obj_t* object); + lv_obj_t* root_ = nullptr; + lv_obj_t* anchor_ = nullptr; + lv_obj_t* content_ = nullptr; + lv_obj_t* parent_content_ = nullptr; + lv_obj_t* layer_ = nullptr; + Changed changed_ = nullptr; + void* context_ = nullptr; + MenuOptions options_{}; +}; +} // namespace ui::components::bottom_bar diff --git a/modules/ui_shared/include/ui/components/two_pane_nav.h b/modules/ui_shared/include/ui/components/two_pane_nav.h index 8fea3cc3..c5c62717 100644 --- a/modules/ui_shared/include/ui/components/two_pane_nav.h +++ b/modules/ui_shared/include/ui/components/two_pane_nav.h @@ -1,6 +1,7 @@ #pragma once #include "lvgl.h" +#include "ui/components/bottom_bar.h" #include #include @@ -47,6 +48,11 @@ struct Adapter bool (*handle_list_enter)(void* ctx, lv_obj_t* focused) = nullptr; void (*on_column_changed)(void* ctx, FocusColumn column) = nullptr; + // Optional bottom-menu presentation; business activation stays in Adapter. + lv_obj_t* filter_panel = nullptr; + lv_obj_t* bottom_bar = nullptr; + void (*build_info)(lv_obj_t* parent) = nullptr; + BackPlacement filter_top_back_placement = BackPlacement::Leading; BackPlacement list_top_back_placement = BackPlacement::None; }; @@ -59,6 +65,10 @@ struct Binding lv_indev_t* encoder = nullptr; FocusColumn column = FocusColumn::Filter; bool bound = false; + bottom_bar::Menu menu{}; + lv_obj_t* menu_bar = nullptr; + lv_obj_t* info_button = nullptr; + bool owns_menu_bar = false; }; void init(Binding* binding, const Adapter& adapter); 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 e96776be..02a5d55c 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 @@ -12,7 +12,6 @@ #include "chat/domain/chat_types.h" #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 @@ -135,7 +134,6 @@ class ChatMessageListScreen lv_group_t* modal_prev_group_ = nullptr; chat::ConversationId modal_conv_{}; ModalButtonContext modal_button_contexts_[5]{}; - ::ui::components::air_status_footer::Footer air_status_footer_{}; int selected_index_ = -1; FilterMode filter_mode_ = FilterMode::Direct; diff --git a/modules/ui_shared/include/ui/screens/chat/chat_message_list_layout.h b/modules/ui_shared/include/ui/screens/chat/chat_message_list_layout.h index 711db908..6887f53c 100644 --- a/modules/ui_shared/include/ui/screens/chat/chat_message_list_layout.h +++ b/modules/ui_shared/include/ui/screens/chat/chat_message_list_layout.h @@ -1,7 +1,6 @@ #pragma once #include "chat/domain/chat_types.h" #include "lvgl.h" -#include "ui/components/air_status_footer.h" namespace chat::ui::layout { @@ -15,7 +14,6 @@ struct MessageListLayout lv_obj_t* direct_btn; lv_obj_t* broadcast_btn; lv_obj_t* team_btn; - ::ui::components::air_status_footer::Footer air_status_footer; }; // split content (filter + list) diff --git a/modules/ui_shared/include/ui/screens/contacts/contacts_page_layout.h b/modules/ui_shared/include/ui/screens/contacts/contacts_page_layout.h index 92cc3ac7..853dead7 100644 --- a/modules/ui_shared/include/ui/screens/contacts/contacts_page_layout.h +++ b/modules/ui_shared/include/ui/screens/contacts/contacts_page_layout.h @@ -15,7 +15,6 @@ namespace layout lv_obj_t* create_root(lv_obj_t* parent); lv_obj_t* create_header(lv_obj_t* root, void (*back_callback)(void*), void* user_data); -void create_footer(lv_obj_t* root); /** * Create the main two-pane content row for the Contacts page. 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 f80e56d8..d3e374fe 100644 --- a/modules/ui_shared/include/ui/screens/contacts/contacts_state.h +++ b/modules/ui_shared/include/ui/screens/contacts/contacts_state.h @@ -9,7 +9,6 @@ #include "chat/domain/peer_browse_entry.h" #include "lvgl.h" #include "platform/ui/map_search_runtime.h" -#include "ui/components/air_status_footer.h" #include "ui/components/floating_search_box.h" #include "ui/widgets/top_bar.h" #include @@ -51,7 +50,6 @@ struct ContactsPageState lv_obj_t* page = nullptr; // Main content row container ::ui::widgets::TopBar top_bar; - ::ui::components::air_status_footer::Footer air_status_footer; // First column: Filter buttons lv_obj_t* filter_panel = nullptr; diff --git a/modules/ui_shared/include/ui/screens/tracker/tracker_page_layout.h b/modules/ui_shared/include/ui/screens/tracker/tracker_page_layout.h index ee3690bf..82dfcdd3 100644 --- a/modules/ui_shared/include/ui/screens/tracker/tracker_page_layout.h +++ b/modules/ui_shared/include/ui/screens/tracker/tracker_page_layout.h @@ -15,7 +15,6 @@ lv_obj_t* create_content(lv_obj_t* root); lv_obj_t* create_filter_panel(lv_obj_t* content, int width); lv_obj_t* create_list_panel(lv_obj_t* content); lv_obj_t* create_list_container(lv_obj_t* list_panel); -lv_obj_t* create_bottom_bar(lv_obj_t* list_panel); } // namespace layout } // namespace ui diff --git a/modules/ui_shared/src/ui/components/air_status_footer.cpp b/modules/ui_shared/src/ui/components/air_status_footer.cpp index 77de54e5..c40ea493 100644 --- a/modules/ui_shared/src/ui/components/air_status_footer.cpp +++ b/modules/ui_shared/src/ui/components/air_status_footer.cpp @@ -91,4 +91,19 @@ void refresh(Footer& footer) ::ui::i18n::set_label_text_raw(footer.detail_label, detail); } +void populate_info(lv_obj_t* parent) +{ + const auto snapshot = deviceStatusModel().snapshot(); + for (const auto* text : {snapshot.header.valid ? snapshot.status_line.c_str() : "-", + snapshot.header.valid ? snapshot.modem_preset.c_str() : "-"}) + { + auto* label = lv_label_create(parent); + lv_obj_set_width(label, LV_PCT(100)); + lv_label_set_long_mode(label, LV_LABEL_LONG_WRAP); + ::ui::components::two_pane_styles::apply_label_primary(label); + ::ui::fonts::apply_font(label, ::ui::page_profile::resolve_body_font()); + ::ui::i18n::set_label_text_raw(label, text); + } +} + } // namespace ui::components::air_status_footer diff --git a/modules/ui_shared/src/ui/components/bottom_bar.cpp b/modules/ui_shared/src/ui/components/bottom_bar.cpp new file mode 100644 index 00000000..aa671e18 --- /dev/null +++ b/modules/ui_shared/src/ui/components/bottom_bar.cpp @@ -0,0 +1,262 @@ +#include "ui/components/bottom_bar.h" + +#include "ui/localization.h" +#include "ui/page/page_profile.h" +#include + +namespace ui::components::bottom_bar +{ +namespace +{ +constexpr uint32_t kBackground = 0xF6E6C6; +constexpr uint32_t kText = 0x6B4A1E; +constexpr uint32_t kSelection = 0xE8C98A; + +bool valid(lv_obj_t* object) { return object && lv_obj_is_valid(object); } + +void plain(lv_obj_t* object) +{ + lv_obj_set_style_border_width(object, 0, LV_PART_MAIN); + lv_obj_set_style_shadow_width(object, 0, LV_PART_MAIN); + lv_obj_set_style_radius(object, 0, LV_PART_MAIN); + lv_obj_set_scrollbar_mode(object, LV_SCROLLBAR_MODE_OFF); +} +} // namespace + +lv_obj_t* create(lv_obj_t* parent) +{ + const auto& profile = ::ui::page_profile::current(); + auto* bar = lv_obj_create(parent); + plain(bar); + lv_obj_clear_flag(bar, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(bar, LV_PCT(100), std::max(26, profile.filter_button_height)); + lv_obj_set_style_bg_color(bar, lv_color_hex(kBackground), LV_PART_MAIN); + lv_obj_set_style_pad_all(bar, 0, LV_PART_MAIN); + lv_obj_set_style_pad_hor(bar, 4, LV_PART_MAIN); + lv_obj_set_style_pad_column(bar, 4, LV_PART_MAIN); + lv_obj_set_flex_flow(bar, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(bar, LV_FLEX_ALIGN_END, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + return bar; +} + +void style_item(lv_obj_t* item) +{ + plain(item); + lv_obj_clear_flag(item, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_opa(item, LV_OPA_TRANSP, LV_PART_MAIN); + lv_obj_set_style_bg_color(item, lv_color_hex(kSelection), LV_STATE_FOCUSED); + lv_obj_set_style_bg_color(item, lv_color_hex(kSelection), LV_STATE_CHECKED); + lv_obj_set_style_bg_color(item, lv_color_hex(kSelection), LV_STATE_PRESSED); + lv_obj_set_style_bg_opa(item, LV_OPA_COVER, LV_STATE_FOCUSED); + lv_obj_set_style_bg_opa(item, LV_OPA_COVER, LV_STATE_CHECKED); + lv_obj_set_style_bg_opa(item, LV_OPA_COVER, LV_STATE_PRESSED); + lv_obj_set_style_text_color(item, lv_color_hex(kText), LV_PART_MAIN); + lv_obj_set_style_pad_hor(item, 8, LV_PART_MAIN); + lv_obj_set_style_pad_ver(item, 3, LV_PART_MAIN); + if (auto* label = lv_obj_get_child(item, 0)) + { + if (lv_obj_has_class(label, &lv_label_class)) + { + lv_obj_set_width(label, LV_PCT(100)); + lv_label_set_long_mode(label, LV_LABEL_LONG_DOT); + lv_obj_set_style_text_align(label, LV_TEXT_ALIGN_LEFT, LV_PART_MAIN); + lv_obj_set_style_text_font(label, ::ui::page_profile::resolve_body_font(), LV_PART_MAIN); + lv_obj_align(label, LV_ALIGN_LEFT_MID, 0, 0); + } + } +} + +lv_obj_t* add_button(lv_obj_t* bar, const char* text, lv_event_cb_t callback, void* context) +{ + auto* button = lv_btn_create(bar); + auto* label = lv_label_create(button); + ::ui::i18n::set_label_text(label, text); + style_item(button); + lv_obj_set_size(button, LV_SIZE_CONTENT, LV_PCT(100)); + lv_obj_set_width(label, LV_SIZE_CONTENT); + lv_obj_center(label); + if (callback) lv_obj_add_event_cb(button, callback, LV_EVENT_CLICKED, context); + return button; +} + +void fit_menu(lv_obj_t* panel, lv_coord_t available_width) +{ + lv_coord_t width = 72; + const auto padding = lv_obj_get_style_pad_left(panel, LV_PART_MAIN) + + lv_obj_get_style_pad_right(panel, LV_PART_MAIN) + + 2 * lv_obj_get_style_border_width(panel, LV_PART_MAIN); + for (uint32_t index = 0; index < lv_obj_get_child_count(panel); ++index) + { + auto* child = lv_obj_get_child(panel, index); + if (lv_obj_has_flag(child, LV_OBJ_FLAG_HIDDEN)) continue; + auto* label = lv_obj_has_class(child, &lv_label_class) ? child : lv_obj_get_child(child, 0); + if (!label || !lv_obj_has_class(label, &lv_label_class)) continue; + lv_point_t extent{}; + lv_text_get_size(&extent, lv_label_get_text(label), lv_obj_get_style_text_font(label, LV_PART_MAIN), + lv_obj_get_style_text_letter_space(label, LV_PART_MAIN), 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE); + const auto inset = lv_obj_get_style_pad_left(child, LV_PART_MAIN) + + lv_obj_get_style_pad_right(child, LV_PART_MAIN) + + 2 * lv_obj_get_style_border_width(child, LV_PART_MAIN); + width = std::max(width, extent.x + inset + padding); + } + lv_obj_set_width(panel, std::min(width, available_width)); + for (uint32_t index = 0; index < lv_obj_get_child_count(panel); ++index) + lv_obj_set_width(lv_obj_get_child(panel, index), LV_PCT(100)); +} + +void Menu::park(lv_obj_t* object) +{ + if (!valid(object) || !valid(root_)) return; + lv_obj_add_flag(object, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(object, LV_OBJ_FLAG_IGNORE_LAYOUT); + lv_obj_set_parent(object, root_); +} + +void Menu::init(lv_obj_t* root, lv_obj_t* anchor, lv_obj_t* content, Changed changed, void* context, MenuOptions options) +{ + reset(); + root_ = root; + anchor_ = anchor; + content_ = content; + changed_ = changed; + context_ = context; + options_ = options; + park(content_); + if (valid(anchor_) && options_.anchor_toggles) lv_obj_add_event_cb(anchor_, anchor_event, LV_EVENT_CLICKED, this); +} + +void Menu::reset() +{ + changed_ = nullptr; + close(); + if (valid(anchor_) && options_.anchor_toggles) lv_obj_remove_event_cb(anchor_, anchor_event); + root_ = anchor_ = content_ = nullptr; + context_ = nullptr; +} + +void Menu::present() +{ + if (!valid(content_) || !valid(layer_)) return; + lv_obj_set_parent(content_, layer_); + lv_obj_clear_flag(content_, LV_OBJ_FLAG_HIDDEN); + plain(content_); + lv_obj_set_flex_grow(content_, 0); + lv_obj_set_flex_flow(content_, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_bg_color(content_, lv_color_hex(kBackground), LV_PART_MAIN); + lv_obj_set_style_bg_opa(content_, LV_OPA_COVER, LV_PART_MAIN); + lv_obj_set_style_border_width(content_, 1, LV_PART_MAIN); + lv_obj_set_style_border_color(content_, lv_color_hex(0xD8B27A), LV_PART_MAIN); + lv_obj_set_style_radius(content_, 5, LV_PART_MAIN); + lv_obj_set_style_pad_all(content_, 4, LV_PART_MAIN); + lv_obj_set_style_pad_row(content_, 2, LV_PART_MAIN); + lv_obj_set_style_margin_all(content_, 0, LV_PART_MAIN); + lv_obj_set_height(content_, LV_SIZE_CONTENT); + lv_obj_update_layout(root_); + fit_menu(content_, std::max(72, lv_obj_get_width(root_) - 8)); + lv_obj_set_style_max_width(content_, lv_obj_get_width(root_) - 8, LV_PART_MAIN); + lv_area_t root_area{}; + lv_obj_get_coords(root_, &root_area); + lv_area_t anchor_area{}; + if (valid(anchor_)) lv_obj_get_coords(anchor_, &anchor_area); + const lv_coord_t bottom = valid(anchor_) ? anchor_area.y1 - root_area.y1 - 3 : lv_obj_get_height(root_) - 3; + lv_obj_set_style_max_height(content_, std::max(24, bottom - 4), LV_PART_MAIN); + lv_obj_add_flag(content_, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_scroll_dir(content_, LV_DIR_VER); + lv_obj_set_scrollbar_mode(content_, LV_SCROLLBAR_MODE_AUTO); + lv_obj_update_layout(content_); + const auto width = lv_obj_get_width(content_); + const auto x = valid(anchor_) ? anchor_area.x2 - root_area.x1 - width + 1 : lv_obj_get_width(root_) - width - 4; + lv_obj_set_pos(content_, std::max(4, std::min(x, lv_obj_get_width(root_) - width - 4)), + std::max(4, bottom - lv_obj_get_height(content_))); + for (uint32_t index = 0; index < lv_obj_get_child_count(content_); ++index) + { + auto* item = lv_obj_get_child(content_, index); + if (lv_obj_has_flag(item, LV_OBJ_FLAG_CLICKABLE)) style_item(item); + } + prepare_keys(content_); +} + +void Menu::prepare_keys(lv_obj_t* object) +{ + lv_obj_remove_event_cb(object, key_event); + if (options_.handle_back) lv_obj_add_event_cb(object, key_event, static_cast(LV_EVENT_KEY | LV_EVENT_PREPROCESS), this); + for (uint32_t i = 0; i < lv_obj_get_child_count(object); ++i) prepare_keys(lv_obj_get_child(object, i)); +} + +void Menu::open() +{ + if (is_open() || !valid(root_) || !valid(content_)) return; + layer_ = lv_obj_create(root_); + lv_obj_remove_style_all(layer_); + lv_obj_add_flag(layer_, LV_OBJ_FLAG_IGNORE_LAYOUT); + lv_obj_add_flag(layer_, LV_OBJ_FLAG_CLICKABLE); + lv_obj_clear_flag(layer_, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(layer_, LV_PCT(100), LV_PCT(100)); + lv_obj_set_pos(layer_, 0, 0); + lv_obj_add_event_cb(layer_, outside_event, LV_EVENT_CLICKED, this); + present(); + if (changed_) changed_(context_, true); +} + +void Menu::close() +{ + if (!layer_) return; + if (parent_content_) + { + if (valid(content_)) lv_obj_del(content_); + content_ = parent_content_; + parent_content_ = nullptr; + } + park(content_); + if (valid(layer_)) lv_obj_del(layer_); + layer_ = nullptr; + if (changed_) changed_(context_, false); +} + +void Menu::toggle() { is_open() ? close() : open(); } + +void Menu::show_submenu(lv_obj_t* content) +{ + if (!is_open() || parent_content_ || !valid(content)) return; + park(content_); + parent_content_ = content_; + content_ = content; + present(); +} + +bool Menu::back() +{ + if (!is_open()) return false; + if (!parent_content_) close(); + else + { + lv_obj_del(content_); + content_ = parent_content_; + parent_content_ = nullptr; + present(); + if (changed_) changed_(context_, true); + } + return true; +} + +void Menu::anchor_event(lv_event_t* event) +{ + static_cast(lv_event_get_user_data(event))->toggle(); +} + +void Menu::outside_event(lv_event_t* event) +{ + if (lv_event_get_target(event) == lv_event_get_current_target(event)) + static_cast(lv_event_get_user_data(event))->close(); +} + +void Menu::key_event(lv_event_t* event) +{ + const auto key = lv_event_get_key(event); + if (key != LV_KEY_ESC && key != LV_KEY_BACKSPACE && key != LV_KEY_LEFT) return; + auto* menu = static_cast(lv_event_get_user_data(event)); + lv_event_stop_processing(event); + lv_event_stop_bubbling(event); + menu->back(); +} +} // namespace ui::components::bottom_bar diff --git a/modules/ui_shared/src/ui/components/two_pane_nav.cpp b/modules/ui_shared/src/ui/components/two_pane_nav.cpp index dac665d4..f1ef2be2 100644 --- a/modules/ui_shared/src/ui/components/two_pane_nav.cpp +++ b/modules/ui_shared/src/ui/components/two_pane_nav.cpp @@ -1,6 +1,7 @@ #include "ui/components/two_pane_nav.h" #include "ui/app_runtime.h" +#include "ui/page/page_profile.h" #include @@ -203,6 +204,10 @@ static void set_column(Binding* binding, FocusColumn column) { return; } + if (column == FocusColumn::List && binding->menu.is_open()) + { + binding->menu.close(); + } if (binding->column == column) { return; @@ -448,6 +453,11 @@ static bool activate_list_button(Binding* binding, lv_obj_t* obj) static void bind_filter_column(Binding* binding) { if (!binding || !binding->group) return; + if (binding->adapter.filter_panel && !binding->menu.is_open()) + { + binding->menu.open(); + return; + } lv_group_focus_freeze(binding->group, true); clear_managed_focus_states(binding); @@ -461,6 +471,8 @@ static void bind_filter_column(Binding* binding) { group_add_if_visible(binding, get_filter_button(binding, index)); } + group_add_if_visible(binding, binding->info_button); + group_add_if_visible(binding, binding->menu.button()); add_top_back(binding, binding->adapter.filter_top_back_placement, BackPlacement::Trailing); lv_group_focus_freeze(binding->group, false); @@ -517,6 +529,7 @@ static void bind_list_column(Binding* binding) } add_top_back(binding, binding->adapter.list_top_back_placement, BackPlacement::Trailing); + group_add_if_visible(binding, binding->menu.button()); lv_group_focus_freeze(binding->group, false); lv_obj_t* target = nullptr; @@ -569,12 +582,78 @@ static void rebind_by_column(Binding* binding) } } +static void menu_changed(void* context, bool open) +{ + auto* binding = static_cast(context); + if (!binding_alive(binding)) return; + binding->column = open ? FocusColumn::Filter : FocusColumn::List; + notify_column_changed(binding); + rebind_by_column(binding); +} + +static void menu_item_clicked(lv_event_t* event) +{ + auto* binding = static_cast(lv_event_get_user_data(event)); + if (!binding_alive(binding) || !binding->menu.is_open()) return; + auto* target = static_cast(lv_event_get_current_target(event)); + if (!activate_filter_button(binding, target)) binding->menu.close(); +} + +static void info_back_clicked(lv_event_t* event) +{ + auto* binding = static_cast(lv_event_get_user_data(event)); + binding->menu.back(); +} + +static void info_clicked(lv_event_t* event) +{ + auto* binding = static_cast(lv_event_get_user_data(event)); + if (!binding_alive(binding) || !binding->adapter.build_info) return; + auto* panel = lv_obj_create(get_key_target(binding)); + auto* back = bottom_bar::add_button(panel, "Back", info_back_clicked, binding); + lv_obj_set_height(back, ::ui::page_profile::current().filter_button_height); + binding->adapter.build_info(panel); + binding->menu.show_submenu(panel); + lv_group_remove_all_objs(binding->group); + lv_group_add_obj(binding->group, back); + lv_group_focus_obj(back); +} + +static void init_menu(Binding* binding) +{ + if (!binding->adapter.filter_panel) return; + binding->menu_bar = binding->adapter.bottom_bar; + binding->owns_menu_bar = !binding->menu_bar; + if (!binding->menu_bar) binding->menu_bar = bottom_bar::create(get_key_target(binding)); + auto* button = bottom_bar::add_button(binding->menu_bar, "Menu"); + attach_key_handler(binding, button); + auto* panel = binding->adapter.filter_panel; + const size_t count = get_filter_count(binding); + for (size_t index = 0; index < count; ++index) + { + if (auto* item = get_filter_button(binding, index)) + lv_obj_add_event_cb(item, menu_item_clicked, LV_EVENT_CLICKED, binding); + } + if (binding->adapter.build_info) + { + binding->info_button = bottom_bar::add_button(panel, "Info", info_clicked, binding); + lv_obj_set_height(binding->info_button, ::ui::page_profile::current().filter_button_height); + attach_key_handler(binding, binding->info_button); + } + binding->menu.init(get_key_target(binding), button, panel, menu_changed, binding); +} + static void root_key_event_cb(lv_event_t* e) { auto* binding = static_cast(lv_event_get_user_data(e)); if (!binding_alive(binding)) return; const uint32_t key = lv_event_get_key(e); + if (binding->adapter.filter_panel && (key == 'm' || key == 'M')) + { + binding->menu.toggle(); + return; + } if (key == LV_KEY_BACKSPACE || key == LV_KEY_ESC) { if (lv_obj_t* back = get_top_back_button(binding)) @@ -588,6 +667,11 @@ static void root_key_event_cb(lv_event_t* e) lv_obj_t* focused = binding->group ? lv_group_get_focused(binding->group) : nullptr; if (!focused) return; + if (focused == binding->menu.button() || focused == binding->info_button) + { + lv_obj_send_event(focused, LV_EVENT_CLICKED, nullptr); + return; + } if (binding->column == FocusColumn::Filter) { @@ -723,6 +807,8 @@ void init(Binding* binding, const Adapter& adapter) binding->encoder = find_encoder_indev(); binding->column = FocusColumn::Filter; binding->bound = true; + init_menu(binding); + if (adapter.filter_panel) binding->column = FocusColumn::List; set_default_group(nullptr); set_default_group(binding->group); @@ -745,6 +831,14 @@ void init(Binding* binding, const Adapter& adapter) void cleanup(Binding* binding) { if (!binding || !binding->bound) return; + auto* menu_button = binding->menu.button(); + binding->menu.reset(); + if (is_valid(menu_button)) lv_obj_del(menu_button); + if (is_valid(binding->info_button)) lv_obj_del(binding->info_button); + binding->info_button = nullptr; + if (binding->owns_menu_bar && is_valid(binding->menu_bar)) lv_obj_del(binding->menu_bar); + binding->menu_bar = nullptr; + binding->owns_menu_bar = false; if (binding->group) { @@ -767,6 +861,7 @@ void cleanup(Binding* binding) void on_ui_refreshed(Binding* binding) { if (!binding_alive(binding) || !binding->group) return; + if (binding->menu.in_submenu()) return; lv_group_set_editing(binding->group, false); rebind_by_column(binding); } 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 00ed177d..b5b132e4 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 @@ -49,11 +49,9 @@ static bool show_second_column_back() static bool use_group_navigation() { -#if defined(USING_INPUT_DEV_TOUCHPAD) - return false; -#else + // The shared controller routes both pointer and hardware input. All targets + // must create the bottom menu, including touchpad-only products. return true; -#endif } } // namespace @@ -404,7 +402,6 @@ ChatMessageListScreen::ChatMessageListScreen(lv_obj_t* parent) direct_btn_ = w.direct_btn; broadcast_btn_ = w.broadcast_btn; team_btn_ = w.team_btn; - air_status_footer_ = w.air_status_footer; // ---------- Styles ---------- chat::ui::message_list::styles::apply_root_container(container_); @@ -707,7 +704,7 @@ void ChatMessageListScreen::updateBatteryFromBoard() { return; } - ::ui::components::air_status_footer::refresh(air_status_footer_); + ui_update_top_bar_battery(top_bar_); } bool ChatMessageListScreen::searchActive() const @@ -796,6 +793,11 @@ void ChatMessageListScreen::applyFilterPanelVisibility() { return; } + if (lv_obj_has_flag(filter_panel_, LV_OBJ_FLAG_IGNORE_LAYOUT)) + { + chat::ui::message_list::input::on_ui_refreshed(&input_controller_); + return; + } if (filter_panel_visible_) { lv_obj_clear_flag(filter_panel_, LV_OBJ_FLAG_HIDDEN); @@ -816,11 +818,11 @@ void ChatMessageListScreen::toggleFilterPanel() { return; } - filter_panel_visible_ = !filter_panel_visible_; - applyFilterPanelVisibility(); if (use_group_navigation()) { - chat::ui::message_list::input::focus_list(&input_controller_); + if (filter_panel_ && lv_obj_has_flag(filter_panel_, LV_OBJ_FLAG_HIDDEN)) + chat::ui::message_list::input::focus_filter(&input_controller_); + else chat::ui::message_list::input::focus_list(&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 f1f3be1b..d1bbb670 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 @@ -1,5 +1,6 @@ #if !defined(ARDUINO_T_WATCH_S3) #include "ui/screens/chat/chat_message_list_input.h" +#include "ui/components/air_status_footer.h" #include "ui/screens/chat/chat_message_list_components.h" @@ -109,6 +110,8 @@ static Adapter make_adapter(ChatMessageListScreen* screen) { Adapter adapter{}; adapter.ctx = screen; + adapter.filter_panel = screen && screen->getDirectButton() ? lv_obj_get_parent(screen->getDirectButton()) : nullptr; + adapter.build_info = ::ui::components::air_status_footer::populate_info; adapter.is_alive = screen_alive; adapter.get_key_target = get_key_target; adapter.get_top_back_button = get_top_back_button; diff --git a/modules/ui_shared/src/ui/screens/chat/chat_message_list_layout.cpp b/modules/ui_shared/src/ui/screens/chat/chat_message_list_layout.cpp index e5921988..2ac67f2f 100644 --- a/modules/ui_shared/src/ui/screens/chat/chat_message_list_layout.cpp +++ b/modules/ui_shared/src/ui/screens/chat/chat_message_list_layout.cpp @@ -6,7 +6,6 @@ #include "ui/screens/chat/chat_message_list_layout.h" #include "ui/assets/fonts/font_utils.h" -#include "ui/components/air_status_footer.h" #include "ui/components/info_card.h" #include "ui/components/two_pane_layout.h" #include "ui/localization.h" @@ -277,7 +276,6 @@ MessageListLayout create_layout(lv_obj_t* parent) w.content = create_content(w.root); w.filter_panel = create_filter_panel(w.content, &w.direct_btn, &w.broadcast_btn, &w.team_btn); w.list_panel = create_list_panel(w.content); - w.air_status_footer = ::ui::components::air_status_footer::create(w.root); return w; } diff --git a/modules/ui_shared/src/ui/screens/contacts/contacts_page_components.cpp b/modules/ui_shared/src/ui/screens/contacts/contacts_page_components.cpp index 61445efa..12c5183a 100644 --- a/modules/ui_shared/src/ui/screens/contacts/contacts_page_components.cpp +++ b/modules/ui_shared/src/ui/screens/contacts/contacts_page_components.cpp @@ -1087,6 +1087,11 @@ static void apply_filter_panel_visibility() { return; } + if (lv_obj_has_flag(g_contacts_state.filter_panel, LV_OBJ_FLAG_IGNORE_LAYOUT)) + { + contacts_input_on_ui_refreshed(); + return; + } if (g_contacts_state.filter_panel_visible) { lv_obj_clear_flag(g_contacts_state.filter_panel, LV_OBJ_FLAG_HIDDEN); @@ -1100,9 +1105,9 @@ static void apply_filter_panel_visibility() 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(); + if (g_contacts_state.filter_panel && lv_obj_has_flag(g_contacts_state.filter_panel, LV_OBJ_FLAG_HIDDEN)) + contacts_focus_to_filter(); + else contacts_focus_to_list(); } // ---------------- Panel creation (public API) ---------------- @@ -4015,7 +4020,6 @@ static void refresh_node_card_ui() if (state.card_index + 1 >= state.card_entries.size()) lv_obj_add_state(state.next_btn, LV_STATE_DISABLED); else lv_obj_clear_state(state.next_btn, LV_STATE_DISABLED); refresh_filter_checked_state(); - ::ui::components::air_status_footer::refresh(state.air_status_footer); state.rendered_data_revision = state.contacts_data_revision; if (created) contacts_input_on_ui_refreshed(); } @@ -4083,7 +4087,6 @@ void refresh_ui() { lv_obj_clear_flag(g_contacts_state.sub_container, LV_OBJ_FLAG_SCROLLABLE); } - ::ui::components::air_status_footer::refresh(g_contacts_state.air_status_footer); bool team_available = is_team_available() && chat_support::supports_team_chat(); const bool reticulum_profile = uses_reticulum_filter_profile(); 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 8519f82e..42694181 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 @@ -5,6 +5,7 @@ #include "ui/screens/contacts/contacts_page_input.h" +#include "ui/components/air_status_footer.h" #include "ui/components/two_pane_nav.h" #include "ui/screens/contacts/contacts_filter_profile.h" #include "ui/screens/contacts/contacts_page_components.h" @@ -221,6 +222,8 @@ static bool handle_list_enter(void* /*ctx*/, lv_obj_t* focused) static Adapter make_adapter() { Adapter adapter{}; + adapter.filter_panel = contacts::ui::g_contacts_state.filter_panel; + adapter.build_info = ::ui::components::air_status_footer::populate_info; adapter.get_key_target = get_key_target; adapter.get_top_back_button = get_top_back_button; adapter.get_filter_count = get_filter_count; diff --git a/modules/ui_shared/src/ui/screens/contacts/contacts_page_layout.cpp b/modules/ui_shared/src/ui/screens/contacts/contacts_page_layout.cpp index 79a7e263..5b809e80 100644 --- a/modules/ui_shared/src/ui/screens/contacts/contacts_page_layout.cpp +++ b/modules/ui_shared/src/ui/screens/contacts/contacts_page_layout.cpp @@ -10,7 +10,6 @@ #include "chat/domain/chat_types.h" #include "ui/assets/fonts/font_utils.h" -#include "ui/components/air_status_footer.h" #include "ui/components/info_card.h" #include "ui/components/two_pane_layout.h" #include "ui/localization.h" @@ -140,11 +139,6 @@ lv_obj_t* create_header(lv_obj_t* root, return header; } -void create_footer(lv_obj_t* root) -{ - g_contacts_state.air_status_footer = ::ui::components::air_status_footer::create(root); -} - lv_obj_t* create_content(lv_obj_t* root) { const auto& profile = ::ui::page_profile::current(); 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 6892b21e..07271ef4 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 @@ -437,7 +437,6 @@ void enter(const shell::Host* host, lv_obj_t* parent) lv_obj_t* content = contacts::ui::layout::create_content(g_contacts_state.root); g_contacts_state.page = content; - contacts::ui::layout::create_footer(g_contacts_state.root); ui_update_top_bar_battery(g_contacts_state.top_bar); create_filter_panel(content); diff --git a/modules/ui_shared/src/ui/screens/extensions/extensions_page_runtime.cpp b/modules/ui_shared/src/ui/screens/extensions/extensions_page_runtime.cpp index 9e0f3a6a..2c7fd370 100644 --- a/modules/ui_shared/src/ui/screens/extensions/extensions_page_runtime.cpp +++ b/modules/ui_shared/src/ui/screens/extensions/extensions_page_runtime.cpp @@ -11,6 +11,7 @@ #include "platform/ui/pack_repository_runtime.h" #include "platform/ui/wifi_runtime.h" #include "ui/app_runtime.h" +#include "ui/components/bottom_bar.h" #include "ui/components/info_card.h" #include "ui/components/two_pane_layout.h" #include "ui/components/two_pane_styles.h" @@ -66,6 +67,7 @@ struct RuntimeState lv_obj_t* root = nullptr; lv_obj_t* content = nullptr; lv_obj_t* filter_panel = nullptr; + ::ui::components::bottom_bar::Menu menu{}; lv_obj_t* installed_filter_btn = nullptr; lv_obj_t* uninstalled_filter_btn = nullptr; lv_obj_t* main_panel = nullptr; @@ -525,18 +527,20 @@ void sync_focus_group(lv_obj_t* preferred_focus = nullptr) lv_group_remove_all_objs(app_g); + if (s_runtime.menu.is_open()) + { + add_focusable(s_runtime.installed_filter_btn); + add_focusable(s_runtime.uninstalled_filter_btn); + lv_group_focus_obj(s_runtime.filter == PackageFilter::Installed ? s_runtime.installed_filter_btn : s_runtime.uninstalled_filter_btn); + set_default_group(app_g); + return; + } + if (s_runtime.top_bar.back_btn != nullptr) { add_focusable(s_runtime.top_bar.back_btn); } - if (s_runtime.installed_filter_btn != nullptr) - { - add_focusable(s_runtime.installed_filter_btn); - } - if (s_runtime.uninstalled_filter_btn != nullptr) - { - add_focusable(s_runtime.uninstalled_filter_btn); - } + add_focusable(s_runtime.menu.button()); if (s_runtime.detail_back_btn != nullptr) { add_focusable(s_runtime.detail_back_btn); @@ -561,13 +565,14 @@ void sync_focus_group(lv_obj_t* preferred_focus = nullptr) add_focusable(s_runtime.connect_btn); } - if (preferred_focus != nullptr && lv_obj_is_valid(preferred_focus)) + if (preferred_focus != nullptr && lv_obj_is_valid(preferred_focus) && + preferred_focus != s_runtime.installed_filter_btn && preferred_focus != s_runtime.uninstalled_filter_btn) { lv_group_focus_obj(preferred_focus); } - else if (s_runtime.installed_filter_btn != nullptr) + else if (!s_runtime.list_buttons.empty()) { - lv_group_focus_obj(s_runtime.installed_filter_btn); + lv_group_focus_obj(s_runtime.list_buttons.front()); } else if (s_runtime.top_bar.back_btn != nullptr) { @@ -814,6 +819,7 @@ void on_filter_clicked(lv_event_t* event) const uintptr_t raw = reinterpret_cast(lv_event_get_user_data(event)); s_runtime.filter = raw == 1U ? PackageFilter::Uninstalled : PackageFilter::Installed; update_filter_button_states(); + s_runtime.menu.close(); show_list_view(); } @@ -1409,6 +1415,11 @@ void enter(const shell::Host* host, lv_obj_t* parent) create_filter_panel(s_runtime.content); create_main_panel(s_runtime.content); + auto* bar = ::ui::components::bottom_bar::create(s_runtime.root); + auto* menu_button = ::ui::components::bottom_bar::add_button(bar, "Menu"); + s_runtime.menu.init(s_runtime.root, menu_button, s_runtime.filter_panel, + [](void*, bool) + { sync_focus_group(); }); refresh_catalog_and_render(); sync_install_ui(false); s_runtime.install_timer = lv_timer_create(install_timer_cb, 250, nullptr); @@ -1426,6 +1437,7 @@ void enter(const shell::Host* host, lv_obj_t* parent) void exit(lv_obj_t* parent) { (void)parent; + s_runtime.menu.reset(); if (s_runtime.install_timer != nullptr) { lv_timer_del(s_runtime.install_timer); diff --git a/modules/ui_shared/src/ui/screens/gps/gps_page_runtime.cpp b/modules/ui_shared/src/ui/screens/gps/gps_page_runtime.cpp index ec99acea..ec12b24f 100644 --- a/modules/ui_shared/src/ui/screens/gps/gps_page_runtime.cpp +++ b/modules/ui_shared/src/ui/screens/gps/gps_page_runtime.cpp @@ -18,6 +18,7 @@ using Projection = gps::ui::shell::Projection; #include "sys/clock.h" #include "track_sharing/installed_package_path.h" #include "ui/app_runtime.h" +#include "ui/components/bottom_bar.h" #include "ui/components/map_poi_search.h" #include "ui/formatters.h" #include "ui/localization.h" @@ -181,6 +182,10 @@ enum class MapControlAction : uint8_t Menu, Navigation, Team, + LayerBack, + LayerOsm, + LayerTerrain, + LayerSatellite, }; const Host* s_host = nullptr; @@ -224,9 +229,11 @@ lv_obj_t* s_map_help_btn = nullptr; lv_obj_t* s_map_tracker_btn = nullptr; lv_obj_t* s_map_menu_btn = nullptr; lv_obj_t* s_map_menu = nullptr; +::ui::components::bottom_bar::Menu s_map_popup{}; lv_obj_t* s_map_navigation_btn = nullptr; bool s_select_navigation = false; bool s_team_panel_visible = false; +bool s_layer_menu_visible = false; lv_obj_t* s_map_menu_panel = nullptr; lv_obj_t* s_map_team_btn = nullptr; lv_obj_t* s_map_altitude_panel = nullptr; @@ -280,7 +287,7 @@ void root_key_event_cb(lv_event_t* e); void open_map_help_modal(); void open_tracker_modal(bool navigation = false); void close_map_menu(); -void open_map_menu(bool team = false); +void open_map_menu(bool team = false, bool layers = false); void refresh_map_menu_labels(); void on_map_control_clicked(lv_event_t* event); void add_map_controls_to_group(lv_group_t* group); @@ -651,6 +658,7 @@ void set_button_label(lv_obj_t* btn, const char* text) void clear_map_controls() { + s_map_popup.reset(); if (s_route_navigation) s_route_navigation->cancel(); ::ui::components::map_poi_search::close(); s_map_search_btn = nullptr; @@ -668,6 +676,7 @@ void clear_map_controls() s_map_menu_btn = s_map_menu = s_map_navigation_btn = nullptr; s_select_navigation = false; s_team_panel_visible = false; + s_layer_menu_visible = false; s_map_menu_panel = nullptr; s_map_team_btn = nullptr; s_map_altitude_panel = nullptr; @@ -1908,6 +1917,10 @@ void sync_map_control_labels(const ::ui::map::MapWorkspaceSnapshot& snapshot) } set_hidden(s_map_control_bar, false); + const bool team_was_visible = map_control_visible(s_map_team_btn); + set_hidden(s_map_team_btn, !snapshot.team.available); + if (!snapshot.team.available && s_team_panel_visible) close_map_menu(); + else if (team_was_visible != snapshot.team.available) rebuild_map_control_group(); sync_route_elevation_profile(); sync_map_altitude_overlay(); @@ -3230,10 +3243,8 @@ void center_map_on_self() request_refresh_view(); } -void cycle_map_layer() +void select_map_layer(uint8_t next_source) { - const auto layer_state = ::ui::widgets::map::current_layer_state(); - const uint8_t next_source = static_cast((layer_state.map_source + 1U) % 3U); ::ui::widgets::map::LayerNotice notice{}; (void)::ui::widgets::map::set_layer_map_source(next_source, ¬ice); if (notice.has_message) @@ -3253,6 +3264,11 @@ void cycle_map_layer() request_refresh_view(); } +void cycle_map_layer() +{ + select_map_layer(static_cast((::ui::widgets::map::current_layer_state().map_source + 1U) % 3U)); +} + void toggle_map_contour() { ::ui::widgets::map::LayerNotice notice{}; @@ -3823,6 +3839,13 @@ void on_map_control_clicked(lv_event_t* e) if (!was_same) open_map_menu(team); return; } + if (action == MapControlAction::Layer || action == MapControlAction::LayerBack) + { + close_map_menu(); + open_map_menu(false, action == MapControlAction::Layer); + if (action == MapControlAction::LayerBack && app_g && s_map_layer_btn) lv_group_focus_obj(s_map_layer_btn); + return; + } close_map_menu(); switch (action) { @@ -3848,7 +3871,12 @@ void on_map_control_clicked(lv_event_t* e) break; } case MapControlAction::Layer: - cycle_map_layer(); + case MapControlAction::LayerBack: + break; + case MapControlAction::LayerOsm: + case MapControlAction::LayerTerrain: + case MapControlAction::LayerSatellite: + select_map_layer(static_cast(action) - static_cast(MapControlAction::LayerOsm)); break; case MapControlAction::Contour: toggle_map_contour(); @@ -3890,10 +3918,31 @@ bool handle_map_key(uint32_t key, lv_event_t* e) { if (s_map_menu) { - if (key == LV_KEY_ESC || key == LV_KEY_BACKSPACE || key == 'm' || key == 'M') + if (key == LV_KEY_RIGHT && app_g && lv_group_get_focused(app_g) == s_map_layer_btn) { consume_key_event(e); close_map_menu(); + open_map_menu(false, true); + return true; + } + if (key == LV_KEY_ESC || key == LV_KEY_BACKSPACE || key == 'm' || key == 'M') + { + const bool back = s_layer_menu_visible && key != 'm' && key != 'M'; + consume_key_event(e); + close_map_menu(); + if (back) + { + open_map_menu(); + if (app_g && s_map_layer_btn) lv_group_focus_obj(s_map_layer_btn); + } + return true; + } + if (key == LV_KEY_LEFT && s_layer_menu_visible) + { + consume_key_event(e); + close_map_menu(); + open_map_menu(); + if (app_g && s_map_layer_btn) lv_group_focus_obj(s_map_layer_btn); return true; } return false; @@ -4122,7 +4171,7 @@ void add_map_controls_to_group(lv_group_t* group) auto* child = lv_obj_get_child(s_map_menu_panel, index); if (lv_obj_has_flag(child, LV_OBJ_FLAG_CLICKABLE)) lv_group_add_obj(group, child); } - if (s_map_team_btn) lv_group_add_obj(group, s_map_team_btn); + if (map_control_visible(s_map_team_btn)) lv_group_add_obj(group, s_map_team_btn); if (s_map_menu_btn) lv_group_add_obj(group, s_map_menu_btn); return; } @@ -4136,7 +4185,7 @@ void add_map_controls_to_group(lv_group_t* group) if (s_map_contour_btn) lv_group_add_obj(group, s_map_contour_btn); if (s_map_tracker_btn) lv_group_add_obj(group, s_map_tracker_btn); if (s_map_help_btn) lv_group_add_obj(group, s_map_help_btn); - if (s_map_team_btn) lv_group_add_obj(group, s_map_team_btn); + if (map_control_visible(s_map_team_btn)) lv_group_add_obj(group, s_map_team_btn); if (s_map_menu_btn) lv_group_add_obj(group, s_map_menu_btn); if (s_map_navigation_btn) lv_group_add_obj(group, s_map_navigation_btn); if (map_control_visible(s_map_route_btn)) lv_group_add_obj(group, s_map_route_btn); @@ -4156,8 +4205,10 @@ void close_map_menu() s_map_menu = nullptr; const bool team = s_team_panel_visible; s_team_panel_visible = false; + s_layer_menu_visible = false; s_map_menu_panel = nullptr; s_map_search_btn = s_map_layer_btn = s_map_contour_btn = s_map_tracker_btn = s_map_navigation_btn = nullptr; + s_map_popup.reset(); if (lv_obj_is_valid(menu)) lv_obj_del(menu); rebuild_map_control_group(); if (app_g && map_control_visible(team ? s_map_team_btn : s_map_menu_btn)) lv_group_focus_obj(team ? s_map_team_btn : s_map_menu_btn); @@ -4167,51 +4218,23 @@ void refresh_map_menu_labels() { if (!s_map_menu) return; const auto layers = ::ui::widgets::map::current_layer_state(); - const char* source = layers.map_source == 1 ? "Terrain" : layers.map_source == 2 ? "Satellite" - : "OSM"; - const auto label = ::ui::i18n::format("Map layer: %s", ::ui::i18n::tr(source)); + const auto label = std::string(::ui::i18n::tr("Map Layer")) + " " LV_SYMBOL_RIGHT; set_button_label(s_map_layer_btn, label.c_str()); set_button_label(s_map_contour_btn, ::ui::i18n::tr(layers.contour_enabled ? "Hide contours" : "Show contours")); } -void open_map_menu(bool team) +void open_map_menu(bool team, bool layers) { if (s_map_menu || s_location_request || !s_map_viewport || !lv_obj_is_valid(s_map_viewport)) return; - s_map_menu = lv_obj_create(s_map_viewport); + if (team && !map_workspace_model().snapshot().team.available) return; + auto* panel = s_map_menu = s_map_menu_panel = lv_obj_create(s_map_viewport); s_team_panel_visible = team; - lv_obj_set_size(s_map_menu, LV_PCT(100), LV_PCT(100)); - lv_obj_add_flag(s_map_menu, LV_OBJ_FLAG_IGNORE_LAYOUT); - lv_obj_set_style_pad_all(s_map_menu, 0, 0); - lv_obj_set_style_border_width(s_map_menu, 0, 0); - lv_obj_set_style_radius(s_map_menu, 0, 0); - lv_obj_set_style_bg_opa(s_map_menu, LV_OPA_TRANSP, 0); - lv_obj_clear_flag(s_map_menu, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_add_event_cb( - s_map_menu, [](lv_event_t* event) - { if (lv_event_get_target(event) == s_map_menu) close_map_menu(); }, - LV_EVENT_CLICKED, nullptr); - auto* panel = s_map_menu_panel = lv_obj_create(s_map_menu); - const auto width = std::min(228, lv_obj_get_width(s_map_viewport) - 8); - const auto available = std::max(48, lv_obj_get_height(s_map_viewport) - kMapControlBarHeight - 8); - lv_obj_set_size(panel, width, LV_SIZE_CONTENT); - lv_obj_set_style_max_height(panel, available, 0); - const lv_coord_t right = team ? std::min(kMapControlButtonWideWidth + 6, std::max(3, lv_obj_get_width(s_map_viewport) - width - 3)) : 3; - lv_obj_align(panel, LV_ALIGN_BOTTOM_RIGHT, -right, -(kMapControlBarHeight + 3)); - lv_obj_set_flex_flow(panel, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_all(panel, 3, 0); - lv_obj_set_style_pad_row(panel, 2, 0); - lv_obj_set_style_bg_color(panel, lv_color_hex(0xFFF3DF), 0); - lv_obj_set_style_border_color(panel, lv_color_hex(0x8A6E43), 0); - lv_obj_set_style_border_width(panel, 1, 0); - lv_obj_set_style_radius(panel, 2, 0); - lv_obj_set_style_shadow_width(panel, 8, 0); - lv_obj_set_scroll_dir(panel, LV_DIR_VER); - lv_obj_set_scrollbar_mode(panel, LV_SCROLLBAR_MODE_AUTO); + s_layer_menu_visible = layers; + const auto width = lv_obj_get_width(s_map_viewport) - 8; auto item = [&](const char* text, MapControlAction action) { auto* button = create_map_control_button(panel, width - 10, ::ui::i18n::tr(text), action); lv_obj_set_height(button, ::ui::page_profile::current().large_touch_hitbox ? 44 : 28); - lv_obj_set_style_text_align(lv_obj_get_child(button, 0), LV_TEXT_ALIGN_LEFT, 0); return button; }; lv_obj_t* first = nullptr; @@ -4234,16 +4257,40 @@ void open_map_menu(bool team) ::ui::i18n::set_label_text(empty, "No team"); } } + else if (layers) + { + auto* back = item("Map Layer", MapControlAction::LayerBack); + set_button_label(back, (std::string(LV_SYMBOL_LEFT " ") + ::ui::i18n::tr("Map Layer")).c_str()); + const auto selected = ::ui::widgets::map::current_layer_state().map_source; + for (uint8_t source = 0; source < 3; ++source) + { + auto* button = item(source == 0 ? "OSM" : source == 1 ? "Terrain" + : "Satellite", + static_cast(static_cast(MapControlAction::LayerOsm) + source)); + if (source == selected) + { + lv_obj_add_state(button, LV_STATE_CHECKED); + auto* label = lv_obj_get_child(button, 0); + set_button_label(button, (std::string(LV_SYMBOL_OK " ") + lv_label_get_text(label)).c_str()); + first = button; + } + } + } else { s_map_contour_btn = item("Show contours", MapControlAction::Contour); - s_map_layer_btn = item("Map layer: %s", MapControlAction::Layer); - s_map_search_btn = item("Search POI", MapControlAction::Search); + s_map_layer_btn = item("Map Layer", MapControlAction::Layer); + s_map_search_btn = item("Search", MapControlAction::Search); s_map_navigation_btn = item("Load navigation", MapControlAction::Navigation); s_map_tracker_btn = item("Load track", MapControlAction::Tracker); first = s_map_contour_btn; } refresh_map_menu_labels(); + s_map_popup.init(s_map_viewport, team ? s_map_team_btn : s_map_menu_btn, panel, + [](void*, bool open) + { if (!open) close_map_menu(); }, + nullptr, {false, false}); + s_map_popup.open(); // Keep the taskbar above the transparent dismiss surface so Menu can // toggle the popup and Pos/Help remain directly accessible. lv_obj_move_foreground(s_map_control_bar); @@ -4257,7 +4304,7 @@ void create_map_control_bar(lv_obj_t* viewport) const lv_coord_t small = narrow ? 20 : kMapControlButtonSmallWidth; const lv_coord_t medium = narrow ? 28 : kMapControlButtonMediumWidth; const lv_coord_t wide = narrow ? 34 : kMapControlButtonWideWidth; - s_map_control_bar = lv_obj_create(viewport); + s_map_control_bar = ::ui::components::bottom_bar::create(viewport); lv_obj_set_size(s_map_control_bar, LV_PCT(100), kMapControlBarHeight); lv_obj_align(s_map_control_bar, LV_ALIGN_BOTTOM_MID, 0, 0); lv_obj_set_flex_flow(s_map_control_bar, LV_FLEX_FLOW_ROW); @@ -4265,18 +4312,9 @@ void create_map_control_bar(lv_obj_t* viewport) LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_bg_color(s_map_control_bar, lv_color_hex(0xFFF3DF), 0); lv_obj_set_style_bg_opa(s_map_control_bar, LV_OPA_90, 0); - lv_obj_set_style_border_width(s_map_control_bar, 1, 0); - lv_obj_set_style_border_color(s_map_control_bar, lv_color_hex(0xB3915D), 0); - lv_obj_set_style_radius(s_map_control_bar, 0, 0); - lv_obj_set_style_pad_left(s_map_control_bar, 3, 0); - lv_obj_set_style_pad_right(s_map_control_bar, 3, 0); lv_obj_set_style_pad_top(s_map_control_bar, 2, 0); lv_obj_set_style_pad_bottom(s_map_control_bar, 2, 0); - lv_obj_set_style_pad_column(s_map_control_bar, 3, 0); - lv_obj_clear_flag(s_map_control_bar, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_scrollbar_mode(s_map_control_bar, LV_SCROLLBAR_MODE_OFF); bind_map_key_handler(s_map_control_bar); s_map_zoom_out_btn = create_map_control_button( @@ -4322,6 +4360,7 @@ void create_map_control_bar(lv_obj_t* viewport) MapControlAction::Help); s_map_team_btn = create_map_control_button( s_map_control_bar, wide, ::ui::i18n::tr("Team"), MapControlAction::Team); + set_hidden(s_map_team_btn, !map_workspace_model().snapshot().team.available); s_map_menu_btn = create_map_control_button( s_map_control_bar, wide, ::ui::i18n::tr("Menu"), MapControlAction::Menu); lv_obj_move_foreground(s_map_control_bar); diff --git a/modules/ui_shared/src/ui/screens/network/network_page_shell.cpp b/modules/ui_shared/src/ui/screens/network/network_page_shell.cpp index 11a23524..0609f275 100644 --- a/modules/ui_shared/src/ui/screens/network/network_page_shell.cpp +++ b/modules/ui_shared/src/ui/screens/network/network_page_shell.cpp @@ -8,6 +8,7 @@ #include "platform/ui/reticulum_page_runtime.h" #include "ui/app_runtime.h" #include "ui/assets/fonts/font_utils.h" +#include "ui/components/bottom_bar.h" #include "ui/components/floating_search_box.h" #include "ui/components/shortcut_help_modal.h" #include "ui/components/two_pane_layout.h" @@ -220,6 +221,8 @@ struct MicronRenderState struct NetworkPageState { + ::ui::components::bottom_bar::Menu menu{}; + lv_obj_t* bottom_bar = nullptr; lv_obj_t* root = nullptr; lv_obj_t* header = nullptr; lv_obj_t* content = nullptr; @@ -1686,23 +1689,38 @@ void rebuild_focus_group(lv_obj_t* preferred) lv_obj_t* previous = lv_group_get_focused(app_g); lv_group_remove_all_objs(app_g); + if (g_state.menu.is_open()) + { + add_directory_focusables(); + set_default_group(app_g); + lv_group_focus_obj(g_state.directory_mode == DirectoryMode::Favourites ? g_state.favourites_tab : g_state.announces_tab); + return; + } + if (!g_state.immersive) { add_visible_to_group(g_state.top_bar.back_btn); } add_directory_focusables(); add_browser_focusables(); + add_visible_to_group(g_state.menu.button()); set_default_group(app_g); (void)focus_preferred_or_fallback(preferred, previous); } void focus_browser_viewport() { + g_state.menu.close(); rebuild_focus_group(g_state.viewport); } void focus_directory_panel() { + if (g_state.menu.button() && !g_state.menu.is_open()) + { + g_state.menu.open(); + return; + } if (g_state.directory_collapsed) { return; @@ -1732,6 +1750,7 @@ void focus_directory_panel() void navigate_to_address(const char* address, bool push) { + g_state.menu.close(); const char* target = (address && address[0] != '\0') ? address : "home:/"; if (push) { @@ -4740,13 +4759,13 @@ void apply_layout_state() { const bool immersive = g_state.immersive; const bool hide_directory = immersive || g_state.directory_collapsed; - const bool hide_browser = !immersive && g_state.browser_collapsed; + const bool hide_browser = !g_state.menu.button() && !immersive && g_state.browser_collapsed; if (g_state.header) { immersive ? lv_obj_add_flag(g_state.header, LV_OBJ_FLAG_HIDDEN) : lv_obj_clear_flag(g_state.header, LV_OBJ_FLAG_HIDDEN); } - if (g_state.directory_panel) + if (g_state.directory_panel && !g_state.menu.button()) { hide_directory ? lv_obj_add_flag(g_state.directory_panel, LV_OBJ_FLAG_HIDDEN) : lv_obj_clear_flag(g_state.directory_panel, LV_OBJ_FLAG_HIDDEN); @@ -4759,6 +4778,12 @@ void apply_layout_state() hide_browser ? lv_obj_add_flag(g_state.browser_panel, LV_OBJ_FLAG_HIDDEN) : lv_obj_clear_flag(g_state.browser_panel, LV_OBJ_FLAG_HIDDEN); } + if (g_state.bottom_bar) + { + immersive ? lv_obj_add_flag(g_state.bottom_bar, LV_OBJ_FLAG_HIDDEN) + : lv_obj_clear_flag(g_state.bottom_bar, LV_OBJ_FLAG_HIDDEN); + if (immersive) g_state.menu.close(); + } if (g_state.browser_toolbar) { immersive ? lv_obj_add_flag(g_state.browser_toolbar, LV_OBJ_FLAG_HIDDEN) @@ -4821,6 +4846,11 @@ void open_node_list_page() void toggle_browser_panel() { + if (g_state.menu.button()) + { + g_state.menu.toggle(); + return; + } if (g_state.immersive) { g_state.immersive = false; @@ -5179,6 +5209,12 @@ void page_shortcut_event_cb(lv_event_t* event) } const uint32_t key = lv_event_get_key(event); lv_obj_t* target = lv_event_get_target_obj(event); + if (g_state.menu.is_open() && (key == LV_KEY_ESC || key == LV_KEY_BACKSPACE)) + { + g_state.menu.close(); + lv_event_stop_processing(event); + return; + } if (g_state.viewport_scroll_locked && (key == LV_KEY_ESC || key == LV_KEY_BACKSPACE)) { unlock_viewport_scroll(event); @@ -5629,6 +5665,17 @@ void enter(void* /*user_data*/, lv_obj_t* parent) create_directory_panel(g_state.content); create_browser_panel(g_state.content); + g_state.bottom_bar = ::ui::components::bottom_bar::create(g_state.root); + auto* menu_button = ::ui::components::bottom_bar::add_button(g_state.bottom_bar, "Menu"); + lv_obj_add_event_cb(menu_button, page_shortcut_event_cb, LV_EVENT_KEY, nullptr); + lv_obj_set_style_min_width(g_state.directory_panel, 180, LV_PART_MAIN); + lv_obj_set_style_min_height(g_state.directory_panel, 160, LV_PART_MAIN); + g_state.menu.init(g_state.root, menu_button, g_state.directory_panel, + [](void*, bool open) + { + g_state.directory_collapsed = !open; + rebuild_focus_group(g_state.viewport); + }); lv_obj_add_event_cb(g_state.root, back_event_cb, LV_EVENT_KEY, nullptr); lv_obj_add_event_cb(g_state.root, page_shortcut_event_cb, LV_EVENT_KEY, nullptr); @@ -5659,6 +5706,7 @@ void exit(void* /*user_data*/, lv_obj_t* /*parent*/) { return; } + g_state.menu.reset(); NETWORK_PAGE_LOG("exit begin root=%u rows=%u links=%u history=%u\n", g_state.root && lv_obj_is_valid(g_state.root) ? 1U : 0U, diff --git a/modules/ui_shared/src/ui/screens/settings/settings_page_input.cpp b/modules/ui_shared/src/ui/screens/settings/settings_page_input.cpp index 06c9a0da..bd3b1887 100644 --- a/modules/ui_shared/src/ui/screens/settings/settings_page_input.cpp +++ b/modules/ui_shared/src/ui/screens/settings/settings_page_input.cpp @@ -95,6 +95,7 @@ static bool handle_list_back_activate(void* /*ctx*/, lv_obj_t* back_button) static Adapter make_adapter() { Adapter adapter{}; + adapter.filter_panel = g_state.filter_panel; adapter.get_key_target = get_key_target; adapter.get_top_back_button = get_top_back_button; adapter.get_filter_count = get_filter_count; diff --git a/modules/ui_shared/src/ui/screens/tracker/tracker_page_components.cpp b/modules/ui_shared/src/ui/screens/tracker/tracker_page_components.cpp index ee8ca0d3..4fe3d818 100644 --- a/modules/ui_shared/src/ui/screens/tracker/tracker_page_components.cpp +++ b/modules/ui_shared/src/ui/screens/tracker/tracker_page_components.cpp @@ -10,6 +10,7 @@ #include "track_sharing/installed_package_path.h" #include "ui/app_runtime.h" #include "ui/assets/fonts/font_utils.h" +#include "ui/components/bottom_bar.h" #include "ui/localization.h" #include "ui/page/page_profile.h" #include "ui/runtime/ui_feedback.h" @@ -2356,10 +2357,7 @@ void close_route_preview_page() state.route_preview_load_btn = nullptr; state.route_preview_load_label = nullptr; clear_preview_model(); - if (state.filter_panel) - { - lv_obj_clear_flag(state.filter_panel, LV_OBJ_FLAG_HIDDEN); - } + if (state.bottom_bar) lv_obj_clear_flag(state.bottom_bar, LV_OBJ_FLAG_HIDDEN); if (state.list_panel) { lv_obj_clear_flag(state.list_panel, LV_OBJ_FLAG_HIDDEN); @@ -2786,6 +2784,7 @@ void open_route_preview_page() { lv_obj_add_flag(state.list_panel, LV_OBJ_FLAG_HIDDEN); } + if (state.bottom_bar) lv_obj_add_flag(state.bottom_bar, LV_OBJ_FLAG_HIDDEN); ::ui::widgets::top_bar_set_title(state.top_bar, ::ui::i18n::tr("Route Preview")); state.route_preview_page = lv_obj_create(state.content); @@ -2982,14 +2981,7 @@ void set_mode(TrackerPageState::Mode mode) update_mode_buttons(); if (state.bottom_bar) { - if (mode != TrackerPageState::Mode::Route) - { - lv_obj_clear_flag(state.bottom_bar, LV_OBJ_FLAG_HIDDEN); - } - else - { - lv_obj_add_flag(state.bottom_bar, LV_OBJ_FLAG_HIDDEN); - } + lv_obj_clear_flag(state.bottom_bar, LV_OBJ_FLAG_HIDDEN); } if (state.start_stop_btn) { @@ -4179,7 +4171,7 @@ void init_page(lv_obj_t* parent) lv_obj_set_size(state.mode_navigation_btn, LV_PCT(100), filter_button_height()); state.mode_navigation_label = lv_label_create(state.mode_navigation_btn); lv_obj_add_style(state.mode_navigation_label, &s_btn_label, LV_PART_MAIN); - ::ui::i18n::set_label_text(state.mode_navigation_label, "Navigation"); + ::ui::i18n::set_label_text(state.mode_navigation_label, "Nav"); lv_obj_center(state.mode_navigation_label); lv_obj_add_event_cb(state.mode_navigation_btn, on_mode_navigation, LV_EVENT_CLICKED, nullptr); lv_obj_add_event_cb(state.mode_navigation_btn, on_mode_navigation, LV_EVENT_FOCUSED, nullptr); @@ -4198,7 +4190,7 @@ void init_page(lv_obj_t* parent) lv_obj_set_scroll_dir(state.list_container, LV_DIR_VER); lv_obj_set_scrollbar_mode(state.list_container, LV_SCROLLBAR_MODE_AUTO); - state.bottom_bar = layout::create_bottom_bar(state.list_panel); + state.bottom_bar = ::ui::components::bottom_bar::create(state.root); state.start_stop_btn = lv_btn_create(state.bottom_bar); lv_obj_set_size(state.start_stop_btn, bottom_bar_button_width(), filter_button_height()); state.start_stop_label = lv_label_create(state.start_stop_btn); diff --git a/modules/ui_shared/src/ui/screens/tracker/tracker_page_input.cpp b/modules/ui_shared/src/ui/screens/tracker/tracker_page_input.cpp index 1c0d6021..8db92db7 100644 --- a/modules/ui_shared/src/ui/screens/tracker/tracker_page_input.cpp +++ b/modules/ui_shared/src/ui/screens/tracker/tracker_page_input.cpp @@ -192,6 +192,8 @@ static void on_column_changed(void* /*ctx*/, NavFocusColumn column) static Adapter make_adapter() { Adapter adapter{}; + adapter.filter_panel = tracker::ui::g_tracker_state.filter_panel; + adapter.bottom_bar = tracker::ui::g_tracker_state.bottom_bar; adapter.is_alive = is_alive; adapter.get_key_target = get_key_target; adapter.get_top_back_button = get_top_back_button; diff --git a/modules/ui_shared/src/ui/screens/tracker/tracker_page_layout.cpp b/modules/ui_shared/src/ui/screens/tracker/tracker_page_layout.cpp index 43f079e2..6c7f76b2 100644 --- a/modules/ui_shared/src/ui/screens/tracker/tracker_page_layout.cpp +++ b/modules/ui_shared/src/ui/screens/tracker/tracker_page_layout.cpp @@ -24,16 +24,6 @@ const ::ui::page_profile::PageLayoutProfile& page_profile() return ::ui::page_profile::current(); } -lv_coord_t panel_gap() -{ - const auto& profile = page_profile(); - if (profile.dense) - { - return 0; - } - return profile.large_touch_hitbox ? 6 : 3; -} - lv_coord_t panel_pad() { const auto& profile = page_profile(); @@ -148,25 +138,6 @@ lv_obj_t* create_list_container(lv_obj_t* list_panel) return container; } -lv_obj_t* create_bottom_bar(lv_obj_t* list_panel) -{ - const auto& profile = page_profile(); - lv_obj_t* bar = lv_obj_create(list_panel); - lv_obj_set_size(bar, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_flex_flow(bar, LV_FLEX_FLOW_ROW); - lv_obj_set_style_pad_column(bar, panel_gap(), LV_PART_MAIN); - lv_obj_set_style_pad_left(bar, profile.list_panel_pad_left, LV_PART_MAIN); - lv_obj_set_style_pad_right(bar, profile.list_panel_pad_right, LV_PART_MAIN); - lv_obj_set_style_pad_top(bar, panel_pad(), LV_PART_MAIN); - lv_obj_set_style_pad_bottom(bar, profile.list_panel_margin_bottom, LV_PART_MAIN); - lv_obj_set_flex_align(bar, LV_FLEX_ALIGN_END, - LV_FLEX_ALIGN_CENTER, - LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_bg_color(bar, lv_color_hex(0xFAF0D8), 0); - apply_base_container_style(bar); - return bar; -} - } // namespace layout } // namespace ui } // namespace tracker diff --git a/packs/ar/locales/ar/strings.tsv b/packs/ar/locales/ar/strings.tsv index 4cca5c46..7382ddef 100644 --- a/packs/ar/locales/ar/strings.tsv +++ b/packs/ar/locales/ar/strings.tsv @@ -859,6 +859,7 @@ No saved navigation لا توجد ملاحة محفوظة Help مساعدة Menu القائمة Navigation الملاحة +Nav ملاحة New navigation ملاحة جديدة Current location الموقع الحالي Use current location استخدام الموقع الحالي diff --git a/packs/europe-cyrillic-ext/locales/ru/strings.tsv b/packs/europe-cyrillic-ext/locales/ru/strings.tsv index 25a024f3..ee77e6f0 100644 --- a/packs/europe-cyrillic-ext/locales/ru/strings.tsv +++ b/packs/europe-cyrillic-ext/locales/ru/strings.tsv @@ -859,6 +859,7 @@ No saved navigation Нет сохранённой навигации Help Справка Menu Меню Navigation Навигация +Nav Нав. New navigation Новый маршрут Current location Текущее положение Use current location Использовать текущее положение diff --git a/packs/europe-latin-ext/locales/de/strings.tsv b/packs/europe-latin-ext/locales/de/strings.tsv index d9cfd8a9..dccd43da 100644 --- a/packs/europe-latin-ext/locales/de/strings.tsv +++ b/packs/europe-latin-ext/locales/de/strings.tsv @@ -859,6 +859,7 @@ No saved navigation Keine gespeicherte Navigation Help Hilfe Menu Menü Navigation Navigation +Nav Nav. New navigation Neue Navigation Current location Aktueller Standort Use current location Aktuellen Standort verwenden diff --git a/packs/europe-latin-ext/locales/es/strings.tsv b/packs/europe-latin-ext/locales/es/strings.tsv index 6c6c81ec..9f77f12c 100644 --- a/packs/europe-latin-ext/locales/es/strings.tsv +++ b/packs/europe-latin-ext/locales/es/strings.tsv @@ -859,6 +859,7 @@ No saved navigation No hay navegaciones guardadas Help Ayuda Menu Menú Navigation Navegación +Nav Nav. New navigation Nueva navegación Current location Ubicación actual Use current location Usar ubicación actual diff --git a/packs/europe-latin-ext/locales/fr/strings.tsv b/packs/europe-latin-ext/locales/fr/strings.tsv index 2c8e5573..e52bc41d 100644 --- a/packs/europe-latin-ext/locales/fr/strings.tsv +++ b/packs/europe-latin-ext/locales/fr/strings.tsv @@ -859,6 +859,7 @@ No saved navigation Aucune navigation enregistrée Help Aide Menu Menu Navigation Navigation +Nav Nav. New navigation Nouvelle navigation Current location Position actuelle Use current location Utiliser la position actuelle diff --git a/packs/europe-latin-ext/locales/it/strings.tsv b/packs/europe-latin-ext/locales/it/strings.tsv index 9e97ff9f..3ff0f8d1 100644 --- a/packs/europe-latin-ext/locales/it/strings.tsv +++ b/packs/europe-latin-ext/locales/it/strings.tsv @@ -859,6 +859,7 @@ No saved navigation Nessuna navigazione salvata Help Aiuto Menu Menu Navigation Navigazione +Nav Nav. New navigation Nuova navigazione Current location Posizione attuale Use current location Usa posizione attuale diff --git a/packs/europe-latin-ext/locales/nl/strings.tsv b/packs/europe-latin-ext/locales/nl/strings.tsv index 97f2af46..e72721b8 100644 --- a/packs/europe-latin-ext/locales/nl/strings.tsv +++ b/packs/europe-latin-ext/locales/nl/strings.tsv @@ -859,6 +859,7 @@ No saved navigation Geen opgeslagen navigatie Help Hulp Menu Menu Navigation Navigatie +Nav Nav. New navigation Nieuwe navigatie Current location Huidige locatie Use current location Huidige locatie gebruiken diff --git a/packs/europe-latin-ext/locales/pl/strings.tsv b/packs/europe-latin-ext/locales/pl/strings.tsv index c7e5543c..9bf58a35 100644 --- a/packs/europe-latin-ext/locales/pl/strings.tsv +++ b/packs/europe-latin-ext/locales/pl/strings.tsv @@ -859,6 +859,7 @@ No saved navigation Brak zapisanej nawigacji Help Pomoc Menu Menu Navigation Nawigacja +Nav Naw. New navigation Nowa nawigacja Current location Bieżąca lokalizacja Use current location Użyj bieżącej lokalizacji diff --git a/packs/europe-latin-ext/locales/pt-PT/strings.tsv b/packs/europe-latin-ext/locales/pt-PT/strings.tsv index f6505600..831c9c47 100644 --- a/packs/europe-latin-ext/locales/pt-PT/strings.tsv +++ b/packs/europe-latin-ext/locales/pt-PT/strings.tsv @@ -859,6 +859,7 @@ No saved navigation Sem navegações guardadas Help Ajuda Menu Menu Navigation Navegação +Nav Nav. New navigation Nova navegação Current location Localização atual Use current location Usar localização atual diff --git a/packs/ja/locales/ja/strings.tsv b/packs/ja/locales/ja/strings.tsv index 2056472a..2f92055e 100644 --- a/packs/ja/locales/ja/strings.tsv +++ b/packs/ja/locales/ja/strings.tsv @@ -859,6 +859,7 @@ No saved navigation 保存済みのナビはありません Help ヘルプ Menu メニュー Navigation ナビゲーション +Nav ナビ New navigation 新しいナビゲーション Current location 現在地 Use current location 現在地を使う diff --git a/packs/ko/locales/ko/strings.tsv b/packs/ko/locales/ko/strings.tsv index 8a15d01a..2fff4ad1 100644 --- a/packs/ko/locales/ko/strings.tsv +++ b/packs/ko/locales/ko/strings.tsv @@ -859,6 +859,7 @@ No saved navigation 저장된 내비게이션 없음 Help 도움말 Menu 메뉴 Navigation 내비게이션 +Nav 내비 New navigation 새 내비게이션 Current location 현재 위치 Use current location 현재 위치 사용 diff --git a/packs/zh-Hans/locales/zh-Hans/strings.tsv b/packs/zh-Hans/locales/zh-Hans/strings.tsv index c53eff90..28d71964 100644 --- a/packs/zh-Hans/locales/zh-Hans/strings.tsv +++ b/packs/zh-Hans/locales/zh-Hans/strings.tsv @@ -859,6 +859,7 @@ No saved navigation 暂无已保存的导航 Help 帮助 Menu 菜单 Navigation 导航 +Nav 导航 New navigation 新建导航 Current location 当前位置 Use current location 使用当前位置 diff --git a/packs/zh-Hant/locales/zh-Hant/strings.tsv b/packs/zh-Hant/locales/zh-Hant/strings.tsv index d56b7281..3135be39 100644 --- a/packs/zh-Hant/locales/zh-Hant/strings.tsv +++ b/packs/zh-Hant/locales/zh-Hant/strings.tsv @@ -859,6 +859,7 @@ No saved navigation 尚無已儲存的導航 Help 說明 Menu 選單 Navigation 導航 +Nav 導航 New navigation 新增導航 Current location 目前位置 Use current location 使用目前位置 diff --git a/tests/site/tracker-page.test.mjs b/tests/site/tracker-page.test.mjs index 482fe769..1f1d6a43 100644 --- a/tests/site/tracker-page.test.mjs +++ b/tests/site/tracker-page.test.mjs @@ -9,13 +9,20 @@ for(const device of ['pager','tdeck','wio-tracker-l2']) { runtime._native_init(device==='pager'?0:1); const tick=()=>{for(let i=0;i<8;i++)runtime._native_tick(50);}; const snapshot=()=>JSON.parse(runtime.UTF8ToString(runtime._native_snapshot())); - const click=text=>{const button=snapshot().find(item=>item.button&&item.text===text);assert.ok(button,`${device}: missing ${text}`);runtime._native_activate(button.id);tick();}; + const click=text=>{ + if(['Tracks','Routes','Nav'].includes(text)&&!snapshot().some(item=>item.button&&item.text===text)) { + const menu=snapshot().find(item=>item.button&&item.text==='Menu'); + assert.ok(menu,`${device}: category menu remains available`);runtime._native_activate(menu.id);tick(); + } + const button=snapshot().find(item=>item.button&&item.text===text);assert.ok(button,`${device}: missing ${text}`);runtime._native_activate(button.id);tick(); + }; runtime._native_open_page(14);tick(); assert.ok(snapshot().some(item=>item.text==='No tracks yet')); assert.ok(!snapshot().some(item=>item.text==='Back')); const action=snapshot().find(item=>item.button&&item.text==='Start recording'); assert.ok(action); - assert.ok(action.x+action.w>=(device==='pager'?480:320)-8,'Primary action aligns to content right edge'); + const menu=snapshot().find(item=>item.button&&item.text==='Menu'); + assert.ok(menu&&action.x+action.w<=menu.x,'Primary action leaves room for the right-aligned category menu'); click('Start recording'); assert.ok(snapshot().some(item=>item.text==='Recording')); const stop=snapshot().find(item=>item.button&&item.text==='Stop recording'); diff --git a/tests/site/two-pane-layout.test.mjs b/tests/site/two-pane-layout.test.mjs index 5a8ee971..9fb1a22b 100644 --- a/tests/site/two-pane-layout.test.mjs +++ b/tests/site/two-pane-layout.test.mjs @@ -1,39 +1,48 @@ import assert from 'node:assert/strict'; import {readFile} from 'node:fs/promises'; +// Exercise real page sources compiled to WASM. The small LVGL contract test +// separately checks shared-menu object lifetime and activation semantics. for(const [device,width,height] of [['pager',480,222],['tdeck',320,240],['wio-tracker-l2',320,240]]) { const base=new URL(`../../site/assets/native/${device}`,import.meta.url); const {default:create}=await import(`${base}.js`); const bytes=await readFile(new URL(`${base}.data`)); - let framePointer; const runtime=await create({ wasmBinary:await readFile(new URL(`${base}.wasm`)), getPreloadedPackage(){return bytes.buffer.slice(bytes.byteOffset,bytes.byteOffset+bytes.byteLength);}, - onFrame(pointer){framePointer=pointer;},print(){},printErr(){}, + print(){},printErr(){}, }); runtime._native_init(device==='pager'?0:1); - for(const [page,filterText,contentText,maxTextGap] of [[5,'Direct','Alex',16],[12,'Contacts','No nodes',14],[14,'Tracks','Not recording',5]]) { - runtime._native_open_page(page); - for(let i=0;i<5;i++)runtime._native_tick(50); - const snapshot=JSON.parse(runtime.UTF8ToString(runtime._native_snapshot())); - const filter=snapshot.find(item=>item.button&&item.text===filterText); - const content=snapshot.find(item=>!item.button&&item.text===contentText); - assert.ok(filter&&content,`${device}:${page}: both columns render`); - const gap=content.x-(filter.x+filter.w); - // Message text includes the card's own inset; compare actual rendered geometry. - const limit=device==='tdeck'&&page===5?24:maxTextGap; - assert.ok(gap>=0&&gap<=limit,`${device}:${page}: content lost width to a ${gap}px gap`); - // Contacts deliberately places its heading eight pixels inside the card. - const verticalInset=page===12?8:0; - // T-Deck's message name is vertically inset inside its richer info card. - if(device!=='tdeck'||page!==5)assert.ok(Math.abs(content.y-filter.y-verticalInset)<=3,`${device}:${page}: first rows align with their intended inset`); - const pixels=new Uint16Array(runtime.HEAPU8.buffer,framePointer,width*height); - const y=height-50; - const background=pixels[y*width+10]; - // Contacts and Tracker deliberately use colored item cards. Their seam must remain - // continuous, while the content card may contrast with the pane background. - const seamEnd=page===12?content.x-11:page===14?content.x:width-10; - for(let x=10;x{for(let i=0;i<5;i++)runtime._native_tick(50);}; + const snapshot=()=>JSON.parse(runtime.UTF8ToString(runtime._native_snapshot())); + const click=text=>{ + const button=snapshot().find(item=>item.button&&item.text===text); + assert.ok(button,`${device}: missing ${text}`); + runtime._native_activate(button.id);tick(); + }; + for(const [page,filterText,contentText] of [[5,'Direct','Alex'],[12,'Contacts','No nodes'],[14,'Tracks','Not recording'],[11,'Profile'],[8,'Installed'],[9,'Fav']]) { + runtime._native_open_page(page);tick(); + const menu=snapshot().find(item=>item.button&&item.text==='Menu'); + assert.ok(menu,`${device}:${page}: bottom menu exists`); + assert.ok(menu.y>=height-48&&menu.x+menu.w>=width-12,`${device}:${page}: menu is in the bottom-right corner`); + assert.ok(!snapshot().some(item=>item.button&&item.text===filterText),`${device}:${page}: filters do not consume a permanent column`); + if(contentText) { + const content=snapshot().find(item=>!item.button&&item.text===contentText); + assert.ok(content&&content.xitem.button&&item.text===filterText); + assert.ok(filter&&filter.x>=0&&filter.x+filter.w<=width,`${device}:${page}: filter menu fits the viewport`); + assert.ok(filter.yitem.button&&item.text==='Back'),`${device}:${page}: radio Info has a touch return action`); + click('Back'); + assert.ok(snapshot().some(item=>item.button&&item.text===filterText)); + } + runtime._native_key(27);tick(); + assert.equal(runtime._native_page_value(),page,`${device}:${page}: Escape closes the menu before leaving the page`); + assert.ok(!snapshot().some(item=>item.button&&item.text===filterText)); } - console.log(`${device}: compact two-pane geometry, row alignment and continuous background passed.`); + console.log(`${device}: six former two-pane pages use bottom menus, full-width content and menu-first back navigation.`); } diff --git a/tests/tracker_card_view/CMakeLists.txt b/tests/tracker_card_view/CMakeLists.txt index 6e9ba6c5..3a8e7724 100644 --- a/tests/tracker_card_view/CMakeLists.txt +++ b/tests/tracker_card_view/CMakeLists.txt @@ -21,3 +21,10 @@ target_include_directories(tracker_card_view_contract PRIVATE ${REPO}/tests/cont target_link_libraries(tracker_card_view_contract PRIVATE ${LVGL_LIBRARY}) enable_testing() add_test(NAME tracker_card_view_contract COMMAND tracker_card_view_contract) +add_executable(bottom_bar_contract test_bottom_bar.cpp + ${REPO}/modules/ui_shared/src/ui/components/bottom_bar.cpp + ${REPO}/modules/ui_shared/src/ui/components/two_pane_nav.cpp) +target_include_directories(bottom_bar_contract PRIVATE ${REPO}/modules/ui_shared/include ${LVGL_DIR} ${REPO}/tests/contacts_card_view) +target_compile_definitions(bottom_bar_contract PRIVATE LV_CONF_INCLUDE_SIMPLE) +target_link_libraries(bottom_bar_contract PRIVATE ${LVGL_LIBRARY}) +add_test(NAME bottom_bar_contract COMMAND bottom_bar_contract) diff --git a/tests/tracker_card_view/test_bottom_bar.cpp b/tests/tracker_card_view/test_bottom_bar.cpp new file mode 100644 index 00000000..09799bb6 --- /dev/null +++ b/tests/tracker_card_view/test_bottom_bar.cpp @@ -0,0 +1,146 @@ +#include "ui/components/bottom_bar.h" +#include "ui/components/two_pane_nav.h" +#include "ui/localization.h" +#include "ui/page/page_profile.h" +#include +#include +#include + +namespace ui::page_profile +{ +const PageLayoutProfile& current() +{ + static PageLayoutProfile profile{}; + return profile; +} +const lv_font_t* resolve_body_font() { return &lv_font_montserrat_14; } +} // namespace ui::page_profile +namespace ui::i18n +{ +void set_label_text(lv_obj_t* label, const char* text) { lv_label_set_text(label, text); } +} // namespace ui::i18n +void set_default_group(lv_group_t* group) { lv_group_set_default(group); } + +namespace +{ +struct Page +{ + lv_obj_t* root; + lv_obj_t* filters[3]; + lv_obj_t* item; + lv_obj_t* back; + int activations = 0; + int selected = 0; +}; +int info_builds = 0; +void info(lv_obj_t* parent) +{ + ++info_builds; + lv_label_set_text(lv_label_create(parent), "Radio status and modem preset"); +} +} // namespace + +int main() +{ + lv_init(); + for (const auto width : {320, 480, 240}) + { + auto* display = lv_display_create(width, width == 480 ? 222 : 240); + std::vector pixels(width * 240); + lv_display_set_color_format(display, LV_COLOR_FORMAT_RGB565); + lv_display_set_buffers(display, pixels.data(), nullptr, pixels.size() * 2, LV_DISPLAY_RENDER_MODE_FULL); + lv_display_set_flush_cb(display, [](lv_display_t* d, const lv_area_t*, uint8_t*) + { lv_display_flush_ready(d); }); + Page page{}; + page.root = lv_obj_create(lv_screen_active()); + lv_obj_remove_style_all(page.root); + lv_obj_set_size(page.root, LV_PCT(100), LV_PCT(100)); + lv_obj_set_flex_flow(page.root, LV_FLEX_FLOW_COLUMN); + auto* row = lv_obj_create(page.root); + lv_obj_remove_style_all(row); + lv_obj_set_width(row, LV_PCT(100)); + lv_obj_set_height(row, 0); + lv_obj_set_flex_grow(row, 1); + lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); + auto* filters = lv_obj_create(row); + lv_obj_set_size(filters, 90, LV_PCT(100)); + lv_obj_set_flex_flow(filters, LV_FLEX_FLOW_COLUMN); + const char* names[] = {"Tracks", "Routes", "Nav"}; + for (unsigned i = 0; i < 3; ++i) + { + page.filters[i] = ui::components::bottom_bar::add_button(filters, names[i]); + lv_obj_set_height(page.filters[i], 28); + } + auto* main = lv_obj_create(row); + lv_obj_remove_style_all(main); + lv_obj_set_size(main, 0, LV_PCT(100)); + lv_obj_set_flex_grow(main, 1); + page.item = lv_btn_create(main); + page.back = lv_btn_create(page.root); + lv_obj_add_flag(page.back, LV_OBJ_FLAG_HIDDEN); + ui::components::two_pane_nav::Adapter adapter{}; + adapter.ctx = &page; + adapter.filter_panel = filters; + adapter.build_info = info; + adapter.get_key_target = [](void* p) + { return static_cast(p)->root; }; + adapter.get_filter_count = [](void*) -> size_t + { return 3; }; + adapter.get_filter_button = [](void* p, size_t i) + { return static_cast(p)->filters[i]; }; + adapter.get_preferred_filter_index = [](void* p) + { return static_cast(p)->selected; }; + adapter.get_list_count = [](void*) -> size_t + { return 1; }; + adapter.get_list_button = [](void* p, size_t) + { return static_cast(p)->item; }; + adapter.handle_filter_activate = [](void* p, lv_obj_t* button) + { + auto& page = *static_cast(p); + for (int i = 0; i < 3; ++i) + if (page.filters[i] == button) page.selected = i; + ++page.activations; + return true; + }; + ui::components::two_pane_nav::Controller controller; + controller.init(adapter); + auto& menu = controller.raw_binding()->menu; + lv_obj_update_layout(page.root); + assert(lv_obj_get_width(main) == width); + assert(!menu.is_open() && lv_obj_has_flag(filters, LV_OBJ_FLAG_HIDDEN)); + const auto retained = lv_obj_get_child_count(page.root); + lv_obj_send_event(menu.button(), LV_EVENT_CLICKED, nullptr); + assert(menu.is_open() && lv_obj_get_width(filters) < 180); + lv_area_t area{}, bar{}; + lv_obj_get_coords(filters, &area); + lv_obj_get_coords(menu.button(), &bar); + assert(area.x1 >= 0 && area.x2 < width && area.y2 < bar.y1); + assert(page.activations == 0); + lv_obj_send_event(page.filters[1], LV_EVENT_CLICKED, nullptr); + assert(page.activations == 1 && page.selected == 1 && !menu.is_open()); + assert(lv_obj_get_width(main) == width); + controller.focus_filter(); + assert(menu.is_open() && lv_group_get_focused(controller.group()) == page.filters[1]); + // Long translations grow within the viewport instead of clipping offscreen. + lv_label_set_text(lv_obj_get_child(page.filters[0], 0), "Navigation auf der Karte laden und anzeigen"); + menu.close(); + menu.open(); + lv_obj_update_layout(filters); + assert(lv_obj_get_width(filters) <= width - 8); + const auto previous_info_builds = info_builds; + lv_obj_send_event(controller.raw_binding()->info_button, LV_EVENT_CLICKED, nullptr); + assert(menu.in_submenu() && info_builds == previous_info_builds + 1); + controller.on_ui_refreshed(); + assert(menu.in_submenu()); + assert(menu.back() && !menu.in_submenu() && menu.is_open()); + uint32_t escape = LV_KEY_ESC; + lv_obj_send_event(page.filters[1], LV_EVENT_KEY, &escape); + assert(!menu.is_open()); + assert(lv_obj_get_child_count(page.root) == retained); + assert(page.activations == 1); + controller.cleanup(); + lv_obj_del(page.root); + lv_display_delete(display); + std::printf("%dpx: full-width content, touch filter, focus, Info/back, close and object reuse passed.\n", width); + } +} diff --git a/tools/web_simulator/CMakeLists.txt b/tools/web_simulator/CMakeLists.txt index a5d08917..00b4e6b2 100644 --- a/tools/web_simulator/CMakeLists.txt +++ b/tools/web_simulator/CMakeLists.txt @@ -137,6 +137,7 @@ target_sources(${PREVIEW_TARGET} PRIVATE ${CHAT_VIEW_SOURCES} "${UI}/components/two_pane_styles.cpp" "${UI}/components/two_pane_layout.cpp" "${UI}/components/two_pane_nav.cpp" + "${UI}/components/bottom_bar.cpp" "${UI}/components/floating_search_box.cpp" "${UI}/components/map_poi_search.cpp" "${UI}/components/air_status_footer.cpp"