mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-09-14 15:16:40 +00:00
Continuing the consistency review: sweep for the same three defect shapes
elsewhere in ui-new/ (own read pass plus two parallel research agents),
verified against source before acting.
Three destructive actions fired on a single Enter with no way back, unlike
contact-delete's existing confirm-defaulted-to-Cancel popup: Trail's "Reset
trail" (wipes the whole recorded route, no undo short of a prior manual
Save -- reuses Trail's own multi-level menu machinery, alongside its
GPS-off confirm), Messages' channel Delete, and RadioPresetPicker's saved-
preset delete (shared by Settings > Radio and Tools > Repeater, so one fix
covers both). All three now confirm the same way, defaulting to Cancel.
KeyboardWidget was the one place Hold-Enter still doubled as Cancel: Shift,
Backspace and a Latin letter's accent popup already have real, kept
meanings under a hold, but every other special-row cell (Space, OK/Done,
the {} placeholder) fell through to a bare CANCELLED, closing the keyboard
exactly like the real Cancel key. Now a no-op there too, matching the "only
Back closes it" rule already applied to popups and screens.
MessagesScreen defined the same two label arrays (Notif states, melody
slots) four times over, once per context-menu handler. Hoisted to one
pair of static class members -- constexpr wasn't enough to get the linker
to emit them on this toolchain, so they follow the same declare-in-class/
define-out-of-class shape NearbyScreen::FILTER_LABELS already uses.
Alert text: "Advert sent!"/"Advert failed.." and "Sent!" were the only
toasts anywhere with trailing punctuation; normalized to the plain style
every other confirmation uses. Unpinning from the Favourites Dial reported
the freed slot number from the Messages screens but not from Nodes or the
dial's own tile menu; now consistent everywhere pinning already was.
DiagnosticsScreen's Live/System/Font tab renderers hand-rolled the same
scroll-clamp/loop/indicator skeleton drawList() already bundles; switched
both to drawList (passing the screen's own _scroll as its `sel` too, since
neither tab has a row cursor -- makes drawList's internal clamp a no-op and
leaves clampScroll() as the only thing bounding it, unchanged). Pure
internal tidy, no behavior change.
1064 lines
47 KiB
C++
1064 lines
47 KiB
C++
#pragma once
|
|
#include "../GeoUtils.h"
|
|
#include "NavView.h"
|
|
#include "TabBar.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_PIN, ACT_ADMIN, ACT_SORT, ACT_SCAN };
|
|
|
|
// Set by UITask::pickAdminTarget() (Tools > Admin, which is remote-only):
|
|
// while true, ENTER on an eligible row (a stored repeater/room contact) hands
|
|
// the node straight to Admin instead of opening the detail view -- everything
|
|
// else (filters, scan, ping, sort, the Hold-Enter menu) behaves identically to
|
|
// normal Nodes browsing, so picking a node for Admin looks exactly like using
|
|
// this screen for anything else. Mirrors MessagesScreen's
|
|
// startPickBotChannel()/startPickBotRoom() pick-mode idiom.
|
|
bool _pick_admin_target = false;
|
|
|
|
// ── unified list entry ───────────────────────────────────────────────────────
|
|
struct Entry {
|
|
char name[32];
|
|
uint8_t type;
|
|
uint8_t pub_key[PUB_KEY_SIZE];
|
|
// has_key: the full 32-byte pubkey is present -- what Ping and the base64
|
|
// key view need. has_prefix: at least the leading FAVOURITE_PREFIX_LEN
|
|
// bytes are, which is all an identity-keyed reference needs (a Locator
|
|
// person target, resolved via UITask::resolvePersonPos). Every has_key row
|
|
// also has_prefix; the reverse doesn't hold -- a [LOC] share and a heard
|
|
// advert carry a prefix and nothing more.
|
|
bool has_key;
|
|
bool has_prefix;
|
|
// 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;
|
|
// Favourite (ContactInfo::flags bit 0) -- always false for a scan/live row
|
|
// that isn't a contact, since only a contact can carry the flag.
|
|
bool fav;
|
|
};
|
|
|
|
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;
|
|
// getContactByIdx() indexes the RAW contact table, whose first
|
|
// MAX_ANON_CONTACTS slots are reserved for anon-request bookkeeping
|
|
// (see BaseChatMesh::resetContacts()/ContactsIterator) -- getNumContacts()
|
|
// already excludes them from the count, so real contact 0 lives at raw
|
|
// index MAX_ANON_CONTACTS, not 0. Reading from 0 pulled those reserved
|
|
// (blank, type=ADV_TYPE_NONE) slots into the list as bogus "Unknown"
|
|
// rows, and silently dropped the same number of real contacts off the
|
|
// end -- while never touching the anon slots themselves, so it always
|
|
// reproduced the same way regardless of the auto-add overwrite setting.
|
|
if (!the_mesh.getContactByIdx(i + MAX_ANON_CONTACTS, 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.has_prefix = 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.fav = (ci.flags & 0x01) != 0;
|
|
e.contact_idx = i + MAX_ANON_CONTACTS; // raw index -- other lookups re-key off this directly
|
|
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.
|
|
// A DM share is pubkey-keyed, so the prefix is real and good enough to
|
|
// keep re-resolving them as a Locator person target; a channel share is
|
|
// matched by name and carries no identity at all.
|
|
e.has_key = false;
|
|
e.has_prefix = s.verified;
|
|
if (s.verified) memcpy(e.pub_key, s.key, LiveTrackStore::KEY_LEN);
|
|
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.has_prefix = true;
|
|
memcpy(e.pub_key, a.pubkey_prefix, sizeof(a.pubkey_prefix));
|
|
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();
|
|
NodePrefs* p = _task->getNodePrefs();
|
|
const bool fav_first = !(p && p->fav_sort_off);
|
|
for (int i = 0; i < _count - 1; i++) {
|
|
int best = i;
|
|
for (int j = i + 1; j < _count; j++) {
|
|
if (fav_first && _entries[j].fav != _entries[best].fav) {
|
|
if (_entries[j].fav) best = j; // favourites outrank the time/distance key
|
|
continue;
|
|
}
|
|
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.has_prefix = 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.fav = false;
|
|
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).
|
|
// Unlike the Messages lists, this one doesn't ask which slot -- it takes the
|
|
// first free one. The menu here is already long, and the dial itself can
|
|
// rearrange afterwards.
|
|
void togglePinToDial(const uint8_t* pub_key) {
|
|
int slot = _task->findFavouriteSlot(pub_key);
|
|
if (slot >= 0) {
|
|
_task->clearFavouriteSlot(slot);
|
|
the_mesh.savePrefs();
|
|
char alert[24];
|
|
snprintf(alert, sizeof(alert), "Unpinned (slot %d)", slot + 1);
|
|
_task->showAlert(alert, 1000);
|
|
return;
|
|
}
|
|
for (int s = 0; s < NodePrefs::FAVOURITES_COUNT; s++) {
|
|
if (_task->isFavouriteSlotEmpty(s)) {
|
|
_task->setFavouriteSlot(s, pub_key);
|
|
the_mesh.savePrefs();
|
|
char alert[24];
|
|
snprintf(alert, sizeof(alert), "Pinned to slot %d", s + 1);
|
|
_task->showAlert(alert, 1000);
|
|
return;
|
|
}
|
|
}
|
|
_task->showAlert("Dial 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));
|
|
}
|
|
|
|
char _fav_label[12]; // "Fav: ON" / "Fav: OFF" -- rewritten in place by L/R
|
|
char _pin_label[24]; // "Pin to dial" / "Unpin (slot N)" -- _menu stores the pointer
|
|
|
|
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_pinned = e && has_key && _task->findFavouriteSlot(e->pub_key) >= 0;
|
|
// Admin needs a real saved contact (repeater/room), not a scan result or a
|
|
// name-only live-share row -- same gating as startPickAdminTarget()'s ENTER.
|
|
bool is_admin_target = e && stored && e->contact_idx >= 0
|
|
&& (e->type == ADV_TYPE_REPEATER || e->type == ADV_TYPE_ROOM);
|
|
|
|
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;
|
|
};
|
|
auto addValue = [&](const char* label, Action a) {
|
|
_menu.addValueItem(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);
|
|
// Only a position is needed: with an identity prefix this becomes a person
|
|
// target that follows them, without one a place target pinned where they were.
|
|
if (has_gps) add("Set as target", ACT_LOCATOR);
|
|
if (can_add) add("Add contact", ACT_ADD);
|
|
if (is_contact && has_key) {
|
|
snprintf(_fav_label, sizeof(_fav_label), e->fav ? "Fav: ON" : "Fav: OFF");
|
|
addValue(_fav_label, ACT_FAV);
|
|
}
|
|
if (is_contact && has_key) {
|
|
if (is_pinned) snprintf(_pin_label, sizeof(_pin_label), "Unpin (slot %d)",
|
|
_task->findFavouriteSlot(e->pub_key) + 1);
|
|
else snprintf(_pin_label, sizeof(_pin_label), "Pin to dial");
|
|
add(_pin_label, ACT_PIN);
|
|
}
|
|
if (is_admin_target) add("Admin", ACT_ADMIN);
|
|
if (is_contact && has_key) add("Delete contact", ACT_DELETE);
|
|
if (stored) addValue(_sort_label, ACT_SORT); // sort is meaningless for live-scan rows
|
|
add(stored ? "Discover scan" : "Rescan", ACT_SCAN);
|
|
}
|
|
|
|
// Flip the selected contact's favourite flag and retitle the open menu row.
|
|
// The list re-sorts underneath (favourites first), but refreshKeepingSelection()
|
|
// re-finds this node, so the popup stays anchored to it.
|
|
void toggleFavSelected() {
|
|
const Entry* e = selected();
|
|
if (!e || !e->has_key) return;
|
|
bool now_fav = !e->fav;
|
|
if (!the_mesh.setContactFavourite(e->pub_key, now_fav)) return;
|
|
snprintf(_fav_label, sizeof(_fav_label), now_fav ? "Fav: ON" : "Fav: OFF");
|
|
refreshKeepingSelection();
|
|
}
|
|
|
|
// Advance the value on the menu's value rows (Sort, Fav). Both are two-state,
|
|
// so LEFT and RIGHT do the same thing here and Enter joins them.
|
|
void cycleMenuValue(int i) {
|
|
if (i < 0 || i >= _menu_action_count) return;
|
|
if (_menu_actions[i] == ACT_SORT) {
|
|
_sort = (_sort == SORT_DIST) ? SORT_TIME : SORT_DIST;
|
|
buildSortLabel();
|
|
refresh();
|
|
} else if (_menu_actions[i] == ACT_FAV) {
|
|
toggleFavSelected();
|
|
}
|
|
}
|
|
|
|
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->lat_e6 == 0 && e->lon_e6 == 0)) break;
|
|
// A prefix is enough to keep re-resolving someone who is moving; without
|
|
// one (a channel share, matched by name) the honest target is the place
|
|
// they were last seen, snapshotted like a waypoint.
|
|
if (e->has_prefix) _task->setTargetNow(1, e->pub_key, e->lat_e6, e->lon_e6, e->name);
|
|
else _task->setTargetNow(0, nullptr, 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: break; // value rows -- see cycleMenuValue()
|
|
case ACT_PIN: {
|
|
const Entry* e = selected();
|
|
if (e && e->has_key) togglePinToDial(e->pub_key);
|
|
break;
|
|
}
|
|
case ACT_DELETE: startDeleteConfirm(); break;
|
|
case ACT_ADMIN: {
|
|
const Entry* e = selected();
|
|
ContactInfo ci;
|
|
if (e && e->contact_idx >= 0 && the_mesh.getContactByIdx(e->contact_idx, ci))
|
|
_task->openAdminFor(ci, false); // direct from Nodes -- Cancel should return here, not to a pick-list
|
|
break;
|
|
}
|
|
case ACT_SORT: break; // value rows -- see cycleMenuValue()
|
|
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, true, ctxMenuOpen());
|
|
|
|
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, true, ctxMenuOpen());
|
|
|
|
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");
|
|
}
|
|
|
|
// Any Hold-Enter popup on screen → highlight the ≡ hint so it reads as the
|
|
// source of the open menu.
|
|
bool ctxMenuOpen() const { return _menu.active || _confirm.active || _ping_menu.active; }
|
|
|
|
// 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;
|
|
}
|
|
|
|
// Header as a circular filter tab bar (shared geometry — see TabBar.h): 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.
|
|
void drawFilterTabs(DisplayDriver& display) {
|
|
tabbar::draw(display, FILTER_LABELS, F_COUNT, _filter, display.menuHintWidth());
|
|
display.drawContextMenuHint(DisplayDriver::LIGHT, ctxMenuOpen()); // Nodes list has a Hold-Enter menu
|
|
}
|
|
|
|
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';
|
|
}
|
|
|
|
// Whether the full-screen navigate-to-node view is up -- used by UITask's
|
|
// GPS duty-cycle "is anything live using GPS right now" check, since this
|
|
// view needs an unbroken stream of fixes for bearing/ETA, not a stale one.
|
|
bool isNavigating() const { return _nav; }
|
|
|
|
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;
|
|
_pick_admin_target = false; // stale pick-mode from a previous visit shouldn't linger
|
|
resetPingLines();
|
|
_task->clearPing();
|
|
refreshStored();
|
|
}
|
|
|
|
// Entered via UITask::pickAdminTarget() right after setCurrScreen(this) has
|
|
// already run onShow()'s reset above -- just arms the pick-mode flag.
|
|
void startPickAdminTarget() { _pick_admin_target = true; }
|
|
|
|
int render(DisplayDriver& display) override {
|
|
display.setTextSize(1);
|
|
int mq_delay = 0; // >0 while the selected row's name is marquee-scrolling
|
|
|
|
// 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, true, ctxMenuOpen());
|
|
} 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 = favourite, the same marker every other list uses. Shown next to
|
|
// any live diamond so both states read at once.
|
|
if (e.fav) {
|
|
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));
|
|
}
|
|
int mqr = display.drawTextEllipsized(tx, y, dist_col - tx - 2, filt, sel);
|
|
if (sel && mqr > 0) mq_delay = mqr;
|
|
|
|
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;
|
|
int ret;
|
|
if (_source == SRC_SCAN) ret = _scanning ? 200 : 2000;
|
|
else ret = _count == 0 ? 3000 : 2000;
|
|
return (mq_delay > 0 && mq_delay < ret) ? mq_delay : ret;
|
|
}
|
|
|
|
bool handleInput(char c) override {
|
|
// ── navigate-to-node view — any nav key returns to detail ─────────────────
|
|
if (_nav) {
|
|
if (c == KEY_CANCEL) _nav = false; // only Back leaves a navigate view
|
|
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 -- and Enter, which PopupMenu reports as VALUE_NEXT on a
|
|
// value row -- cycle Sort and Fav in place; the popup stays open so the
|
|
// user can keep tapping, and only Back closes it. Other rows swallow L/R.
|
|
if (keyIsPrev(c) || keyIsNext(c)) {
|
|
cycleMenuValue(_menu.selectedIndex());
|
|
return true;
|
|
}
|
|
auto res = _menu.handleInput(c);
|
|
if (res == PopupMenu::VALUE_NEXT) {
|
|
cycleMenuValue(_menu.selectedIndex());
|
|
} else if (res == PopupMenu::SELECTED) {
|
|
int i = _menu.selectedIndex();
|
|
if (i >= 0 && i < _menu_action_count) 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 (_pick_admin_target) { _pick_admin_target = false; _task->gotoToolsScreen(); return true; }
|
|
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 (_pick_admin_target) {
|
|
const Entry* e = selected();
|
|
ContactInfo ci;
|
|
if (e && e->contact_idx >= 0 && (e->type == ADV_TYPE_REPEATER || e->type == ADV_TYPE_ROOM)
|
|
&& the_mesh.getContactByIdx(e->contact_idx, ci)) {
|
|
_pick_admin_target = false;
|
|
_task->openAdminFor(ci, true); // via the picker -- Cancel should return here
|
|
}
|
|
// else: row isn't an eligible admin target -- ignore, stay on the picker.
|
|
return true;
|
|
}
|
|
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" };
|