#include "LvContactsScreen.h" #include "ui/Theme.h" #include "ui/LvTheme.h" #include "ui/LvInput.h" #include "ui/LxmFaceAvatar.h" #include "ui/UIManager.h" #include "reticulum/AnnounceManager.h" #include #include #include #include "fonts/fonts.h" namespace { constexpr int kContactRowH = 38; constexpr int kContactAvatar = 32; constexpr int kContactTextX = 48; unsigned long nodeAgeMs(const DiscoveredNode& node, unsigned long now) { if (node.lastSeen == 0 || now < node.lastSeen) return ULONG_MAX; return now - node.lastSeen; } std::string displayNameFor(const DiscoveredNode& node) { if (!node.name.empty()) return node.name; std::string hex = node.hash.toHex(); return hex.substr(0, 12); } std::string identityLineFor(const DiscoveredNode& node) { std::string hex = node.hash.toHex(); return "ID: " + hex; } std::string compactAge(unsigned long ageMs) { if (ageMs == ULONG_MAX) return "old"; if (ageMs < 5000) return "now"; unsigned long sec = ageMs / 1000; char buf[12]; if (sec < 60) { snprintf(buf, sizeof(buf), "%lus", sec); } else if (sec < 3600) { snprintf(buf, sizeof(buf), "%lum", sec / 60); } else if (sec < 86400) { snprintf(buf, sizeof(buf), "%luh", sec / 3600); } else { snprintf(buf, sizeof(buf), "%lud", sec / 86400); } return buf; } std::string contactMetaFor(unsigned long ageMs) { return compactAge(ageMs); } lv_obj_t* createEmptyState(lv_obj_t* parent) { lv_obj_t* box = lv_obj_create(parent); lv_obj_set_size(box, 252, 94); lv_obj_set_style_bg_opa(box, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(box, 0, 0); lv_obj_set_style_pad_all(box, 0, 0); lv_obj_clear_flag(box, LV_OBJ_FLAG_SCROLLABLE); lv_obj_center(box); lv_obj_t* head = lv_obj_create(box); lv_obj_set_size(head, 18, 18); lv_obj_set_pos(head, 117, 7); lv_obj_set_style_radius(head, 9, 0); lv_obj_set_style_bg_opa(head, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(head, 2, 0); lv_obj_set_style_border_color(head, lv_color_hex(Theme::PRIMARY), 0); lv_obj_set_style_pad_all(head, 0, 0); lv_obj_clear_flag(head, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* shoulders = lv_obj_create(box); lv_obj_set_size(shoulders, 36, 17); lv_obj_set_pos(shoulders, 108, 27); lv_obj_set_style_radius(shoulders, 8, 0); lv_obj_set_style_bg_opa(shoulders, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(shoulders, 2, 0); lv_obj_set_style_border_color(shoulders, lv_color_hex(Theme::BORDER_ACTIVE), 0); lv_obj_set_style_pad_all(shoulders, 0, 0); lv_obj_clear_flag(shoulders, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* title = lv_label_create(box); lv_obj_set_style_text_font(title, &lv_font_ratdeck_14, 0); lv_obj_set_style_text_color(title, lv_color_hex(Theme::TEXT_SECONDARY), 0); lv_label_set_text(title, "No trusted contacts"); lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 49); lv_obj_t* hint = lv_label_create(box); lv_obj_set_style_text_font(hint, &lv_font_ratdeck_10, 0); lv_obj_set_style_text_color(hint, lv_color_hex(Theme::TEXT_MUTED), 0); lv_label_set_text(hint, "Saved peers appear here"); lv_obj_align(hint, LV_ALIGN_TOP_MID, 0, 70); return box; } } // namespace void LvContactsScreen::createUI(lv_obj_t* parent) { _screen = parent; lv_obj_set_style_bg_color(parent, lv_color_hex(Theme::BG), 0); lv_obj_set_style_pad_all(parent, 0, 0); _emptyState = createEmptyState(parent); _list = lv_obj_create(parent); lv_obj_set_size(_list, lv_pct(100), lv_pct(100)); lv_obj_add_style(_list, LvTheme::styleList(), 0); lv_obj_set_layout(_list, LV_LAYOUT_FLEX); lv_obj_set_flex_flow(_list, LV_FLEX_FLOW_COLUMN); lv_obj_add_flag(_list, LV_OBJ_FLAG_HIDDEN); _lastContactCount = -1; rebuildList(); } void LvContactsScreen::onEnter() { _lastContactCount = -1; _focusActive = false; rebuildList(); } void LvContactsScreen::refreshUI() { if (!_am) return; unsigned long now = millis(); int contacts = 0; for (const auto& n : _am->nodes()) { if (n.saved) contacts++; } if (contacts != _lastContactCount || now - _lastRebuild >= REBUILD_INTERVAL_MS) { rebuildList(); } } void LvContactsScreen::rebuildList() { if (!_am || !_list) return; _lastRebuild = millis(); _contactIndices.clear(); lv_obj_clean(_list); _avatarBuffers.clear(); const auto& nodes = _am->nodes(); for (int i = 0; i < (int)nodes.size(); i++) { if (nodes[i].saved) _contactIndices.push_back(i); } std::sort(_contactIndices.begin(), _contactIndices.end(), [&nodes](int a, int b) { std::string an = displayNameFor(nodes[a]); std::string bn = displayNameFor(nodes[b]); if (an == bn) return nodes[a].hash.toHex() < nodes[b].hash.toHex(); return an < bn; }); int count = (int)_contactIndices.size(); _lastContactCount = count; _avatarBuffers.reserve(count); // Keep list visible even with no contacts so the QR header row is reachable. lv_obj_clear_flag(_list, LV_OBJ_FLAG_HIDDEN); if (_emptyState) { if (count == 0) { lv_obj_clear_flag(_emptyState, LV_OBJ_FLAG_HIDDEN); lv_obj_move_foreground(_emptyState); } else { lv_obj_add_flag(_emptyState, LV_OBJ_FLAG_HIDDEN); } } { lv_obj_t* qrRow = lv_obj_create(_list); lv_obj_set_size(qrRow, Theme::CONTENT_W, 28); lv_obj_add_style(qrRow, LvTheme::styleListBtn(), 0); lv_obj_add_style(qrRow, LvTheme::styleListBtnFocused(), LV_STATE_FOCUSED); lv_obj_set_style_bg_color(qrRow, lv_color_hex(Theme::PRIMARY_SUBTLE), 0); lv_obj_set_style_bg_opa(qrRow, LV_OPA_COVER, 0); lv_obj_set_style_border_side(qrRow, LV_BORDER_SIDE_BOTTOM, 0); lv_obj_set_style_border_width(qrRow, 1, 0); lv_obj_set_style_border_color(qrRow, lv_color_hex(Theme::BORDER), 0); lv_obj_set_style_pad_all(qrRow, 0, 0); lv_obj_clear_flag(qrRow, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_flag(qrRow, LV_OBJ_FLAG_CLICKABLE); // Sentinel marks this as not-a-contact for handleLongPress(). lv_obj_set_user_data(qrRow, (void*)(intptr_t)-1); lv_obj_add_event_cb(qrRow, [](lv_event_t* e) { auto* self = (LvContactsScreen*)lv_event_get_user_data(e); if (self->_showQrCb) self->_showQrCb(); }, LV_EVENT_CLICKED, this); lv_group_add_obj(LvInput::group(), qrRow); lv_obj_add_event_cb(qrRow, [](lv_event_t* e) { lv_obj_scroll_to_view(lv_event_get_target(e), LV_ANIM_ON); }, LV_EVENT_FOCUSED, nullptr); lv_obj_t* qrLbl = lv_label_create(qrRow); lv_obj_set_style_text_font(qrLbl, &lv_font_ratdeck_14, 0); lv_obj_set_style_text_color(qrLbl, lv_color_hex(Theme::ACCENT), 0); lv_label_set_text(qrLbl, "Share My QR"); lv_obj_align(qrLbl, LV_ALIGN_LEFT_MID, 12, 0); lv_obj_t* hintLbl = lv_label_create(qrRow); lv_obj_set_style_text_font(hintLbl, &lv_font_ratdeck_10, 0); lv_obj_set_style_text_color(hintLbl, lv_color_hex(Theme::TEXT_MUTED), 0); lv_label_set_text(hintLbl, "Enter"); lv_obj_align(hintLbl, LV_ALIGN_RIGHT_MID, -12, 0); } unsigned long now = millis(); for (int i = 0; i < count; i++) { int nodeIdx = _contactIndices[i]; const auto& node = nodes[nodeIdx]; unsigned long age = nodeAgeMs(node, now); lv_obj_t* row = lv_obj_create(_list); lv_obj_set_size(row, Theme::CONTENT_W, kContactRowH); lv_obj_add_style(row, LvTheme::styleListBtn(), 0); lv_obj_add_style(row, LvTheme::styleListBtnFocused(), LV_STATE_FOCUSED); lv_obj_set_style_border_side(row, LV_BORDER_SIDE_BOTTOM, 0); lv_obj_set_style_border_width(row, 1, 0); lv_obj_set_style_border_color(row, lv_color_hex(Theme::BORDER), 0); lv_obj_set_style_pad_all(row, 0, 0); lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_flag(row, LV_OBJ_FLAG_CLICKABLE); lv_obj_set_user_data(row, (void*)(intptr_t)i); lv_obj_add_event_cb(row, [](lv_event_t* e) { auto* self = (LvContactsScreen*)lv_event_get_user_data(e); int idx = (int)(intptr_t)lv_obj_get_user_data(lv_event_get_target(e)); if (idx < (int)self->_contactIndices.size() && self->_onSelect) { int nodeIdx = self->_contactIndices[idx]; self->_onSelect(self->_am->nodes()[nodeIdx].hash.toHex()); } }, LV_EVENT_CLICKED, this); lv_group_add_obj(LvInput::group(), row); lv_obj_add_event_cb(row, [](lv_event_t* e) { lv_obj_scroll_to_view(lv_event_get_target(e), LV_ANIM_ON); }, LV_EVENT_FOCUSED, nullptr); std::string hashHex = node.hash.toHex(); _avatarBuffers.emplace_back(LxmFaceAvatar::bufferSize(kContactAvatar)); auto avatar = LxmFaceAvatar::create(row, 8, 3, kContactAvatar, _avatarBuffers.back().data(), Theme::PRIMARY_SUBTLE, Theme::BORDER); LxmFaceAvatar::render(avatar.canvas, String(hashHex.c_str())); lv_obj_t* nameLbl = lv_label_create(row); lv_obj_set_style_text_font(nameLbl, &lv_font_ratdeck_14, 0); lv_obj_set_style_text_color(nameLbl, lv_color_hex(Theme::ACCENT), 0); lv_label_set_long_mode(nameLbl, LV_LABEL_LONG_CLIP); lv_obj_set_width(nameLbl, Theme::CONTENT_W - kContactTextX - 72); std::string name = displayNameFor(node); lv_label_set_text(nameLbl, name.c_str()); lv_obj_set_pos(nameLbl, kContactTextX, 2); lv_obj_t* metaLbl = lv_label_create(row); lv_obj_set_style_text_font(metaLbl, &lv_font_ratdeck_10, 0); lv_obj_set_style_text_color(metaLbl, lv_color_hex(Theme::TEXT_SECONDARY), 0); lv_obj_set_style_text_align(metaLbl, LV_TEXT_ALIGN_RIGHT, 0); lv_label_set_long_mode(metaLbl, LV_LABEL_LONG_CLIP); lv_obj_set_width(metaLbl, 64); std::string meta = contactMetaFor(age); lv_label_set_text(metaLbl, meta.c_str()); lv_obj_set_pos(metaLbl, Theme::CONTENT_W - 72, 5); lv_obj_t* idLbl = lv_label_create(row); lv_obj_set_style_text_font(idLbl, &lv_font_ratdeck_10, 0); lv_obj_set_style_text_color(idLbl, lv_color_hex(Theme::TEXT_MUTED), 0); lv_label_set_long_mode(idLbl, LV_LABEL_LONG_CLIP); lv_obj_set_width(idLbl, Theme::CONTENT_W - kContactTextX - 8); std::string identity = identityLineFor(node); lv_label_set_text(idLbl, identity.c_str()); lv_obj_set_pos(idLbl, kContactTextX, 22); } // Clear auto-focus if user hasn't started navigating yet if (!_focusActive) { lv_obj_t* focused = lv_group_get_focused(LvInput::group()); if (focused) lv_obj_clear_state(focused, LV_STATE_FOCUSED | LV_STATE_FOCUS_KEY); } } bool LvContactsScreen::handleLongPress() { if (!_am || _contactIndices.empty()) return false; // Only consume long-press once the user has actively navigated into // the list. Otherwise let main.cpp blank the screen. if (!_focusActive) return false; lv_obj_t* focused = lv_group_get_focused(LvInput::group()); if (!focused) return false; _deleteIdx = (int)(intptr_t)lv_obj_get_user_data(focused); if (_deleteIdx < 0 || _deleteIdx >= (int)_contactIndices.size()) return false; _confirmDelete = true; if (_ui) _ui->lvStatusBar().showToast("Remove? Enter=Remove Esc=Keep", 5000); return true; } bool LvContactsScreen::handleKey(const KeyEvent& event) { if (!_am || _contactIndices.empty()) return false; if (!_focusActive && (event.up || event.down || event.enter)) { _focusActive = true; lv_obj_t* focused = lv_group_get_focused(LvInput::group()); if (focused) lv_obj_add_state(focused, LV_STATE_FOCUSED | LV_STATE_FOCUS_KEY); return true; } if (_confirmDelete) { if (event.enter || event.character == '\n' || event.character == '\r') { if (_deleteIdx >= 0 && _deleteIdx < (int)_contactIndices.size()) { int nodeIdx = _contactIndices[_deleteIdx]; if (nodeIdx >= 0 && nodeIdx < (int)_am->nodes().size()) { _am->deleteContact(nodeIdx); if (_ui) _ui->lvStatusBar().showToast("Contact removed", 1200); rebuildList(); } } _confirmDelete = false; return true; } _confirmDelete = false; if (_ui) _ui->lvStatusBar().showToast("Kept contact", 800); return true; } return false; }