#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_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]; 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; // 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; }; 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_admin_target) add("Admin", ACT_ADMIN); 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_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; // 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, 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'; } 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); // 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 = 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 (_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" };