From 440a222e4b10fce876b411a78710a33846944dde Mon Sep 17 00:00:00 2001 From: MarekZegare4 Date: Mon, 22 Jun 2026 13:45:47 +0200 Subject: [PATCH] feat(geo-alert): pick target from a list (favourites first) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../tools_screen/tools_screen.md | 4 +- .../companion_radio/ui-new/GeoAlertScreen.h | 161 +++++++++++++----- 2 files changed, 120 insertions(+), 45 deletions(-) diff --git a/docs/solo_features/tools_screen/tools_screen.md b/docs/solo_features/tools_screen/tools_screen.md index 9dbef8b8..9174486b 100644 --- a/docs/solo_features/tools_screen/tools_screen.md +++ b/docs/solo_features/tools_screen/tools_screen.md @@ -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. diff --git a/examples/companion_radio/ui-new/GeoAlertScreen.h b/examples/companion_radio/ui-new/GeoAlertScreen.h index db406b11..f0446a0b 100644 --- a/examples/companion_radio/ui-new/GeoAlertScreen.h +++ b/examples/companion_radio/ui-new/GeoAlertScreen.h @@ -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; } };