mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-09-14 15:16:40 +00:00
Found while re-reviewing this session's own commits: five screens each hand-built the same 2-row Action/Cancel confirm popup, defaulting the highlight to Cancel -- NearbyScreen's contact-delete, AdminScreen's OTA-start, and the three just added (Trail's reset, Messages' channel delete, RadioPresetPicker's preset delete). The plan that added those three had already flagged this exact duplication without acting on it, so it just tripled instead of getting fixed. One PopupMenu::beginConfirm(title, action_label, cancel_label="Cancel") replaces all five call sites, and makes "defaults to Cancel" a property of the popup itself rather than something each new confirm has to remember. Also drops two small redundancies spotted along the way: NearbyScreen's _confirm.active = true, dead since begin() already sets it, and RadioPresetPicker's deleting = false being set twice in a row (once inside openConfirm(), once again by its only caller). No behavior change; verified against the actual PopupMenu/menu-level state machines in each of the five call sites before touching them.
1060 lines
47 KiB
C++
1060 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.beginConfirm("Delete contact?", "Delete");
|
|
}
|
|
|
|
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" };
|