diff --git a/examples/companion_radio/ui-core/NearbyModel.h b/examples/companion_radio/ui-core/NearbyModel.h index 732d752c..8c9ceab1 100644 --- a/examples/companion_radio/ui-core/NearbyModel.h +++ b/examples/companion_radio/ui-core/NearbyModel.h @@ -43,6 +43,7 @@ public: // channel (name, best-effort) one. bool is_live; bool live_verified; + bool is_sos; // the live position came with an SOS (LiveTrackStore::Entry::sos) // Favourite (ContactInfo::flags bit 0) -- only a contact carries it. bool fav; }; @@ -130,6 +131,7 @@ public: e.is_known = true; e.is_live = false; e.live_verified = false; + e.is_sos = false; } mergeLiveTrack(); @@ -161,6 +163,7 @@ public: e.contact_idx = -1; e.is_live = false; e.live_verified = false; + e.is_sos = false; } // strongest first for (int i = 0; i < _count - 1; i++) { @@ -230,6 +233,7 @@ protected: if (s.ts > e.lastmod) e.lastmod = s.ts; e.is_live = true; e.live_verified = s.verified; + e.is_sos = s.sos; } 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. @@ -253,6 +257,7 @@ protected: e.is_known = false; e.is_live = true; e.live_verified = s.verified; + e.is_sos = s.sos; } } } @@ -287,6 +292,7 @@ protected: e.contact_idx = -1; e.is_known = false; e.is_live = false; + e.is_sos = false; } } diff --git a/examples/companion_radio/ui-lvgl/HomeScreen.h b/examples/companion_radio/ui-lvgl/HomeScreen.h index 203301ce..b70f9c39 100644 --- a/examples/companion_radio/ui-lvgl/HomeScreen.h +++ b/examples/companion_radio/ui-lvgl/HomeScreen.h @@ -142,7 +142,7 @@ static lv_obj_t* s_imgs[COLS * ROWS]; static lv_obj_t* s_hint = nullptr; static lv_obj_t* s_caption = nullptr; static const int MAX_PTS = LiveTrackStore::CAPACITY + 2; -enum : uint8_t { P_ME, P_LAST, P_TARGET, P_LIVE }; // how a point's dot looks +enum : uint8_t { P_ME, P_LAST, P_TARGET, P_LIVE, P_SOS }; // how a point's dot looks struct Pt { int32_t lat, lon; uint8_t kind; lv_obj_t* obj; }; static PSRAM_BSS Pt s_pts[MAX_PTS]; // [0] you, then the target, then live shares static int s_npts = 0; @@ -160,6 +160,7 @@ static lv_obj_t* dot(uint8_t kind) { const struct { uint8_t d; uint32_t col, border; } LOOK[] = { { 14, theme::ACCENT, theme::BG }, { 14, theme::BG, theme::ACCENT }, // you; no fix: the last position, hollow { 12, theme::BG, theme::ACCENT }, { 12, theme::OK, theme::BG }, // the target; live shares + { 12, theme::FAIL, theme::BG }, // someone's SOS, pulsing }; int d = LOOK[kind].d; uint32_t col = LOOK[kind].col, border = LOOK[kind].border; @@ -172,6 +173,7 @@ static lv_obj_t* dot(uint8_t kind) { lv_obj_set_style_border_color(o, lv_color_hex(border), 0); lv_obj_set_style_border_width(o, 2, 0); lv_obj_remove_flag(o, LV_OBJ_FLAG_CLICKABLE); + if (kind == P_SOS) mapview::sosPulse(o); return o; } } // namespace mini @@ -708,7 +710,7 @@ bool UITask::homeMapFit() { for (int i = 0; i < LiveTrackStore::CAPACITY && np < MAX_PTS; i++) { if (!lt.isActive(i, now)) continue; const LiveTrackStore::Entry& e = lt.slotAt(i); - pts[np++] = { e.lat_1e6, e.lon_1e6, P_LIVE, nullptr }; + pts[np++] = { e.lat_1e6, e.lon_1e6, e.sos ? P_SOS : P_LIVE, nullptr }; live++; } bool same = np == s_npts; diff --git a/examples/companion_radio/ui-lvgl/MapScreen.h b/examples/companion_radio/ui-lvgl/MapScreen.h index 94f74937..76825d05 100644 --- a/examples/companion_radio/ui-lvgl/MapScreen.h +++ b/examples/companion_radio/ui-lvgl/MapScreen.h @@ -189,6 +189,7 @@ struct Mark { int16_t lx, ly, lw, lh; // name pill, layer px (lw 0: no room for it) uint8_t order; // 1.. while it is a hop of the route being picked, else 0 bool vis; // inside the view + bool sos; // someone's SOS: a pulsing ring around the dot (s_sos_rings) }; // What was drawn for a slot last time, to repaint only what changed. struct MarkSig { int16_t x, y, lx, ly, lw; uint32_t col, hash; uint8_t order; bool vis; }; @@ -198,6 +199,10 @@ static Mark* s_marks = psramBuf(MAX_MARKS); static MarkSig* s_sigs = psramBuf(MAX_MARKS); static int s_sig_n = 0; static lv_obj_t* s_mark_layer = nullptr; +// Rings pulsing out of the SOS markers in view: objects over the drawn layer, +// so only their small area is repainted each frame, not the layer. +static const int SOS_RINGS = 4; +static lv_obj_t* s_sos_rings[SOS_RINGS]; // The route being picked (Route > Map): its points as lat/lon, and in layer px // from the last layout, drawn as a line under the markers. static const int ROUTE_PTS = HopRoute::MAX_PTS; // a picked route has 10 at most @@ -505,6 +510,27 @@ static void drawFlows(lv_layer_t* layer, const lv_area_t& a) { } static const int MARK_D = 12; // marker dot diameter + +// An SOS marker's ring: an outline round `o` (the dot's size) that grows out +// of it and fades, over and over. Home's mini map uses it too. +static void setSosPulse(void* o, int32_t v) { + lv_obj_set_style_outline_pad((lv_obj_t*)o, (int32_t)(v * 14 / 1000), 0); + lv_obj_set_style_outline_opa((lv_obj_t*)o, (lv_opa_t)(LV_OPA_COVER - v * LV_OPA_COVER / 1000), 0); +} +static void sosPulse(lv_obj_t* o) { + lv_obj_set_style_outline_color(o, lv_color_hex(theme::FAIL), 0); + lv_obj_set_style_outline_width(o, 2, 0); + lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_var(&a, o); + lv_anim_set_exec_cb(&a, setSosPulse); + lv_anim_set_values(&a, 0, 1000); + lv_anim_set_duration(&a, 1200); + lv_anim_set_repeat_delay(&a, 300); + lv_anim_set_repeat_count(&a, LV_ANIM_REPEAT_INFINITE); + lv_anim_set_path_cb(&a, lv_anim_path_ease_out); + lv_anim_start(&a); +} static const int MARK_LABELS_MAX = 20; // names shown at once: the rest stay dots // LV_EVENT_DRAW_MAIN of the marker layer: the name pills, then the dots over them. @@ -830,6 +856,15 @@ void UITask::buildMap() { lv_obj_remove_flag(mapview::s_mark_layer, LV_OBJ_FLAG_CLICKABLE); lv_obj_remove_flag(mapview::s_mark_layer, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_event_cb(mapview::s_mark_layer, mapview::drawMarks, LV_EVENT_DRAW_MAIN, NULL); + for (lv_obj_t*& r : mapview::s_sos_rings) { + r = lv_obj_create(mapview::s_mark_layer); + lv_obj_remove_style_all(r); + lv_obj_set_size(r, mapview::MARK_D + 1, mapview::MARK_D + 1); + lv_obj_set_style_radius(r, LV_RADIUS_CIRCLE, 0); + lv_obj_remove_flag(r, LV_OBJ_FLAG_CLICKABLE); + lv_obj_add_flag(r, LV_OBJ_FLAG_HIDDEN); + mapview::sosPulse(r); + } mapview::s_flow = &_core->flow; mapview::s_view_ok = false; mapview::s_flow_area_on = false; @@ -1120,15 +1155,15 @@ void UITask::rebuildNodeMarkers() { const NearbyModel::Entry& e = _nearby->at(i); if (e.lat_e6 == 0 && e.lon_e6 == 0) continue; if (e.is_live && _map_nav) continue; // the live share's own marker shows it - uint32_t col = e.is_live ? theme::OK : e.type == ADV_TYPE_CHAT ? theme::TEXT : theme::TEXT_MUTED; - addMapMark(mapview::MK_NODE, i, e.lat_e6, e.lon_e6, col, e.name[0] ? e.name : "?"); + uint32_t col = e.is_sos ? theme::FAIL : e.is_live ? theme::OK : e.type == ADV_TYPE_CHAT ? theme::TEXT : theme::TEXT_MUTED; + addMapMark(mapview::MK_NODE, i, e.lat_e6, e.lon_e6, col, e.name[0] ? e.name : "?", e.is_sos); } } // A marker is a record: where it is, its colour and name. Placement and // painting are layoutMapMarks() and mapview::drawMarks(). The name's width is // measured here, not per pan: a slot whose name is unchanged keeps it. -void UITask::addMapMark(uint8_t kind, int idx, int32_t lat_e6, int32_t lon_e6, uint32_t col, const char* text) { +void UITask::addMapMark(uint8_t kind, int idx, int32_t lat_e6, int32_t lon_e6, uint32_t col, const char* text, bool sos) { if (!_map_marks || mapview::s_mark_count >= mapview::MAX_MARKS) return; uint32_t h = 2166136261u; // FNV-1a of the name for (const char* c = text; *c; c++) h = (h ^ (uint8_t)*c) * 16777619u; @@ -1136,7 +1171,7 @@ void UITask::addMapMark(uint8_t kind, int idx, int32_t lat_e6, int32_t lon_e6, u mapview::Mark& mk = mapview::s_marks[k]; bool same_name = k < mapview::s_mark_prev && mk.text_hash == h; mk.lat_e6 = lat_e6; mk.lon_e6 = lon_e6; mk.idx = idx; mk.kind = kind; mk.col = col; mk.text_hash = h; - mk.vis = false; mk.lw = 0; mk.order = 0; + mk.vis = false; mk.lw = 0; mk.order = 0; mk.sos = sos; if (same_name) return; size_t n = strlen(text); if (n > (size_t)mapview::MARK_NAME) { // cut at a character boundary @@ -1268,6 +1303,14 @@ void UITask::layoutMapMarks(double left, double top, int w, int h) { const Mark& m = s_marks[k]; s_sigs[k] = { m.x, m.y, m.lx, m.ly, m.lw, m.col, m.text_hash, m.order, m.vis }; } + int rings = 0; + for (int k = 0; k < s_mark_count && rings < SOS_RINGS; k++) { + const Mark& m = s_marks[k]; + if (!m.vis || !m.sos || !s_sos_rings[rings]) continue; + lv_obj_set_pos(s_sos_rings[rings], m.x - half, m.y - half); + lv_obj_remove_flag(s_sos_rings[rings++], LV_OBJ_FLAG_HIDDEN); + } + for (; rings < SOS_RINGS; rings++) if (s_sos_rings[rings]) lv_obj_add_flag(s_sos_rings[rings], LV_OBJ_FLAG_HIDDEN); s_sig_n = s_mark_count; } diff --git a/examples/companion_radio/ui-lvgl/NavMap.h b/examples/companion_radio/ui-lvgl/NavMap.h index f2b852fb..fa41d82a 100644 --- a/examples/companion_radio/ui-lvgl/NavMap.h +++ b/examples/companion_radio/ui-lvgl/NavMap.h @@ -306,7 +306,7 @@ void UITask::rebuildNavMarkers() { char age[8], t[LiveTrackStore::NAME_LEN + 12]; geo::fmtAgeShort(age, sizeof(age), now, e.ts); snprintf(t, sizeof(t), "%s%s%s", e.name, age[0] ? " " : "", age); - addMapMark(mapview::MK_LIVE, i, e.lat_1e6, e.lon_1e6, theme::OK, t); + addMapMark(mapview::MK_LIVE, i, e.lat_1e6, e.lon_1e6, e.sos ? theme::FAIL : theme::OK, t, e.sos); } navTrailSync(); @@ -1550,10 +1550,10 @@ void UITask::navTargetsPopup() { const LiveTrackStore::Entry& e = lt.slotAt(i); char age[8], sub[40], d[12]; geo::fmtAgeShort(age, sizeof(age), now, e.ts); - snprintf(sub, sizeof(sub), "%s - %s ago", e.verified ? "direct" : "channel", age[0] ? age : "0s"); + snprintf(sub, sizeof(sub), "%s%s - %s ago", e.sos ? "SOS, " : "", e.verified ? "direct" : "channel", age[0] ? age : "0s"); lv_obj_t* row = listRow(list, e.name, sub, onNavPick, (void*)(uintptr_t)navmap::code(navmap::T_LIVE, i)); dist(e.lat_1e6, e.lon_1e6, d, sizeof(d)); - if (d[0]) navRowRight(row, d, theme::OK, theme::PAD); + if (d[0]) navRowRight(row, d, e.sos ? theme::FAIL : theme::OK, theme::PAD); live++; } if (live == 0) label(list, "Nobody is sharing their position.", THEME_FONT_SMALL, theme::TEXT_MUTED); diff --git a/examples/companion_radio/ui-lvgl/NodesView.h b/examples/companion_radio/ui-lvgl/NodesView.h index 02d95dc2..8a407772 100644 --- a/examples/companion_radio/ui-lvgl/NodesView.h +++ b/examples/companion_radio/ui-lvgl/NodesView.h @@ -182,7 +182,7 @@ uint32_t UITask::nearbySignature() const { for (int i = 0; i < _nearby->count(); i++) { const NearbyModel::Entry& e = _nearby->at(i); sig = sig * 31 + e.contact_idx + (uint32_t)e.lastmod + (uint32_t)(e.dist_km * 100) + e.rssi + e.fav - + e.type * 5 + (e.is_live ? 3 : 0); + + e.type * 5 + (e.is_live ? 3 : 0) + (e.is_sos ? 17 : 0); for (const char* p = e.name; *p; p++) sig = sig * 31 + (uint8_t)*p; } // Ages are shown in minutes, so let the list re-render once a minute anyway. @@ -201,9 +201,9 @@ static void nearbyText(const NearbyModel::Entry& e, uint32_t now, bool imperial, if (e.dist_km >= 0.0f) geo::fmtDist(t.right, sizeof(t.right), e.dist_km, imperial); else if (age[0]) snprintf(t.right, sizeof(t.right), "%s", age); const char* kind = e.contact_idx >= 0 ? NearbyModel::typeName(e.type) : "not a contact"; - snprintf(t.sub, sizeof(t.sub), "%s%s%s%s", kind, e.is_live ? " - live" : "", + snprintf(t.sub, sizeof(t.sub), "%s%s%s%s", kind, e.is_sos ? " - SOS" : e.is_live ? " - live" : "", (e.dist_km >= 0.0f && age[0]) ? " - " : "", (e.dist_km >= 0.0f && age[0]) ? age : ""); - t.right_col = e.is_live ? theme::OK : theme::TEXT_MUTED; + t.right_col = e.is_sos ? theme::FAIL : e.is_live ? theme::OK : theme::TEXT_MUTED; } // A small arrow towards the node, drawn in the value label's left padding: @@ -391,7 +391,8 @@ void UITask::refreshNode() { int o = 0; auto tag = [&](const char* t) { o += snprintf(status + o, sizeof(status) - o, "%s%s", o ? ", " : "", t); }; if (!known) tag("not a contact"); - if (e.is_live) tag(e.live_verified ? "live position" : "live position (channel)"); + if (e.is_sos) tag(e.live_verified ? "SOS position" : "SOS position (channel)"); + else if (e.is_live) tag(e.live_verified ? "live position" : "live position (channel)"); if (e.fav) tag("favourite"); if (status[0] >= 'a' && status[0] <= 'z') status[0] -= 'a' - 'A'; int32_t lat, lon; diff --git a/examples/companion_radio/ui-lvgl/UITask.h b/examples/companion_radio/ui-lvgl/UITask.h index 1788b9bf..46ce7dfe 100644 --- a/examples/companion_radio/ui-lvgl/UITask.h +++ b/examples/companion_radio/ui-lvgl/UITask.h @@ -486,7 +486,7 @@ private: void mapLoop(); void rebuildMapMarkers(); void rebuildNodeMarkers(); - void addMapMark(uint8_t kind, int idx, int32_t lat_e6, int32_t lon_e6, uint32_t col, const char* text); + void addMapMark(uint8_t kind, int idx, int32_t lat_e6, int32_t lon_e6, uint32_t col, const char* text, bool sos = false); void mapPointToLatLon(int x, int y, int32_t& lat_e6, int32_t& lon_e6) const; double mapCenterBias() const; int mapBottomH() const;