Files
MeshCore-Solo/examples/companion_radio/ui-new/WaypointsView.h
MarekZegare4 57774d41f3 feat(companion): live location sharing, Locator geofencing, trail auto-pause
Squash merge of feat/location-beacon-alerts-autopause (v1.21).

Features:
- Live Location Sharing — broadcast position as movement-gated [LOC]
  messages to a channel or contact; live shares show as map pins with
  distance/bearing in Nearby Nodes and a status-bar indicator. [LOC] is
  parsed in DMs, channel messages and room messages; DM shares name the
  sender.
- Locator (geofence) — arm a geofence around a saved waypoint or a person
  (live [LOC] or last-known position), alert on arrive/leave or near/far,
  with an optional homing beeper (gated to arrive/both modes). Arm from
  Tools > Locator, Nearby Nodes, or Waypoints; target picker lists
  favourites first, clearable via a "None" entry. Active target is drawn
  as a flag on the map.
- One active target shared across Locator / Navigate / Map via a single
  resolver (resolvePersonPos / activeTargetPos) that prefers a live
  [LOC] share over the last-advertised GPS fix.
- Follow live contacts — Navigate to a live-sharing contact follows them
  as they move and adds an ETA line; quick-share your own position from
  the Map.
- Map & status-bar upgrades — home mini-map gets a north marker, scale
  tick, and a connected trail line (was disconnected dots); status line
  shows tracked-node count, an arrow + distance to the active Locator/Nav
  target (falling back to the nearest live-tracked contact); GPS fix icon
  in the top status bar, shown only on GPS boards with GPS enabled.
- Trail auto-pause — recording freezes on stops (banking elapsed time,
  breaking the map line across the idle gap) and resumes on movement
  without ending the session.
- Streaming trail simplification — GPS points are simplified in-stream
  via a fixed-corridor (Reumann-Witkam) pass tuned for fidelity: straight
  runs collapse to their endpoints, curves stay bounded to the Min-dist
  tolerance, so the 512-point buffer covers a far longer route than a
  flat point budget would suggest.
- Collapsible Tools (Location / Comms / System sections, fold-in-place
  like Settings) and page-indicator icons on the home carousel.
- Waypoint coordinate editor — add a waypoint by scroll-editing lat/lon
  digit by digit.

Fixes:
- Critical: low-heap hang and contact loss on RAM-tight builds. Halved
  message-history scrollback rings (recovering ~14 KB free heap) and
  made contacts/channels/prefs persistence atomic (temp file + rename),
  so an interrupted save can no longer corrupt or wipe the store.
- Serial.write() bounded so a stalled USB host can't hang the device.
- Nearby Nodes: live [LOC] senders respect the type filter, sort by
  shared position, and the list refreshes so live shares bubble up.
- Map: live contacts are labelled before waypoints.
- GPS status icon hidden when GPS is off in Settings.
- Splash screen no longer truncates a pre-release tag's own dash (e.g.
  v1.21-rc1) when stripping the build's commit-hash suffix.
- Null-guarded the Locator target picker; clamped loc-share channel
  index on load.

Under the hood:
- -Os size optimisation on the e-ink and GAT562 30S solo envs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 19:04:21 +02:00

357 lines
15 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#pragma once
// Waypoint management UI for Tools Trail: list / navigate / add-by-coords,
// plus mark-here, rename, delete and send. Extracted from TrailScreen as a
// self-contained component that TrailScreen owns and delegates to while active.
// It needs the TrailStore only for the synthetic "Trail start" backtrack row.
//
// Included by TrailScreen.h (which UITask.cpp pulls in after UITask is fully
// defined, so the inline _task->… calls below see the complete type).
#include "../Waypoint.h"
#include "../GeoUtils.h"
#include "../Trail.h"
#include "KeyboardWidget.h"
#include "PopupMenu.h"
#include "NavView.h"
#include "DigitEditor.h"
class WaypointsView {
UITask* _task;
TrailStore* _store;
// Sub-modes layered over the trail views. OFF = the component is dormant.
enum Mode { OFF, LIST, NAV, ADD };
uint8_t _mode = OFF;
int _sel = 0;
int _scroll = 0;
PopupMenu _ctx; // Rename / Delete / Send on a selected waypoint
bool _kb_active = false;
int _kb_rename_idx = -1; // -1 = marking new, ≥0 = renaming that index
int32_t _mark_lat = 0, _mark_lon = 0; // pending new-mark position
uint32_t _mark_ts = 0;
// ADD form — enter a waypoint by coordinates. Lat/Lon are numeric, so they use
// the digit-by-digit scroll editor (same widget as the radio frequency field)
// rather than the text keyboard: Enter opens the editor on the magnitude,
// LEFT/RIGHT on the row toggles the hemisphere (N/S, E/W). Only the Label field
// uses the keyboard.
float _add_lat_mag = 0.0f, _add_lon_mag = 0.0f; // magnitude (degrees)
char _add_label[WAYPOINT_LABEL_LEN] = "";
bool _add_lat_neg = false; // true = S
bool _add_lon_neg = false; // true = W
int _add_sel = 0; // 0=Lat 1=Lon 2=Label 3=Save
int _kb_field = -1; // ADD label edit (>=0) vs mark/rename (-1)
DigitEditor _add_editor; // active while editing a Lat/Lon magnitude
bool useImperial() const { return _task && _task->useImperial(); }
bool ownPos(int32_t& lat, int32_t& lon) const { return _task->currentLocation(lat, lon); }
// The nav list shows a synthetic "Trail start" row (index 0) whenever a trail
// exists, followed by the saved waypoints. These map the combined row index
// (_sel) onto that layout.
bool hasStart() const { return !_store->empty(); }
int wpListCount() const { return (hasStart() ? 1 : 0) + _task->waypoints().count(); }
bool selIsStart() const { return hasStart() && _sel == 0; }
int wpIndex() const { return _sel - (hasStart() ? 1 : 0); } // index into WaypointStore
// Add a waypoint by coordinates (no GPS fix needed). Opens the ADD form.
void openAddForm() {
if (_task->waypoints().full()) { _task->showAlert("Waypoints full", 1000); return; }
_add_lat_mag = _add_lon_mag = 0.0f;
_add_label[0] = '\0';
_add_lat_neg = _add_lon_neg = false;
_add_sel = 0;
_add_editor.active = false;
_mode = ADD;
}
// Store the just-typed label (the only ADD field that still uses the keyboard).
void applyAddField(const char* buf) {
strncpy(_add_label, buf, sizeof(_add_label) - 1);
_add_label[sizeof(_add_label) - 1] = '\0';
}
// Validate the ADD form and save the waypoint.
void commitAddForm() {
double la = _add_lat_mag, lo = _add_lon_mag;
if (_add_lat_neg) la = -la;
if (_add_lon_neg) lo = -lo;
if (la < -90.0 || la > 90.0 || lo < -180.0 || lo > 180.0) {
_task->showAlert("Out of range", 1200); return;
}
if (_task->waypoints().full()) { _task->showAlert("Waypoints full", 1000); return; }
int32_t lat = (int32_t)(la * 1e6 + (la < 0 ? -0.5 : 0.5));
int32_t lon = (int32_t)(lo * 1e6 + (lo < 0 ? -0.5 : 0.5));
char label[WAYPOINT_LABEL_LEN];
if (_add_label[0]) { strncpy(label, _add_label, sizeof(label) - 1); label[sizeof(label) - 1] = '\0'; }
else snprintf(label, sizeof(label), "WP%d", _task->waypoints().count() + 1);
if (_task->addWaypoint(lat, lon, label)) { _mode = OFF; }
}
// Open the shared keyboard for a literal field. Waypoint labels and the
// lat/lon magnitudes are never placeholder-expanded, so {loc}/{time} are
// cleared (same intent as the bot's literal trigger field).
void openKb(const char* initial, int max) {
KeyboardWidget& kb = _task->keyboard();
kb.begin(initial, max);
kb.clearPlaceholders();
_kb_active = true;
}
// Commit a mark-here / rename label from the keyboard.
void commitLabel(const char* buf) {
if (_kb_rename_idx >= 0) {
_task->waypoints().rename(_kb_rename_idx, buf);
_task->saveWaypoints();
_kb_rename_idx = -1;
return;
}
char auto_lbl[WAYPOINT_LABEL_LEN];
if (!buf || !buf[0]) {
snprintf(auto_lbl, sizeof(auto_lbl), "WP%d", _task->waypoints().count() + 1);
buf = auto_lbl;
}
_task->addWaypoint(_mark_lat, _mark_lon, _mark_ts, buf);
}
void renderAddForm(DisplayDriver& display) {
display.setColor(DisplayDriver::LIGHT);
display.drawCenteredHeader("ADD WAYPOINT");
const int top = display.listStart();
const int step = display.lineStep();
for (int i = 0; i < 4; i++) {
int y = top + i * step;
bool sel = (i == _add_sel);
display.drawSelectionRow(0, y - 1, display.width(), step - 1, sel);
// While editing a magnitude, draw the row prefix + the digit editor so the
// place under the cursor is highlighted; otherwise draw the plain value.
if ((i == 0 || i == 1) && sel && _add_editor.active) {
char prefix[10];
if (i == 0) snprintf(prefix, sizeof(prefix), "Lat: %c ", _add_lat_neg ? 'S' : 'N');
else snprintf(prefix, sizeof(prefix), "Lon: %c ", _add_lon_neg ? 'W' : 'E');
display.setCursor(2, y); display.print(prefix);
_add_editor.render(display, 2 + (int)strlen(prefix) * display.getCharWidth(), y);
} else {
char row[28];
if (i == 0) snprintf(row, sizeof(row), "Lat: %c %.5f", _add_lat_neg ? 'S' : 'N', _add_lat_mag);
else if (i == 1) snprintf(row, sizeof(row), "Lon: %c %.5f", _add_lon_neg ? 'W' : 'E', _add_lon_mag);
else if (i == 2) snprintf(row, sizeof(row), "Label: %s", _add_label[0] ? _add_label : "(auto)");
else snprintf(row, sizeof(row), "[Save]");
display.setCursor(2, y); display.print(row);
}
display.setColor(DisplayDriver::LIGHT);
}
}
void renderWpList(DisplayDriver& display) {
display.setColor(DisplayDriver::LIGHT);
char title[24];
snprintf(title, sizeof(title), "WAYPOINTS %d/%d",
_task->waypoints().count(), WaypointStore::CAPACITY);
display.drawCenteredHeader(title);
int n = wpListCount();
int total = n + 1; // final row = "+ Add by coords"
const int step = display.lineStep();
int32_t mylat, mylon; bool have = ownPos(mylat, mylon);
drawList(display, total, _sel, _scroll, [&](int row, int y, bool sel, int reserve) {
display.drawSelectionRow(0, y - 1, display.width() - reserve, step - 1, sel);
if (row == n) { // the synthetic "Add" row
display.setCursor(2, y); display.print("+ Add by coords");
display.setColor(DisplayDriver::LIGHT);
return;
}
int32_t tlat, tlon; const char* label;
if (!rowTarget(row, tlat, tlon, label)) return;
char dist[12] = ""; int bw = 0;
if (have) {
geo::fmtDist(dist, sizeof(dist), geo::haversineKm(mylat, mylon, tlat, tlon), useImperial());
bw = display.getTextWidth(dist) + 2;
}
char nm[24];
display.translateUTF8ToBlocks(nm, label, sizeof(nm));
display.drawTextEllipsized(2, y, display.width() - 2 - bw - reserve, nm);
if (dist[0]) { display.setCursor(display.width() - bw + 1 - reserve, y); display.print(dist); }
display.setColor(DisplayDriver::LIGHT);
});
}
void renderWpNav(DisplayDriver& display) {
int32_t tlat, tlon; const char* label;
if (!rowTarget(_sel, tlat, tlon, label)) { _mode = LIST; return; }
int32_t mylat, mylon; bool have = ownPos(mylat, mylon);
int cog; bool cogv = _task->currentCourse(cog);
navview::draw(display, have, mylat, mylon, tlat, tlon, label, cogv, cog, useImperial());
}
// Resolve a combined-list row to a nav target. Row 0 is the trail start when
// a trail exists; the rest are saved waypoints.
bool rowTarget(int row, int32_t& lat, int32_t& lon, const char*& label) {
if (hasStart() && row == 0) {
const TrailPoint& s = _store->first();
lat = s.lat_1e6; lon = s.lon_1e6; label = "Trail start";
return true;
}
int wi = row - (hasStart() ? 1 : 0);
WaypointStore& wp = _task->waypoints();
if (wi >= 0 && wi < wp.count()) {
const Waypoint& w = wp.at(wi);
lat = w.lat_1e6; lon = w.lon_1e6;
label = w.label[0] ? w.label : "(unnamed)";
return true;
}
return false;
}
public:
WaypointsView(UITask* task, TrailStore* store) : _task(task), _store(store) {}
void reset() { _mode = OFF; _ctx.active = false; _kb_active = false; _kb_field = -1; }
// True while the component owns the screen (a sub-mode or the keyboard is up).
bool active() const { return _mode != OFF || _kb_active; }
// Entry points called from TrailScreen's action menu.
void openList() { _mode = LIST; _sel = 0; _scroll = 0; }
void markHere() {
int32_t lat, lon;
if (!ownPos(lat, lon)) { _task->showAlert("No GPS fix", 1000); return; }
if (_task->waypoints().full()) { _task->showAlert("Waypoints full", 1000); return; }
_mark_lat = lat; _mark_lon = lon; _mark_ts = (uint32_t)rtc_clock.getCurrentTime();
_kb_rename_idx = -1;
openKb("", WAYPOINT_LABEL_LEN - 1);
}
// Only called while active().
int render(DisplayDriver& display) {
display.setTextSize(1);
display.setColor(DisplayDriver::LIGHT);
if (_kb_active) return _task->keyboard().render(display); // keyboard owns the screen
if (_mode == ADD) { renderAddForm(display); return 1000; }
if (_mode == NAV) { renderWpNav(display); return 1000; }
renderWpList(display); // LIST
if (_ctx.active) _ctx.render(display);
return 1000;
}
// Returns true if the input was consumed (always, while active()).
bool handleInput(char c) {
// Label keyboard (mark new / rename / ADD field) takes all input first.
if (_kb_active) {
KeyboardWidget& kb = _task->keyboard();
auto r = kb.handleInput(c);
if (r == KeyboardWidget::DONE) {
if (_kb_field >= 0) applyAddField(kb.buf); // ADD field edit
else commitLabel(kb.buf); // mark / rename label
_kb_active = false; _kb_field = -1;
} else if (r == KeyboardWidget::CANCELLED) {
_kb_active = false; _kb_field = -1;
}
return true;
}
// Rename/Delete/Send popup — operates on the saved-waypoint index (the
// synthetic Trail-start row, if any, is never editable).
if (_ctx.active) {
auto res = _ctx.handleInput(c);
if (res == PopupMenu::SELECTED) {
int sel = _ctx.selectedIndex();
int wi = wpIndex();
if (sel == 0) { // Rename
if (wi >= 0 && wi < _task->waypoints().count()) {
_kb_rename_idx = wi;
openKb(_task->waypoints().at(wi).label, WAYPOINT_LABEL_LEN - 1);
}
} else if (sel == 1) { // Delete
if (wi >= 0 && wi < _task->waypoints().count()) {
_task->waypoints().remove(wi);
_task->saveWaypoints();
if (_sel >= wpListCount()) _sel = wpListCount() - 1;
if (_sel < 0) _sel = 0;
}
} else if (sel == 2) { // Send (share in a message)
if (wi >= 0 && wi < _task->waypoints().count()) {
const Waypoint& w = _task->waypoints().at(wi);
double lat = w.lat_1e6 / 1000000.0, lon = w.lon_1e6 / 1000000.0;
char text[80];
if (w.label[0]) snprintf(text, sizeof(text), WAYPOINT_MSG_TAG "%.5f,%.5f %s", lat, lon, w.label);
else snprintf(text, sizeof(text), WAYPOINT_MSG_TAG "%.5f,%.5f", lat, lon);
_task->shareToMessage(text); // hands off to the Messages screen
}
} else { // Set as target
if (wi >= 0 && wi < _task->waypoints().count()) {
const Waypoint& w = _task->waypoints().at(wi);
_task->setTargetNow(0, nullptr, w.lat_1e6, w.lon_1e6, w.label);
}
}
}
return true;
}
// ADD form: scroll-edit lat/lon (magnitude) + hemisphere toggle + label.
if (_mode == ADD) {
// The digit editor, while open, consumes all input (UP/DOWN change the
// digit, LEFT/RIGHT move the cursor, Enter commits, Cancel aborts).
if (_add_editor.active) {
if (_add_editor.handleInput(c) == DigitEditor::DONE) {
if (_add_sel == 0) _add_lat_mag = _add_editor.value;
else _add_lon_mag = _add_editor.value;
}
return true;
}
if (c == KEY_CANCEL) { _mode = OFF; return true; }
if (c == KEY_UP) { _add_sel = (_add_sel > 0) ? _add_sel - 1 : 3; return true; }
if (c == KEY_DOWN) { _add_sel = (_add_sel < 3) ? _add_sel + 1 : 0; return true; }
if (c == KEY_LEFT || c == KEY_RIGHT) {
if (_add_sel == 0) _add_lat_neg = !_add_lat_neg; // N <-> S
else if (_add_sel == 1) _add_lon_neg = !_add_lon_neg; // E <-> W
return true;
}
if (c == KEY_ENTER) {
if (_add_sel == 0) _add_editor.begin(_add_lat_mag, 0.0f, 90.0f, 2, 5);
else if (_add_sel == 1) _add_editor.begin(_add_lon_mag, 0.0f, 180.0f, 3, 5);
else if (_add_sel == 2) { _kb_field = 2; openKb(_add_label, WAYPOINT_LABEL_LEN - 1); }
else { commitAddForm(); }
return true;
}
return true;
}
// Navigation view — any nav key returns to the list.
if (_mode == NAV) {
if (c == KEY_CANCEL || keyIsPrev(c) || keyIsNext(c)) { _mode = LIST; }
return true;
}
// List (row 0 is the synthetic "Trail start" when a trail exists; the final
// row is "+ Add by coords"). Cancel returns control to the trail views.
int n = wpListCount();
int total = n + 1; // + the "Add by coords" row
if (c == KEY_CANCEL) { _mode = OFF; return true; }
// drawList() reclamps _scroll from _sel every render.
if (c == KEY_UP) { _sel = (_sel > 0) ? _sel - 1 : total - 1; return true; }
if (c == KEY_DOWN) { _sel = (_sel < total - 1) ? _sel + 1 : 0; return true; }
if (c == KEY_ENTER) {
if (_sel == n) openAddForm(); // last row → open the add form
else _mode = NAV; // a waypoint / Trail-start row
return true;
}
// Rename/Delete/Send/Locator apply to saved waypoints only — not Trail-start or Add.
if (c == KEY_CONTEXT_MENU && !selIsStart() && _sel != n &&
wpIndex() >= 0 && wpIndex() < _task->waypoints().count()) {
_ctx.begin("Waypoint", 4);
_ctx.addItem("Rename");
_ctx.addItem("Delete");
_ctx.addItem("Send");
_ctx.addItem("Set as target");
return true;
}
return true;
}
};