From 31ce457192ec528b20a794416dfc0ae0d8cda43c Mon Sep 17 00:00:00 2001 From: DeFiDude <59237470+DeFiDude@users.noreply.github.com> Date: Sat, 28 Mar 2026 14:38:59 -0600 Subject: [PATCH] Add action modal for node selection (Add Contact / Message / Back) Replace direct-message-on-Enter with a 3-option modal overlay: - "Add Contact" prompts for a nickname (pre-filled with announce name, falls back to hex hash if left empty), then saves contact - "Message" opens conversation (previous Enter behavior) - "Back" closes modal (also Esc at any point) Long press on unsaved nodes now also shows the modal instead of silently adding. Long press on saved contacts keeps the existing delete confirmation flow. --- src/ui/screens/LvNodesScreen.cpp | 196 +++++++++++++++++++++++++++++-- src/ui/screens/LvNodesScreen.h | 21 ++++ 2 files changed, 208 insertions(+), 9 deletions(-) diff --git a/src/ui/screens/LvNodesScreen.cpp b/src/ui/screens/LvNodesScreen.cpp index a5a2cf0..da13bd9 100644 --- a/src/ui/screens/LvNodesScreen.cpp +++ b/src/ui/screens/LvNodesScreen.cpp @@ -69,6 +69,59 @@ void LvNodesScreen::createUI(lv_obj_t* parent) { _lastContactCount = -1; updateSortOrder(); syncVisibleRows(); + + // --- Action modal overlay (hidden initially) --- + _overlay = lv_obj_create(parent); + lv_obj_set_size(_overlay, 180, 100); + lv_obj_center(_overlay); + lv_obj_set_style_bg_color(_overlay, lv_color_hex(0x001100), 0); + lv_obj_set_style_bg_opa(_overlay, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(_overlay, 1, 0); + lv_obj_set_style_border_color(_overlay, lv_color_hex(Theme::PRIMARY), 0); + lv_obj_set_style_radius(_overlay, 6, 0); + lv_obj_set_style_pad_all(_overlay, 6, 0); + lv_obj_set_style_pad_row(_overlay, 2, 0); + lv_obj_set_layout(_overlay, LV_LAYOUT_FLEX); + lv_obj_set_flex_flow(_overlay, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(_overlay, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_clear_flag(_overlay, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(_overlay, LV_OBJ_FLAG_HIDDEN); + + const char* menuText[] = {"Add Contact", "Message", "Back"}; + for (int i = 0; i < 3; i++) { + _menuLabels[i] = lv_label_create(_overlay); + lv_obj_set_style_text_font(_menuLabels[i], &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(_menuLabels[i], lv_color_hex(Theme::PRIMARY), 0); + lv_label_set_text(_menuLabels[i], menuText[i]); + } + + // Nickname input widgets (hidden in menu mode) + _nicknameBox = lv_obj_create(_overlay); + lv_obj_set_size(_nicknameBox, 166, LV_SIZE_CONTENT); + lv_obj_set_style_bg_opa(_nicknameBox, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(_nicknameBox, 0, 0); + lv_obj_set_style_pad_all(_nicknameBox, 0, 0); + lv_obj_set_style_pad_row(_nicknameBox, 2, 0); + lv_obj_set_layout(_nicknameBox, LV_LAYOUT_FLEX); + lv_obj_set_flex_flow(_nicknameBox, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(_nicknameBox, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_clear_flag(_nicknameBox, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(_nicknameBox, LV_OBJ_FLAG_HIDDEN); + + lv_obj_t* nickTitle = lv_label_create(_nicknameBox); + lv_obj_set_style_text_font(nickTitle, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(nickTitle, lv_color_hex(Theme::ACCENT), 0); + lv_label_set_text(nickTitle, "Enter nickname:"); + + _nicknameLbl = lv_label_create(_nicknameBox); + lv_obj_set_style_text_font(_nicknameLbl, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(_nicknameLbl, lv_color_hex(Theme::PRIMARY), 0); + lv_label_set_text(_nicknameLbl, "_"); + + _nicknameHint = lv_label_create(_nicknameBox); + lv_obj_set_style_text_font(_nicknameHint, &lv_font_montserrat_10, 0); + lv_obj_set_style_text_color(_nicknameHint, lv_color_hex(Theme::MUTED), 0); + lv_label_set_text(_nicknameHint, "Enter=Save Esc=Cancel"); } void LvNodesScreen::onEnter() { @@ -277,6 +330,62 @@ void LvNodesScreen::scrollToSelected() { } } +// --- Action modal helpers --- + +void LvNodesScreen::showActionMenu(int nodeIdx) { + _actionNodeIdx = nodeIdx; + _menuIdx = 0; + _actionState = NodeAction::ACTION_MENU; + _nicknameText = ""; + if (_overlay) { + for (int i = 0; i < 3; i++) lv_obj_clear_flag(_menuLabels[i], LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(_nicknameBox, LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(_overlay, LV_OBJ_FLAG_HIDDEN); + updateMenuSelection(); + } +} + +void LvNodesScreen::hideOverlay() { + _actionState = NodeAction::BROWSE; + _actionNodeIdx = -1; + _nicknameText = ""; + if (_overlay) lv_obj_add_flag(_overlay, LV_OBJ_FLAG_HIDDEN); +} + +void LvNodesScreen::showNicknameInput() { + _actionState = NodeAction::NICKNAME_INPUT; + // Pre-fill with announce name + if (_am && _actionNodeIdx >= 0 && _actionNodeIdx < (int)_am->nodes().size()) { + _nicknameText = String(_am->nodes()[_actionNodeIdx].name.c_str()); + } + for (int i = 0; i < 3; i++) lv_obj_add_flag(_menuLabels[i], LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(_nicknameBox, LV_OBJ_FLAG_HIDDEN); + updateNicknameDisplay(); +} + +void LvNodesScreen::updateMenuSelection() { + for (int i = 0; i < 3; i++) { + bool sel = (i == _menuIdx); + lv_obj_set_style_text_color(_menuLabels[i], lv_color_hex( + sel ? Theme::BG : Theme::PRIMARY), 0); + lv_obj_set_style_bg_color(_menuLabels[i], lv_color_hex( + sel ? Theme::PRIMARY : 0x001100), 0); + lv_obj_set_style_bg_opa(_menuLabels[i], LV_OPA_COVER, 0); + lv_obj_set_style_pad_left(_menuLabels[i], 8, 0); + lv_obj_set_style_pad_right(_menuLabels[i], 8, 0); + lv_obj_set_style_pad_top(_menuLabels[i], 2, 0); + lv_obj_set_style_pad_bottom(_menuLabels[i], 2, 0); + lv_obj_set_style_radius(_menuLabels[i], 3, 0); + } +} + +void LvNodesScreen::updateNicknameDisplay() { + if (_nicknameLbl) { + String display = _nicknameText + "_"; + lv_label_set_text(_nicknameLbl, display.c_str()); + } +} + bool LvNodesScreen::handleLongPress() { if (!_am || _totalEntries == 0) return false; if (_selectedIdx < 0 || _selectedIdx >= _totalEntries) return false; @@ -287,12 +396,7 @@ bool LvNodesScreen::handleLongPress() { _confirmDelete = true; if (_ui) _ui->lvStatusBar().showToast("Remove friend? Enter=Yes Esc=No", 5000); } else { - auto& mutableNode = const_cast(node); - mutableNode.saved = true; - _am->saveContacts(); - if (_ui) _ui->lvStatusBar().showToast("Added to friends!", 1200); - updateSortOrder(); - syncVisibleRows(); + showActionMenu(nodeIdx); } return true; } @@ -300,6 +404,81 @@ bool LvNodesScreen::handleLongPress() { bool LvNodesScreen::handleKey(const KeyEvent& event) { if (!_am || _totalEntries == 0) return false; + // --- Nickname input mode --- + if (_actionState == NodeAction::NICKNAME_INPUT) { + if (event.enter || event.character == '\n' || event.character == '\r') { + // Save contact with nickname + if (_actionNodeIdx >= 0 && _actionNodeIdx < (int)_am->nodes().size()) { + auto& node = const_cast(_am->nodes()[_actionNodeIdx]); + String finalName = _nicknameText; + finalName.trim(); + if (finalName.isEmpty()) { + // Fallback: announce name → hex hash + if (!node.name.empty()) finalName = String(node.name.c_str()); + else finalName = String(node.hash.toHex().substr(0, 12).c_str()); + } + node.name = finalName.c_str(); + node.saved = true; + _am->saveContacts(); + if (_ui) _ui->lvStatusBar().showToast("Contact saved!", 1200); + hideOverlay(); + updateSortOrder(); + syncVisibleRows(); + } else { + hideOverlay(); + } + return true; + } + if (event.character == 0x1B) { hideOverlay(); return true; } // Esc + if (event.character == '\b' || event.character == 0x7F) { + if (_nicknameText.length() > 0) _nicknameText.remove(_nicknameText.length() - 1); + updateNicknameDisplay(); + return true; + } + if (event.character >= 0x20 && event.character <= 0x7E && _nicknameText.length() < 16) { + _nicknameText += (char)event.character; + updateNicknameDisplay(); + return true; + } + return true; // Consume all keys in input mode + } + + // --- Action menu mode --- + if (_actionState == NodeAction::ACTION_MENU) { + if (event.up) { + if (_menuIdx > 0) { _menuIdx--; updateMenuSelection(); } + return true; + } + if (event.down) { + if (_menuIdx < 2) { _menuIdx++; updateMenuSelection(); } + return true; + } + if (event.enter || event.character == '\n' || event.character == '\r') { + switch (_menuIdx) { + case 0: // Add Contact + showNicknameInput(); + break; + case 1: // Message + if (_actionNodeIdx >= 0 && _actionNodeIdx < (int)_am->nodes().size() && _onSelect) { + std::string hex = _am->nodes()[_actionNodeIdx].hash.toHex(); + hideOverlay(); + _onSelect(hex); + } else { + hideOverlay(); + } + break; + case 2: // Back + hideOverlay(); + break; + } + return true; + } + if (event.character == 0x1B) { hideOverlay(); return true; } // Esc + return true; // Consume all keys in menu mode + } + + // --- Browse mode (normal) --- + // Confirm delete mode if (_confirmDelete) { if (event.enter || event.character == '\n' || event.character == '\r') { @@ -324,7 +503,6 @@ bool LvNodesScreen::handleKey(const KeyEvent& event) { if (event.up) { int prev = _selectedIdx; _selectedIdx--; - // Skip headers while (_selectedIdx >= 0 && getNodeIdxForEntry(_selectedIdx) == -1) _selectedIdx--; if (_selectedIdx < 0) _selectedIdx = prev; if (_selectedIdx != prev) syncVisibleRows(); @@ -340,8 +518,8 @@ bool LvNodesScreen::handleKey(const KeyEvent& event) { } if (event.enter || event.character == '\n' || event.character == '\r') { int nodeIdx = getNodeIdxForEntry(_selectedIdx); - if (nodeIdx >= 0 && nodeIdx < (int)_am->nodes().size() && _onSelect) { - _onSelect(_am->nodes()[nodeIdx].hash.toHex()); + if (nodeIdx >= 0 && nodeIdx < (int)_am->nodes().size()) { + showActionMenu(nodeIdx); } return true; } diff --git a/src/ui/screens/LvNodesScreen.h b/src/ui/screens/LvNodesScreen.h index 03b8603..6fe6259 100644 --- a/src/ui/screens/LvNodesScreen.h +++ b/src/ui/screens/LvNodesScreen.h @@ -31,11 +31,32 @@ private: void scrollToSelected(); int getNodeIdxForEntry(int entry) const; + // Action modal helpers + enum class NodeAction { BROWSE, ACTION_MENU, NICKNAME_INPUT }; + void showActionMenu(int nodeIdx); + void hideOverlay(); + void showNicknameInput(); + void updateMenuSelection(); + void updateNicknameDisplay(); + AnnounceManager* _am = nullptr; class UIManager* _ui = nullptr; UserConfig* _cfg = nullptr; NodeSelectedCallback _onSelect; bool _confirmDelete = false; + + // Action modal state + NodeAction _actionState = NodeAction::BROWSE; + int _menuIdx = 0; + int _actionNodeIdx = -1; + String _nicknameText; + + // Overlay widgets + lv_obj_t* _overlay = nullptr; + lv_obj_t* _menuLabels[3] = {}; + lv_obj_t* _nicknameBox = nullptr; + lv_obj_t* _nicknameLbl = nullptr; + lv_obj_t* _nicknameHint = nullptr; int _lastNodeCount = -1; int _lastContactCount = -1; int _selectedIdx = 0;