Files
MeshCore-Solo/examples/companion_radio/ui-new/NearbyScreen.h
Jakub 97493b490e feat(nodes): on-device add/delete/favourite + circular filter tabs, rename to "Nodes"
Turn Nearby Nodes into the single node hub and make its navigation legible.

Node management (no phone app needed):
- MyMesh gains addDiscoveredContact() and deleteContactByKey(); CMD_REMOVE_CONTACT
  now reuses deleteContactByKey() (one delete path: contact + blob + room password
  + UI cleanup + lazy write).
- Action menu (Hold Enter) gains Add contact (a new scanned node), Favourite /
  Unfavourite (pin to the first free dial slot), and Delete contact (confirm first,
  defaults to Cancel). Actions are offered contextually per row.
- A pinned contact shows a star in the list row, next to any live-share diamond.

Absorb passively-heard adverts: refreshStored() folds getRecentlyHeard() nodes that
aren't already listed as name+age rows (All filter only — AdvertPath has no type),
so "recently heard" no longer needs its own page.

Legible filter navigation: the filter (LEFT/RIGHT) is now a visible tab strip — the
active filter is a centred inverted pill, neighbours fan out and wrap around
(circular, first<->last). Only whole tabs are drawn so labels never wrap a line.

Rename Tools > "Nearby Nodes" to "Nodes".

Both solo envs build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 01:52:51 +02:00

969 lines
41 KiB
C++

#pragma once
#include "../GeoUtils.h"
#include "NavView.h"
// ── Nearby Nodes ──────────────────────────────────────────────────────────────
// One list / detail / action-menu interaction path over two sources:
// SRC_STORED — contacts known to the mesh (distance / bearing / last-heard)
// SRC_SCAN — live NODE_DISCOVER_REQ results (RSSI / SNR / status)
// Filter (type) and sort are independent axes and combine freely. The action
// menu (Hold Enter) is identical everywhere; only the per-row column and the
// detail fields differ between sources.
class NearbyScreen : public UIScreen {
UITask* _task;
// ── filter (type axis) ──────────────────────────────────────────────────────
enum Filter : uint8_t { F_ALL, F_FAV, F_COMP, F_RPT, F_ROOM, F_SNSR, F_COUNT };
static const char* FILTER_LABELS[F_COUNT];
// ── sort axis ───────────────────────────────────────────────────────────────
enum Sort : uint8_t { SORT_DIST, SORT_TIME };
// ── source ──────────────────────────────────────────────────────────────────
enum Source : uint8_t { SRC_STORED, SRC_SCAN };
// ── action-menu actions (matched by id, not by row index) ────────────────────
enum Action : uint8_t { ACT_NAV, ACT_PING, ACT_WAYPOINT, ACT_LOCATOR,
ACT_ADD, ACT_DELETE, ACT_FAV, ACT_SORT, ACT_SCAN };
// ── unified list entry ───────────────────────────────────────────────────────
struct Entry {
char name[32];
uint8_t type;
uint8_t pub_key[PUB_KEY_SIZE];
bool has_key;
// stored-source fields
int32_t lat_e6, lon_e6;
float dist_km;
uint32_t lastmod;
int contact_idx;
// scan-source fields
int8_t rssi, snr_x4, remote_snr_x4;
bool is_known;
// live-track ([LOC] share) overlay: this row's position/age came from a
// shared-location message. live_verified == true for a DM (pubkey) share,
// false for a channel (name, best-effort) share.
bool is_live;
bool live_verified;
};
static const int MAX_NEARBY = 32;
Entry _entries[MAX_NEARBY];
int _count;
int _sel;
int _scroll;
bool _detail;
bool _nav = false; // full-screen navigate-to-node view (over detail)
navview::EtaTracker _nav_eta; // closing-speed/ETA for the navigate view
int32_t _own_lat, _own_lon;
bool _own_gps;
Source _source;
uint8_t _filter;
uint8_t _sort;
unsigned long _detail_refresh_ms;
unsigned long _list_refresh_ms = 0;
static const unsigned long DETAIL_REFRESH_MS = 10000UL;
static const unsigned long NAV_REFRESH_MS = 1000UL; // navigate view tracks a moving target
static const unsigned long TIME_LIST_REFRESH_MS = 3000UL;
// ── live-scan state ──────────────────────────────────────────────────────────
bool _scanning;
unsigned long _scan_started_ms;
static const unsigned long SCAN_DURATION_MS = 8000UL;
// ── popups ────────────────────────────────────────────────────────────────────
PopupMenu _menu; // unified action menu (Hold Enter), list + detail
PopupMenu _ping_menu; // ping (special: read-only result rows)
PopupMenu _confirm; // delete-contact confirmation (destructive → 2-step)
Action _menu_actions[10]; // parallel to _menu rows — stable action ids
int _menu_action_count;
char _sort_label[16]; // dynamic label for the Sort row
// ── ping state ───────────────────────────────────────────────────────────────
char _ping_time_str[24];
char _ping_snr_out_str[24];
char _ping_snr_back_str[24];
bool _pinging;
unsigned long _ping_started_ms;
static const unsigned long PING_TIMEOUT_MS = 3000UL;
// ── helpers ──────────────────────────────────────────────────────────────────
static void pubKeyToBase64(const uint8_t* key, char* out, int out_len) {
static const char T[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
int j = 0;
for (int i = 0; i < PUB_KEY_SIZE && j + 5 < out_len; i += 3) {
uint32_t b = ((uint32_t)key[i] << 16)
| (i+1 < PUB_KEY_SIZE ? (uint32_t)key[i+1] << 8 : 0)
| (i+2 < PUB_KEY_SIZE ? (uint32_t)key[i+2] : 0);
out[j++] = T[(b >> 18) & 63];
out[j++] = T[(b >> 12) & 63];
if (i+1 < PUB_KEY_SIZE) out[j++] = T[(b >> 6) & 63];
if (i+2 < PUB_KEY_SIZE) out[j++] = T[b & 63];
}
out[j] = '\0';
}
bool useImperial() const { return _task && _task->useImperial(); }
// Full "X ago" form for the detail view, built on the shared short-age tag
// so there's one bucket ladder (see geo::fmtAgeShort).
static void fmtAge(char* buf, int n, uint32_t lastmod) {
char s[8];
geo::fmtAgeShort(s, sizeof(s), rtc_clock.getCurrentTime(), lastmod);
if (!s[0]) { snprintf(buf, n, "unknown"); return; }
snprintf(buf, n, "%s ago", s);
}
static const char* typeName(uint8_t t) {
switch (t) {
case ADV_TYPE_CHAT: return "Companion";
case ADV_TYPE_REPEATER: return "Repeater";
case ADV_TYPE_ROOM: return "Room";
case ADV_TYPE_SENSOR: return "Sensor";
default: return "Unknown";
}
}
static const char* typeShort(uint8_t t) {
switch (t) {
case ADV_TYPE_REPEATER: return "Rpt";
case ADV_TYPE_SENSOR: return "Snsr";
case ADV_TYPE_ROOM: return "Room";
case ADV_TYPE_CHAT: return "Comp";
default: return "?";
}
}
// The selected entry, or nullptr when the list is empty.
const Entry* selected() const {
return (_count > 0 && _sel < _count) ? &_entries[_sel] : nullptr;
}
bool typeMatchesFilter(uint8_t type, uint8_t flags, bool have_flags) const {
switch (_filter) {
case F_FAV: return have_flags && (flags & 0x01);
case F_COMP: return type == ADV_TYPE_CHAT;
case F_RPT: return type == ADV_TYPE_REPEATER;
case F_ROOM: return type == ADV_TYPE_ROOM;
case F_SNSR: return type == ADV_TYPE_SENSOR;
case F_ALL:
default: return true;
}
}
// ── data refresh ──────────────────────────────────────────────────────────────
void refreshStored() {
_count = 0;
_own_lat = _own_lon = 0;
_own_gps = _task->currentLocation(_own_lat, _own_lon);
int nc = the_mesh.getNumContacts();
for (int i = 0; i < nc && _count < MAX_NEARBY; i++) {
ContactInfo ci;
if (!the_mesh.getContactByIdx(i, ci)) continue;
if (!typeMatchesFilter(ci.type, ci.flags, true)) continue;
Entry& e = _entries[_count++];
strncpy(e.name, ci.name, sizeof(e.name) - 1);
e.name[sizeof(e.name) - 1] = '\0';
memcpy(e.pub_key, ci.id.pub_key, PUB_KEY_SIZE);
e.has_key = true;
e.lat_e6 = ci.gps_lat;
e.lon_e6 = ci.gps_lon;
bool remote_gps = (ci.gps_lat != 0 || ci.gps_lon != 0);
e.dist_km = (_own_gps && remote_gps)
? geo::haversineKm(_own_lat, _own_lon, ci.gps_lat, ci.gps_lon)
: -1.0f;
e.type = ci.type;
e.contact_idx = i;
e.lastmod = ci.lastmod;
e.is_known = true;
e.is_live = false;
e.live_verified = false;
}
mergeLiveTrack();
mergeRecentlyHeard();
sortStored();
clampSelection();
}
// Rebuild the stored list (re-merge live shares + re-sort) while keeping the
// currently highlighted node selected across the rebuild — by contact index
// for a stored node, or by name for a non-contact live sender. Returns false
// if that node is no longer in the list (caller decides whether to drop the
// detail/nav view). Shared by the list, detail and navigate refresh paths.
bool refreshKeepingSelection() {
int saved_idx = (_sel < _count) ? _entries[_sel].contact_idx : -1;
bool saved_live = (_sel < _count) && _entries[_sel].is_live && saved_idx < 0;
char saved_name[sizeof(_entries[0].name)]; saved_name[0] = '\0';
if (_sel < _count) {
strncpy(saved_name, _entries[_sel].name, sizeof(saved_name) - 1);
saved_name[sizeof(saved_name) - 1] = '\0';
}
refreshStored();
if (saved_idx >= 0) {
for (int i = 0; i < _count; i++)
if (_entries[i].contact_idx == saved_idx) { _sel = i; return true; }
} else if (saved_live && saved_name[0]) {
for (int i = 0; i < _count; i++)
if (_entries[i].is_live && strncmp(_entries[i].name, saved_name, sizeof(saved_name) - 1) == 0)
{ _sel = i; return true; }
}
return false;
}
// Overlay live [LOC] shares onto the stored list: refresh a matching contact
// with the fresher shared position, and append senders who aren't contacts so
// a group sharing on a channel still shows up. DM shares match by pubkey
// prefix (verified); channel shares match by name (best-effort).
void mergeLiveTrack() {
if (!_task) return;
LiveTrackStore& lt = _task->liveTrack();
uint32_t now = rtc_clock.getCurrentTime();
for (int i = 0; i < LiveTrackStore::CAPACITY; i++) {
if (!lt.isActive(i, now)) continue;
const LiveTrackStore::Entry& s = lt.slotAt(i);
int m = -1;
for (int j = 0; j < _count; j++) {
if (s.verified && _entries[j].has_key
&& memcmp(_entries[j].pub_key, s.key, LiveTrackStore::KEY_LEN) == 0) { m = j; break; }
if (!s.verified && strncmp(_entries[j].name, s.name, sizeof(_entries[j].name) - 1) == 0) { m = j; break; }
}
if (m >= 0) {
Entry& e = _entries[m];
// A [LOC] share is an explicit "here I am now", so it defines the pin and
// the distance (used for proximity sorting). Recency for the time sort is
// the most recent of the advert and the share.
e.lat_e6 = s.lat_1e6;
e.lon_e6 = s.lon_1e6;
e.dist_km = _own_gps ? geo::haversineKm(_own_lat, _own_lon, s.lat_1e6, s.lon_1e6) : -1.0f;
if (s.ts > e.lastmod) e.lastmod = s.ts;
e.is_live = true;
e.live_verified = s.verified;
} else if (_count < MAX_NEARBY && typeMatchesFilter(ADV_TYPE_CHAT, 0, false)) {
// A sender we don't have as a contact: treat it as a companion (the only
// node type that shares position), so the type filter still applies —
// e.g. it must not appear under the Repeater/Room/Sensor filters.
Entry& e = _entries[_count++];
memset(&e, 0, sizeof(e));
strncpy(e.name, s.name, sizeof(e.name) - 1);
e.name[sizeof(e.name) - 1] = '\0';
// We only keep a key *prefix* for shares, not the full pubkey, so Ping
// and the base64 key view (which need 32 bytes) stay unavailable for a
// non-contact live entry. Navigate / Save-waypoint work off lat/lon.
e.has_key = false;
e.type = ADV_TYPE_CHAT;
e.lat_e6 = s.lat_1e6;
e.lon_e6 = s.lon_1e6;
e.dist_km = _own_gps ? geo::haversineKm(_own_lat, _own_lon, s.lat_1e6, s.lon_1e6) : -1.0f;
e.lastmod = s.ts;
e.contact_idx = -1;
e.is_known = false;
e.is_live = true;
e.live_verified = s.verified;
}
}
}
// Fold passively-heard adverts that aren't already listed (contact or live) into
// the list as name+age rows — no full key/GPS, so informational only. This is
// what absorbs the old standalone "Recent adverts" home page. Gated to the All
// filter because AdvertPath carries no node type to filter or sort-by-dist on.
void mergeRecentlyHeard() {
if (_filter != F_ALL || !_task) return;
static AdvertPath heard[8]; // scratch — refreshStored isn't reentrant
int n = the_mesh.getRecentlyHeard(heard, 8);
for (int i = 0; i < n && _count < MAX_NEARBY; i++) {
AdvertPath& a = heard[i];
if (a.name[0] == 0) continue;
bool dup = false;
for (int j = 0; j < _count; j++) {
if (_entries[j].has_key &&
memcmp(_entries[j].pub_key, a.pubkey_prefix, sizeof(a.pubkey_prefix)) == 0) { dup = true; break; }
if (strncmp(_entries[j].name, a.name, sizeof(a.name) - 1) == 0) { dup = true; break; }
}
if (dup) continue;
Entry& e = _entries[_count++];
memset(&e, 0, sizeof(e));
strncpy(e.name, a.name, sizeof(e.name) - 1);
e.name[sizeof(e.name) - 1] = '\0';
e.type = ADV_TYPE_CHAT; // unknown from AdvertPath — best-effort label
e.has_key = false;
e.dist_km = -1.0f;
e.lastmod = a.recv_timestamp;
e.contact_idx = -1;
e.is_known = false;
e.is_live = false;
}
}
void sortStored() {
uint32_t now_ts = rtc_clock.getCurrentTime();
for (int i = 0; i < _count - 1; i++) {
int best = i;
for (int j = i + 1; j < _count; j++) {
if (_sort == SORT_TIME) {
// lastmod=0 or lastmod>now (RTC not synced) → "unknown" → sort to bottom.
uint32_t tj = (_entries[j].lastmod > 0 && now_ts >= _entries[j].lastmod) ? _entries[j].lastmod : 0;
uint32_t tb = (_entries[best].lastmod > 0 && now_ts >= _entries[best].lastmod) ? _entries[best].lastmod : 0;
if (tj > 0 && (tb == 0 || tj > tb)) best = j; // descending — most recent first
} else {
float dj = _entries[j].dist_km, db = _entries[best].dist_km;
if (dj >= 0.0f && (db < 0.0f || dj < db)) best = j; // ascending — closest first
}
}
if (best != i) { Entry tmp = _entries[i]; _entries[i] = _entries[best]; _entries[best] = tmp; }
}
}
void refreshScan() {
static DiscoverResult dr[DISCOVER_RESULTS_MAX]; // scratch — refresh isn't reentrant
int n = the_mesh.getDiscoverResults(dr, DISCOVER_RESULTS_MAX);
_count = 0;
for (int i = 0; i < n && _count < MAX_NEARBY; i++) {
if (!typeMatchesFilter(dr[i].type, 0, false)) continue;
Entry& e = _entries[_count++];
strncpy(e.name, dr[i].name, sizeof(e.name) - 1);
e.name[sizeof(e.name) - 1] = '\0';
e.type = dr[i].type;
memcpy(e.pub_key, dr[i].pub_key, PUB_KEY_SIZE);
e.has_key = true;
e.rssi = dr[i].rssi;
e.snr_x4 = dr[i].snr_x4;
e.remote_snr_x4 = dr[i].remote_snr_x4;
e.is_known = dr[i].is_known;
e.lat_e6 = e.lon_e6 = 0;
e.dist_km = -1.0f;
e.lastmod = 0;
e.contact_idx = -1;
e.is_live = false;
e.live_verified = false;
}
// strongest first
for (int i = 0; i < _count - 1; i++) {
int best = i;
for (int j = i + 1; j < _count; j++)
if (_entries[j].rssi > _entries[best].rssi) best = j;
if (best != i) { Entry tmp = _entries[i]; _entries[i] = _entries[best]; _entries[best] = tmp; }
}
clampSelection();
}
void refresh() { if (_source == SRC_SCAN) refreshScan(); else refreshStored(); }
void clampSelection() {
if (_count == 0) { _sel = _scroll = 0; }
else if (_sel >= _count) { _sel = _count - 1; if (_scroll > _sel) _scroll = _sel; }
}
// ── live scan ────────────────────────────────────────────────────────────────
void enterScan() {
_source = SRC_SCAN;
_detail = false;
_nav = false;
_scanning = true;
_scan_started_ms = millis();
_sel = _scroll = 0;
the_mesh.sendNodeDiscoverReq();
refreshScan();
}
void leaveScan() {
_source = SRC_STORED;
_detail = false;
_nav = false;
_sel = _scroll = 0;
refreshStored();
}
// Save the currently-selected entry as a waypoint (its name as label).
void saveSelectedWaypoint() {
const Entry* e = selected();
if (!e) return;
if (e->lat_e6 == 0 && e->lon_e6 == 0) { _task->showAlert("No node GPS", 1000); return; }
_task->addWaypoint(e->lat_e6, e->lon_e6, e->name); // WaypointStore truncates the label
}
// Toggle the given contact in the Favourites dial: unpin if already pinned, else
// pin to the first empty slot. Persisted immediately (same as the Messages picker).
void toggleFavourite(const uint8_t* pub_key) {
int slot = _task->findFavouriteSlot(pub_key);
if (slot >= 0) {
_task->clearFavouriteSlot(slot);
the_mesh.savePrefs();
_task->showAlert("Unfavourited", 1000);
return;
}
for (int s = 0; s < NodePrefs::FAVOURITES_COUNT; s++) {
if (_task->isFavouriteSlotEmpty(s)) {
_task->setFavouriteSlot(s, pub_key);
the_mesh.savePrefs();
_task->showAlert("Favourited", 1000);
return;
}
}
_task->showAlert("Favourites full", 1200);
}
// Deleting a contact is destructive → confirm first (default highlight = Cancel).
void startDeleteConfirm() {
const Entry* e = selected();
if (!e || !e->has_key || !entryIsContact(e)) return;
_confirm.begin("Delete contact?", 2);
_confirm.addItem("Delete");
_confirm.addItem("Cancel");
_confirm.setSelected(1);
_confirm.active = true;
}
void doDeleteSelected() {
const Entry* e = selected();
if (!e || !e->has_key || !entryIsContact(e)) return;
uint8_t key[PUB_KEY_SIZE];
memcpy(key, e->pub_key, PUB_KEY_SIZE);
if (the_mesh.deleteContactByKey(key)) {
_task->showAlert("Contact deleted", 1200);
_detail = false; // the node this detail/nav view showed is gone
_nav = false;
refresh();
clampSelection();
}
}
// ── ping ──────────────────────────────────────────────────────────────────────
void resetPingLines() {
_ping_time_str[0] = '\0';
_ping_snr_out_str[0] = '\0';
_ping_snr_back_str[0] = '\0';
}
int pingRowCount() const {
return 1 + (_ping_time_str[0] ? 1 : 0)
+ (_ping_snr_out_str[0] ? 1 : 0)
+ (_ping_snr_back_str[0] ? 1 : 0);
}
void rebuildPingMenu() {
int keep = _ping_menu.selectedIndex(); // preserve selection across a rebuild
_ping_menu.begin("Ping", 4);
_ping_menu.addItem("Send");
if (_ping_time_str[0]) _ping_menu.addItem(_ping_time_str);
if (_ping_snr_out_str[0]) _ping_menu.addItem(_ping_snr_out_str);
if (_ping_snr_back_str[0]) _ping_menu.addItem(_ping_snr_back_str);
_ping_menu.setSelected(keep);
}
void closePingMenu(bool clear_task = true) {
_ping_menu.active = false;
_pinging = false;
if (clear_task && _task) _task->clearPing();
resetPingLines();
}
void startPingForKey(const uint8_t* pub_key) {
resetPingLines();
snprintf(_ping_time_str, sizeof(_ping_time_str), "RTT: ...");
if (_task && _task->startPing(pub_key)) {
_pinging = true;
_ping_started_ms = millis();
} else {
snprintf(_ping_time_str, sizeof(_ping_time_str), "RTT: send fail");
}
if (_ping_menu.active) rebuildPingMenu(); // surface "RTT: ..." immediately
}
void updatePingMenuState() {
if (!_ping_menu.active || !_task || (!_pinging && !_task->isPingActive())) return;
int16_t snr_out = 0, snr_back = 0;
uint32_t rtt = 0;
_task->getPingResult(snr_out, snr_back, rtt);
if (_pinging && (snr_out != 0 || snr_back != 0 || rtt != 0)) {
if (rtt > 0 && rtt < 10000) {
snprintf(_ping_time_str, sizeof(_ping_time_str), "RTT: %lums", rtt);
} else {
snprintf(_ping_time_str, sizeof(_ping_time_str), "RTT: timeout");
}
if (snr_out != 0)
snprintf(_ping_snr_out_str, sizeof(_ping_snr_out_str), "SNR out: %.1f", snr_out / 4.0f);
if (snr_back != 0)
snprintf(_ping_snr_back_str, sizeof(_ping_snr_back_str), "SNR back: %.1f", snr_back / 4.0f);
_pinging = false;
} else if (_pinging && millis() - _ping_started_ms >= PING_TIMEOUT_MS) {
snprintf(_ping_time_str, sizeof(_ping_time_str), "RTT: timeout");
_ping_snr_out_str[0] = '\0';
_ping_snr_back_str[0] = '\0';
_pinging = false;
if (_task) _task->clearPing();
}
if (pingRowCount() != _ping_menu.count()) rebuildPingMenu();
}
// Ping popup input. Result rows are read-only, so UP/DOWN are swallowed to keep
// the highlight on "Send".
void handlePingMenuInput(char c) {
if (c == KEY_UP || c == KEY_DOWN) return;
auto res = _ping_menu.handleInput(c);
if (res == PopupMenu::SELECTED) {
const Entry* e = selected();
if (!_pinging && e && e->has_key) startPingForKey(e->pub_key);
_ping_menu.active = true; // stay open so Ping can be repeated
} else if (res == PopupMenu::CANCELLED) {
closePingMenu();
}
}
// ── action menu (Hold Enter) — same everywhere ──────────────────────────────
void buildSortLabel() {
snprintf(_sort_label, sizeof(_sort_label),
"Sort: %s", _sort == SORT_TIME ? "Recent" : "Dist");
}
// A row is already a contact when it carries a contacts[] index (stored source)
// or the live-scan flagged it known. Full 32-byte key required to add/delete.
bool entryIsContact(const Entry* e) const {
return e && ((e->contact_idx >= 0) || (_source == SRC_SCAN && e->is_known));
}
void openActionMenu() {
const Entry* e = selected();
bool stored = (_source == SRC_STORED);
bool has_gps = e && stored && (e->lat_e6 != 0 || e->lon_e6 != 0);
bool has_key = e && e->has_key;
bool is_contact = entryIsContact(e);
bool can_add = e && has_key && !is_contact; // a new node we can save
bool is_fav = e && has_key && _task->findFavouriteSlot(e->pub_key) >= 0;
buildSortLabel();
_menu_action_count = 0;
_menu.begin("Options", 10);
auto add = [&](const char* label, Action a) {
_menu.addItem(label);
_menu_actions[_menu_action_count++] = a;
};
if (has_gps) add("Navigate", ACT_NAV);
if (has_key) add("Ping", ACT_PING);
if (has_gps) add("Save waypoint", ACT_WAYPOINT);
// Needs both a position and a stable identity — a person target is keyed
// by pubkey prefix, so a name-only live-scan/channel row can't offer this.
if (has_gps && has_key) add("Set as target", ACT_LOCATOR);
if (can_add) add("Add contact", ACT_ADD);
if (is_contact && has_key) add(is_fav ? "Unfavourite" : "Favourite", ACT_FAV);
if (is_contact && has_key) add("Delete contact", ACT_DELETE);
if (stored) add(_sort_label, ACT_SORT); // sort is meaningless for live-scan rows
add(stored ? "Discover scan" : "Rescan", ACT_SCAN);
}
void runAction(Action a) {
switch (a) {
case ACT_NAV: {
const Entry* e = selected();
if (e && (e->lat_e6 != 0 || e->lon_e6 != 0)) { _nav = true; _nav_eta.reset(); }
else _task->showAlert("No node GPS", 1000);
break;
}
case ACT_PING: {
const Entry* e = selected();
rebuildPingMenu();
_ping_menu.active = true;
if (e && e->has_key) startPingForKey(e->pub_key);
break;
}
case ACT_WAYPOINT: saveSelectedWaypoint(); break;
case ACT_LOCATOR: {
const Entry* e = selected();
if (e && e->has_key && (e->lat_e6 != 0 || e->lon_e6 != 0))
_task->setTargetNow(1, e->pub_key, e->lat_e6, e->lon_e6, e->name);
break;
}
case ACT_ADD: {
const Entry* e = selected();
if (e && e->has_key) {
if (the_mesh.addDiscoveredContact(e->pub_key, e->name, e->type)) {
_task->showAlert("Contact added", 1200);
refresh(); // now a known contact — re-sort / re-mark this pass
} else {
_task->showAlert("Contacts full", 1200);
}
}
break;
}
case ACT_FAV: {
const Entry* e = selected();
if (e && e->has_key) toggleFavourite(e->pub_key);
break;
}
case ACT_DELETE: startDeleteConfirm(); break;
case ACT_SORT: break; // adjusted in-place via LEFT/RIGHT, not ENTER
case ACT_SCAN: enterScan(); break;
}
}
// ── detail rendering ──────────────────────────────────────────────────────────
void renderStoredDetail(DisplayDriver& display) {
const Entry& e = _entries[_sel];
const int hdr = display.listStart(); // content top (gap below the header separator)
display.drawInvertedHeader(e.name);
int step = display.lineStep();
if (step * 5 > display.height() - hdr) step = (display.height() - hdr) / 5;
char buf[32];
snprintf(buf, sizeof(buf), "Lat: %.5f", e.lat_e6 / 1e6);
display.setCursor(2, hdr); display.print(buf);
snprintf(buf, sizeof(buf), "Lon: %.5f", e.lon_e6 / 1e6);
display.setCursor(2, hdr + step); display.print(buf);
if (e.dist_km >= 0.0f) {
char dist[12];
geo::fmtDist(dist, sizeof(dist), e.dist_km, useImperial());
int az = geo::bearingDeg(_own_lat, _own_lon, e.lat_e6, e.lon_e6);
snprintf(buf, sizeof(buf), "Dist: %s %s", dist, geo::bearingCardinal(az));
} else {
snprintf(buf, sizeof(buf), "Dist: no GPS");
}
display.setCursor(2, hdr + step * 2); display.print(buf);
snprintf(buf, sizeof(buf), "Type: %s", typeName(e.type));
display.setCursor(2, hdr + step * 3); display.print(buf);
char age[16];
fmtAge(age, sizeof(age), e.lastmod);
// For a live [LOC] row, label the timestamp as a position share and note
// whether the sender's identity is verified (DM) or name-only (channel).
if (e.is_live) snprintf(buf, sizeof(buf), "Sharing pos: %s %s", age, e.live_verified ? "(DM)" : "(chan)");
else snprintf(buf, sizeof(buf), "Seen: %s", age);
display.drawTextEllipsized(2, hdr + step * 4, display.width() - 4, buf);
}
void renderScanDetail(DisplayDriver& display) {
const Entry& e = _entries[_sel];
const int hdr = display.listStart(); // content top (gap below the header separator)
char label[32];
if (e.name[0]) { strncpy(label, e.name, 31); label[31] = '\0'; }
else { snprintf(label, sizeof(label), "[%s]", typeName(e.type)); }
display.drawInvertedHeader(label);
char b64[48];
pubKeyToBase64(e.pub_key, b64, sizeof(b64));
int max_chars = (display.width() - 4) / display.getCharWidth();
char b64_line[48];
if (max_chars < 4) {
b64_line[0] = '\0';
} else if ((int)strlen(b64) > max_chars) {
strncpy(b64_line, b64, max_chars - 3);
b64_line[max_chars - 3] = '\0';
strcat(b64_line, "...");
} else {
strncpy(b64_line, b64, sizeof(b64_line) - 1);
b64_line[sizeof(b64_line) - 1] = '\0';
}
if (b64_line[0]) { display.setCursor(2, hdr); display.print(b64_line); }
int step = display.lineStep();
if (step * 5 > display.height() - hdr) step = (display.height() - hdr) / 5;
char buf[32];
snprintf(buf, sizeof(buf), "RSSI: %d dBm", (int)e.rssi);
display.setCursor(2, hdr + step); display.print(buf);
snprintf(buf, sizeof(buf), "SNR: %.1f dB", e.snr_x4 / 4.0f);
display.setCursor(2, hdr + step * 2); display.print(buf);
snprintf(buf, sizeof(buf), "Rem: %.1f dB", e.remote_snr_x4 / 4.0f);
display.setCursor(2, hdr + step * 3); display.print(buf);
display.setCursor(2, hdr + step * 4);
display.print(e.is_known ? "Status: known" : "Status: new");
}
// Draw whichever popup is active over the current view. Returns true if one was.
bool renderActivePopup(DisplayDriver& display) {
updatePingMenuState();
if (_ping_menu.active) { _ping_menu.render(display); return true; }
if (_confirm.active) { _confirm.render(display); return true; }
if (_menu.active) { _menu.render(display); return true; }
return false;
}
// One filter tab: short label; the active one is an inverted pill (same look as
// a selected row) so it reads as "you are here" in the tab strip.
void drawTab(DisplayDriver& display, int x, int w, const char* label, bool active) {
int lh = display.getLineHeight();
if (active) {
display.setColor(DisplayDriver::LIGHT);
display.fillRect(x, 0, w, lh + 1);
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
}
display.drawTextCentered(x + w / 2, 0, label);
display.setColor(DisplayDriver::LIGHT);
}
// Header as a circular filter tab bar: the active filter sits centred as a
// filled pill, neighbours fan out either side and wrap around (the tab before
// "All" is "Snsr", and vice-versa), matching the wrap-around LEFT/RIGHT cycle.
// Only whole tabs are drawn — a tab that would spill past a screen edge is
// skipped rather than clipped, so labels never wrap onto a second line.
void drawFilterTabs(DisplayDriver& display) {
const int pad = 3, gap = 2;
int w[F_COUNT];
for (int i = 0; i < F_COUNT; i++)
w[i] = display.getTextWidth(FILTER_LABELS[i]) + pad * 2;
int ax = display.width() / 2 - w[_filter] / 2; // active tab centred
drawTab(display, ax, w[_filter], FILTER_LABELS[_filter], true);
int lx = ax - gap; // next left tab's right edge
int rx = ax + w[_filter] + gap; // next right tab's left edge
bool lfit = true, rfit = true;
for (int k = 1; k <= F_COUNT / 2 && (lfit || rfit); k++) {
int li = (_filter - k + F_COUNT) % F_COUNT;
int ri = (_filter + k) % F_COUNT;
if (lfit) {
int x = lx - w[li];
if (x >= 0) { drawTab(display, x, w[li], FILTER_LABELS[li], false); lx = x - gap; }
else lfit = false;
}
// Skip the right side when it lands on the same tab as the left (the single
// opposite tab on an even count) so it isn't drawn twice.
if (rfit && ri != li) {
if (rx + w[ri] <= display.width()) { drawTab(display, rx, w[ri], FILTER_LABELS[ri], false); rx += w[ri] + gap; }
else rfit = false;
}
}
display.setColor(DisplayDriver::LIGHT);
display.fillRect(0, display.headerH() - display.sepH(), display.width(), display.sepH());
}
public:
NearbyScreen(UITask* task)
: _task(task), _count(0), _sel(0), _scroll(0), _detail(false),
_own_lat(0), _own_lon(0), _own_gps(false),
_source(SRC_STORED), _filter(F_ALL), _sort(SORT_DIST),
_detail_refresh_ms(0), _scanning(false), _scan_started_ms(0),
_menu_action_count(0), _pinging(false), _ping_started_ms(0) {
resetPingLines();
_sort_label[0] = '\0';
}
void onShow() override {
_sel = _scroll = 0;
_detail = false;
_nav = false;
_source = SRC_STORED;
// _filter / _sort persist across enter() — set once in the constructor
_scanning = false;
_menu.active = false;
_ping_menu.active = false;
_confirm.active = false;
_pinging = false;
resetPingLines();
_task->clearPing();
refreshStored();
}
int render(DisplayDriver& display) override {
display.setTextSize(1);
// Periodic refresh of the selected entry while in detail or navigate view,
// preserving the selection across the list rebuild. Navigate refreshes
// faster so a moving live target (a contact sharing [LOC]) tracks smoothly.
// Re-selection keys on contact index for stored nodes, and on name for a
// non-contact live sender — without the latter, navigating to someone who
// only shares on a channel would drop out on the first refresh.
unsigned long refresh_due = _nav ? NAV_REFRESH_MS : DETAIL_REFRESH_MS;
if ((_detail || _nav) && _source == SRC_STORED &&
millis() - _detail_refresh_ms >= refresh_due) {
if (!refreshKeepingSelection()) { _detail = false; _nav = false; } // node/share gone
_detail_refresh_ms = millis();
}
// ── navigate-to-node view ────────────────────────────────────────────────
if (_nav && _sel < _count) {
const Entry& e = _entries[_sel];
int cog; bool cogv = _task->currentCourse(cog);
navview::draw(display, _own_gps, _own_lat, _own_lon,
e.lat_e6, e.lon_e6, e.name, cogv, cog, useImperial(), &_nav_eta);
return 1000;
}
// ── detail view ──────────────────────────────────────────────────────────
if (_detail && _sel < _count) {
if (_source == SRC_SCAN) renderScanDetail(display);
else renderStoredDetail(display);
renderActivePopup(display);
return _ping_menu.active ? 50 : 2000;
}
// ── list view ────────────────────────────────────────────────────────────
if (_source == SRC_SCAN) {
refreshScan();
if (_scanning && millis() - _scan_started_ms >= SCAN_DURATION_MS) _scanning = false;
} else if (millis() - _list_refresh_ms >= TIME_LIST_REFRESH_MS) {
// Re-merge + re-sort periodically so newly-heard contacts and fresh live
// [LOC] shares bubble to the right spot under either sort (distances move
// as you and they do, not just recency). Keep the highlighted node put.
refreshKeepingSelection();
_list_refresh_ms = millis();
}
int item_h = display.lineStep();
int dist_col = display.width() - display.getCharWidth() * 7;
display.setColor(DisplayDriver::LIGHT);
const char* flt = (_filter != F_ALL) ? FILTER_LABELS[_filter] : nullptr;
if (_source == SRC_SCAN) {
char title[28];
const char* base = _scanning ? "SCANNING" : "SCAN";
if (flt) {
if (!_scanning && _count == 0) snprintf(title, sizeof(title), "SCAN %s: none", flt);
else snprintf(title, sizeof(title), "%s %s (%d)", base, flt, _count);
} else if (_scanning) snprintf(title, sizeof(title), "SCANNING (%d)", _count);
else if (_count == 0) snprintf(title, sizeof(title), "SCAN: none");
else snprintf(title, sizeof(title), "SCAN (%d)", _count);
display.drawCenteredHeader(title);
} else {
// Stored list: the filter is a visible tab strip (LEFT/RIGHT switches tabs).
drawFilterTabs(display);
}
if (_count == 0) {
char empty[24];
if (_source == SRC_SCAN) {
const char* msg;
if (_scanning) msg = "Waiting for replies...";
else if (flt) { snprintf(empty, sizeof(empty), "No %s nodes", flt); msg = empty; }
else msg = "No nodes found";
display.drawTextCentered(display.width() / 2, display.height() / 2, msg);
} else {
const char* hint;
if (flt) { snprintf(empty, sizeof(empty), "No %s contacts", flt); hint = "[<>] change filter"; }
else { snprintf(empty, sizeof(empty), "No contacts found"); hint = "[Enter]=Discover"; }
display.drawTextCentered(display.width() / 2, display.height() / 2 - display.lineStep() / 2, empty);
display.drawTextCentered(display.width() / 2, display.height() / 2 + display.lineStep() / 2, hint);
}
} else {
drawList(display, _count, _sel, _scroll, [&](int idx, int y, bool sel, int reserve) {
const Entry& e = _entries[idx];
drawRowSelection(display, y, sel, reserve);
char filt[32];
int tx = 2;
if (e.is_live) {
// Diamond marker = this node is broadcasting its position ([LOC]),
// the same marker the map uses for a live-tracked contact. DM-verified
// vs channel-only is spelled out in the detail view.
int iw = ICON_MAP_CONTACT.w * miniIconScale(display);
miniIconDrawCentered(display, tx + iw / 2, y + display.getLineHeight() / 2 - 1, ICON_MAP_CONTACT);
tx += iw + 2;
}
// Star = pinned to the Favourites dial (same glyph as the Favourites page
// icon). Shown next to any live diamond so both states read at once.
if (e.has_key && _task->findFavouriteSlot(e.pub_key) >= 0) {
int iw = ICON_PG_STAR.w * miniIconScale(display);
miniIconDrawCentered(display, tx + iw / 2, y + display.getLineHeight() / 2 - 1, ICON_PG_STAR);
tx += iw + 2;
}
display.translateUTF8ToBlocks(filt, e.name, sizeof(filt));
if (_source == SRC_SCAN && !e.name[0]) { // unknown node → "[Type]"
snprintf(filt, sizeof(filt), "[%s]", typeName(e.type));
}
display.drawTextEllipsized(tx, y, dist_col - tx - 2, filt);
display.setColor(sel ? DisplayDriver::DARK : DisplayDriver::LIGHT);
char right[10];
if (_source == SRC_SCAN) {
snprintf(right, sizeof(right), "%d", (int)e.rssi);
} else if (_sort == SORT_TIME) {
geo::fmtAgeShort(right, sizeof(right), rtc_clock.getCurrentTime(), e.lastmod);
if (!right[0]) snprintf(right, sizeof(right), "?"); // unknown / RTC not synced
} else {
if (e.dist_km >= 0.0f) geo::fmtDist(right, sizeof(right), e.dist_km, useImperial());
else strncpy(right, "?GPS", sizeof(right));
}
display.drawTextRightAlign(display.width() - reserve - 2, y, right);
});
}
if (renderActivePopup(display)) return 50;
if (_source == SRC_SCAN) return _scanning ? 200 : 2000;
return _count == 0 ? 3000 : 2000;
}
bool handleInput(char c) override {
// ── navigate-to-node view — any nav key returns to detail ─────────────────
if (_nav) {
if (c == KEY_CANCEL || keyIsPrev(c) || keyIsNext(c)) _nav = false;
return true;
}
// ── popups (same handling in list and detail) ─────────────────────────────
if (_confirm.active) {
auto res = _confirm.handleInput(c);
if (res == PopupMenu::SELECTED) {
if (_confirm.selectedIndex() == 0) doDeleteSelected();
_confirm.active = false;
} else if (res == PopupMenu::CANCELLED) {
_confirm.active = false;
}
return true;
}
if (_ping_menu.active) { handlePingMenuInput(c); return true; }
if (_menu.active) {
// LEFT/RIGHT on the Sort row toggles the value in-place and rebuilds the
// label; the popup stays open so the user can keep tapping. Other rows
// swallow L/R. ENTER on Sort just closes (value changes via L/R only).
if (keyIsPrev(c) || keyIsNext(c)) {
int i = _menu.selectedIndex();
if (i >= 0 && i < _menu_action_count && _menu_actions[i] == ACT_SORT) {
_sort = (_sort == SORT_DIST) ? SORT_TIME : SORT_DIST;
buildSortLabel();
refresh();
}
return true;
}
auto res = _menu.handleInput(c);
if (res == PopupMenu::SELECTED) {
int i = _menu.selectedIndex();
if (i >= 0 && i < _menu_action_count && _menu_actions[i] != ACT_SORT)
runAction(_menu_actions[i]);
}
return true;
}
// ── detail view ───────────────────────────────────────────────────────────
if (_detail) {
if (c == KEY_CANCEL) { _detail = false; closePingMenu(); return true; }
if (c == KEY_CONTEXT_MENU) { openActionMenu(); return true; }
return true;
}
// ── list view ───────────────────────────────────────────────────────────
if (c == KEY_CANCEL) {
if (_source == SRC_SCAN) leaveScan();
else _task->gotoToolsScreen();
return true;
}
if (c == KEY_CONTEXT_MENU) { openActionMenu(); return true; }
// drawList() reclamps _scroll from _sel every render.
if (c == KEY_UP && _count > 0) { _sel = (_sel > 0) ? _sel - 1 : _count - 1; return true; }
if (c == KEY_DOWN && _count > 0) { _sel = (_sel < _count - 1) ? _sel + 1 : 0; return true; }
if (c == KEY_ENTER) {
if (_count == 0) { if (_source == SRC_STORED) enterScan(); return true; }
_detail = true;
_detail_refresh_ms = millis();
return true;
}
if (keyIsPrev(c)) { _filter = (_filter + F_COUNT - 1) % F_COUNT; refresh(); return true; }
if (keyIsNext(c)) { _filter = (_filter + 1) % F_COUNT; refresh(); return true; }
return false;
}
};
const char* NearbyScreen::FILTER_LABELS[F_COUNT] = { "All", "Fav", "Comp", "Rpt", "Room", "Snsr" };