mirror of
https://github.com/ratspeak/ratdeck.git
synced 2026-05-14 03:15:05 +00:00
d9c92abf6c
Adds an lxma://<hash>:<pubkey> QR overlay reachable from Settings and from a new header row at the top of the Contacts tab. Including the public key lets Columba/Sideband skip the PENDING_IDENTITY round-trip. Contacts list stays visible even with no saved contacts so the header row is always reachable; the empty-state floats above it.
329 lines
13 KiB
C++
329 lines
13 KiB
C++
#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 <Arduino.h>
|
|
#include <algorithm>
|
|
#include <climits>
|
|
#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;
|
|
}
|