feat(geo-alert): pick target from a list (favourites first)

Enter on the Target row now opens a picker instead of only cycling: it
lists favourites first (the quick path), then active verified live
senders, then waypoints — UP/DOWN + Enter to choose. This lets you arm an
alert against a person ahead of time by pubkey, without waiting for them
to share a [LOC] (the previous cycle only offered currently-active live
senders, so a person you hadn't received a fresh share from couldn't be
selected at all). LEFT/RIGHT still quick-cycles the same unified set.

Verified: WioTrackerL1_companion_solo_dual builds clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MarekZegare4
2026-06-22 13:45:47 +02:00
parent bcb7eb59d5
commit 440a222e4b
2 changed files with 120 additions and 45 deletions

View File

@@ -232,14 +232,14 @@ Navigate with **UP/DOWN**, change a value with **LEFT/RIGHT** (or **Enter**); **
| Setting | Options | Notes |
| ------- | -------------------------------- | -------------------------------------------------------------------------------------- |
| Alert | ON / OFF | Master switch. Enabling without a target prompts you to pick one. |
| Target | waypoints + live contacts | **LEFT/RIGHT** (or **Enter**) cycles through your waypoints and any verified (DM) live contacts; a contact target is shown with an `@` prefix. Shows `none` until set. |
| Target | person or waypoint | **Enter** opens a picker — **favourites** first, then any active live senders, then waypoints; **UP/DOWN** + **Enter** to choose. **LEFT/RIGHT** quick-cycles the same set in place. A person is shown with an `@` prefix. Shows `none` until set. |
| Radius | 50 / 100 / 250 / 500 m / 1 km | Geofence size. |
| Mode | Arrive / Leave / Both | Which crossing fires the alert — entering the radius, leaving it, or both. |
| Beeper | ON / OFF | Optional homing tone (see below). |
**Crossing alert.** When armed with a target, the device watches its own GPS fix and fires the alert (a short melody plus an on-screen message) the moment you cross the radius, according to **Mode**. The wording adapts to the target — `Arrived` / `Left` for a waypoint, `Near` / `Away` for a person. The edge has a little hysteresis so a fix hovering right on the boundary doesn't chatter, and the first reading after arming only seeds the in/out state — it won't fire spuriously just because you armed it while already inside.
**Following a person.** With a **live-contact** target, the geofence tracks the distance *between you and them* using their latest shared position, so it works even while both of you move. If they stop sharing (their share goes stale), evaluation pauses until a fresh position arrives — it never alerts on an outdated fix. Only contacts whose shares are **DM-verified** can be targeted (a channel share carries no stable identity to lock onto).
**Following a person.** Pick a **favourite** (or contact / active live sender) as the target and the geofence tracks the distance *between you and them* using their latest shared position, so it works even while both of you move. You can arm it **ahead of time** — choosing a favourite locks onto their identity (pubkey), and the alert starts working as soon as they share a position. If they stop sharing (their share goes stale), evaluation pauses until a fresh position arrives — it never alerts on an outdated fix. A person must share over a **DM** for it to follow them (a channel share carries no stable identity to lock onto).
**Proximity beeper.** With **Beeper** on, the device also ticks while you're inside the radius and **shortens the gap between ticks the closer you get to the target** — slow near the edge, rapid near the centre — like a homing beeper guiding you to the exact spot. It's silent outside the radius. Because the beeper is its own opt-in toggle, turning it on **overrides the global buzzer mute** (**Settings Sound Buzzer**) — it's an explicit "I want to hear this". It works independently of the arrive/leave crossing alert (which does follow the mute), so you can use either or both.

View File

@@ -1,10 +1,13 @@
#pragma once
// Geo-alert config tool. Tools Geo Alert.
// A single geofence around a saved waypoint: when armed, the device beeps and
// shows an alert as it crosses into (arrive) or out of (leave) the radius. The
// target is snapshotted from a waypoint (coord + label), so the alert keeps
// working even if that waypoint is later edited or deleted. The crossing engine
// itself lives in UITask::evaluateGeoAlert().
// A single geofence whose target is either a saved waypoint (a place) or a
// person — a favourite/contact or a live [LOC] sender, keyed by pubkey prefix.
// When armed the device beeps / alerts as it crosses into (arrive/near) or out
// of (leave/away) the radius. A waypoint target is snapshotted (coord + label);
// a person target follows their latest shared position. The crossing engine
// lives in UITask::evaluateGeoAlert(). The Target row's Enter opens a picker
// (favourites first, then active live senders, then waypoints); LEFT/RIGHT
// quick-cycles the same set.
// Included by UITask.cpp after LiveShareScreen.h.
#include "../NodePrefs.h"
@@ -19,6 +22,15 @@ class GeoAlertScreen : public UIScreen {
int _sel = 0;
int _scroll = 0;
// Target picker (Enter on the Target row): a flat selectable list of people
// and places, rebuilt from favourites / live senders / waypoints on open.
bool _picking = false;
int _pick_sel = 0, _pick_scroll = 0;
struct Target { uint8_t kind; int32_t lat, lon; uint8_t key[6]; char name[20]; }; // kind 0=waypoint 1=person
static const int TARGET_MAX = 24;
Target _targets[TARGET_MAX];
int _target_n = 0;
enum Kind : uint8_t { K_ENABLE, K_TARGET, K_RADIUS, K_MODE, K_BEEPER };
struct Row { Kind kind; const char* label; };
static const int ROW_COUNT = 5;
@@ -36,7 +48,7 @@ class GeoAlertScreen : public UIScreen {
public:
GeoAlertScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs) {}
void enter() { _dirty = false; _sel = 0; _scroll = 0; }
void enter() { _dirty = false; _sel = 0; _scroll = 0; _picking = false; }
void valueLabel(Kind k, char* buf, int n) {
switch (k) {
@@ -72,6 +84,7 @@ public:
int render(DisplayDriver& display) override {
display.setTextSize(1);
display.setColor(DisplayDriver::LIGHT);
if (_picking) { renderPicker(display); return 400; }
display.drawCenteredHeader("GEO ALERT");
const int valx = display.width() / 2 + 6;
@@ -121,53 +134,112 @@ public:
_task->resetGeoAlert();
}
// Cycle the target through saved waypoints (static) and verified live contacts
// (a person sharing [LOC] over a DM), snapshotting the chosen one into prefs.
// A waypoint stores coord+label; a contact stores the pubkey prefix so the
// engine can follow its live position, plus the name and last-known coord.
void cycleTarget(int dir) {
WaypointStore& wp = _task->waypoints();
// Build the selectable target set into _targets: favourites first (the quick
// path you pin ahead of time), then active verified live senders not already
// pinned, then saved waypoints. A person is keyed by pubkey prefix so the
// engine follows their live position even before/independent of a [LOC].
void buildTargets() {
_target_n = 0;
for (int i = 0; i < NodePrefs::FAVOURITES_COUNT && _target_n < TARGET_MAX; i++) {
const uint8_t* pre = _prefs->favourite_contacts[i];
bool empty = true;
for (int b = 0; b < NodePrefs::FAVOURITE_PREFIX_LEN; b++) if (pre[b]) { empty = false; break; }
if (empty) continue;
ContactInfo* c = the_mesh.lookupContactByPubKey(pre, NodePrefs::FAVOURITE_PREFIX_LEN);
if (!c) continue;
Target& t = _targets[_target_n++];
t.kind = 1; t.lat = t.lon = 0;
memcpy(t.key, pre, 6);
snprintf(t.name, sizeof(t.name), "%s", c->name);
}
LiveTrackStore& lt = _task->liveTrack();
uint32_t now = rtc_clock.getCurrentTime();
for (int i = 0; i < LiveTrackStore::CAPACITY && _target_n < TARGET_MAX; i++) {
if (!lt.isActive(i, now) || !lt.slotAt(i).verified) continue;
const LiveTrackStore::Entry& e = lt.slotAt(i);
bool dup = false;
for (int j = 0; j < _target_n; j++)
if (_targets[j].kind == 1 && memcmp(_targets[j].key, e.key, 6) == 0) { dup = true; break; }
if (dup) continue;
Target& t = _targets[_target_n++];
t.kind = 1; t.lat = e.lat_1e6; t.lon = e.lon_1e6;
memcpy(t.key, e.key, 6);
snprintf(t.name, sizeof(t.name), "%s", e.name);
}
WaypointStore& wp = _task->waypoints();
for (int i = 0; i < wp.count() && _target_n < TARGET_MAX; i++) {
const Waypoint& w = wp.at(i);
Target& t = _targets[_target_n++];
t.kind = 0; t.lat = w.lat_1e6; t.lon = w.lon_1e6;
memset(t.key, 0, 6);
snprintf(t.name, sizeof(t.name), "%s", w.label);
}
}
struct T { uint8_t kind; int idx; }; // kind 0=waypoint, 1=live contact (slot)
T list[32]; int n = 0;
for (int i = 0; i < wp.count() && n < 32; i++) list[n++] = { 0, i };
for (int i = 0; i < LiveTrackStore::CAPACITY && n < 32; i++)
if (lt.isActive(i, now) && lt.slotAt(i).verified) list[n++] = { 1, i };
if (n == 0) { _task->showAlert("No waypoints / live", 1400); return; }
// Locate the current target in the combined list.
int cur = -1;
for (int i = 0; i < n; i++) {
if (list[i].kind == 0 && _prefs->geo_alert_target_kind == 0) {
const Waypoint& w = wp.at(list[i].idx);
if (w.lat_1e6 == _prefs->geo_alert_lat_1e6 && w.lon_1e6 == _prefs->geo_alert_lon_1e6) { cur = i; break; }
} else if (list[i].kind == 1 && _prefs->geo_alert_target_kind == 1) {
if (memcmp(lt.slotAt(list[i].idx).key, _prefs->geo_alert_key, LiveTrackStore::KEY_LEN) == 0) { cur = i; break; }
// Index of the currently-configured target within _targets, or -1.
int currentTargetIndex() const {
for (int i = 0; i < _target_n; i++) {
if (_targets[i].kind == 1 && _prefs->geo_alert_target_kind == 1) {
if (memcmp(_targets[i].key, _prefs->geo_alert_key, 6) == 0) return i;
} else if (_targets[i].kind == 0 && _prefs->geo_alert_target_kind == 0) {
if (_targets[i].lat == _prefs->geo_alert_lat_1e6 &&
_targets[i].lon == _prefs->geo_alert_lon_1e6) return i;
}
}
int nx = (cur < 0) ? (dir >= 0 ? 0 : n - 1) : ((cur + (dir >= 0 ? 1 : n - 1)) % n);
return -1;
}
if (list[nx].kind == 0) {
const Waypoint& w = wp.at(list[nx].idx);
_prefs->geo_alert_target_kind = 0;
_prefs->geo_alert_lat_1e6 = w.lat_1e6;
_prefs->geo_alert_lon_1e6 = w.lon_1e6;
snprintf(_prefs->geo_alert_label, sizeof(_prefs->geo_alert_label), "%s", w.label);
} else {
const LiveTrackStore::Entry& e = lt.slotAt(list[nx].idx);
_prefs->geo_alert_target_kind = 1;
memcpy(_prefs->geo_alert_key, e.key, LiveTrackStore::KEY_LEN);
_prefs->geo_alert_lat_1e6 = e.lat_1e6;
_prefs->geo_alert_lon_1e6 = e.lon_1e6;
snprintf(_prefs->geo_alert_label, sizeof(_prefs->geo_alert_label), "%s", e.name);
}
void applyTarget(const Target& t) {
_prefs->geo_alert_target_kind = t.kind;
if (t.kind == 1) memcpy(_prefs->geo_alert_key, t.key, 6);
_prefs->geo_alert_lat_1e6 = t.lat;
_prefs->geo_alert_lon_1e6 = t.lon;
snprintf(_prefs->geo_alert_label, sizeof(_prefs->geo_alert_label), "%s", t.name);
_prefs->geo_alert_has_target = 1;
_dirty = true;
_task->resetGeoAlert();
}
// LEFT/RIGHT quick-cycle over the same set the picker shows.
void cycleTarget(int dir) {
buildTargets();
if (_target_n == 0) { _task->showAlert("No favs / waypoints", 1400); return; }
int cur = currentTargetIndex();
int nx = (cur < 0) ? (dir >= 0 ? 0 : _target_n - 1)
: ((cur + (dir >= 0 ? 1 : _target_n - 1)) % _target_n);
applyTarget(_targets[nx]);
}
void openPicker() {
buildTargets();
if (_target_n == 0) { _task->showAlert("No favs / waypoints", 1400); return; }
int cur = currentTargetIndex();
_pick_sel = (cur >= 0) ? cur : 0;
_pick_scroll = 0;
_picking = true;
}
void renderPicker(DisplayDriver& display) {
display.drawCenteredHeader("PICK TARGET");
drawList(display, _target_n, _pick_sel, _pick_scroll, [&](int i, int y, bool sel, int reserve) {
display.drawSelectionRow(0, y - 1, display.width() - reserve, display.lineStep() - 1, sel);
char row[28];
// '@' marks a person; a plain name is a waypoint.
if (_targets[i].kind == 1) snprintf(row, sizeof(row), "@%s", _targets[i].name);
else snprintf(row, sizeof(row), "%s", _targets[i].name);
display.drawTextEllipsized(2, y, display.width() - 2 - reserve, row);
});
}
bool handleInput(char c) override {
// Target picker takes all input while open.
if (_picking) {
if (c == KEY_UP) { _pick_sel = (_pick_sel > 0) ? _pick_sel - 1 : _target_n - 1; return true; }
if (c == KEY_DOWN) { _pick_sel = (_pick_sel < _target_n - 1) ? _pick_sel + 1 : 0; return true; }
if (c == KEY_ENTER) { applyTarget(_targets[_pick_sel]); _picking = false; return true; }
if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { _picking = false; return true; }
return true;
}
if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) {
if (_dirty) { the_mesh.savePrefs(); _dirty = false; } // engine re-seeded per edit
_task->gotoToolsScreen();
@@ -177,7 +249,10 @@ public:
if (c == KEY_DOWN) { moveSel(+1); return true; }
if (keyIsPrev(c)) { activate(-1); return true; }
if (keyIsNext(c)) { activate(+1); return true; }
if (c == KEY_ENTER) { activate(+1); return true; }
if (c == KEY_ENTER) {
if (rows(_sel).kind == K_TARGET) { openPicker(); return true; } // full list picker
activate(+1); return true;
}
return false;
}
};