From 9597e1e2a40cfcbf0326c071b962d99218670179 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Fri, 2 Oct 2026 23:26:29 +0200 Subject: [PATCH] feat(ui-lvgl): e-ink graphics in colour on the L2 Node detail: a card with a compass rose and the hop chain over the info; the bar keeps Message / Admin / Add, Ping and Navigate, the rest goes to a "More" group under the info (favourite toggled in place, delete by a second tap). Nodes: an arrow by each distance. A trail's height profile in Map tools and a saved trail's popup. Diagnostics > History: battery, noise, SNR and traffic charts. Clock > Calendar: today, the weekend and alarm days. Co-Authored-By: Claude Opus 5.5 --- .../companion_radio/ui-lvgl/ClockScreen.h | 100 ++++++++++- examples/companion_radio/ui-lvgl/DiagScreen.h | 160 ++++++++++++++++- examples/companion_radio/ui-lvgl/NavMap.h | 13 +- examples/companion_radio/ui-lvgl/NodeViz.h | 170 ++++++++++++++++++ .../companion_radio/ui-lvgl/ProfileView.h | 70 ++++++++ examples/companion_radio/ui-lvgl/UITask.cpp | 104 ++++++++--- examples/companion_radio/ui-lvgl/UITask.h | 2 + 7 files changed, 582 insertions(+), 37 deletions(-) create mode 100644 examples/companion_radio/ui-lvgl/NodeViz.h create mode 100644 examples/companion_radio/ui-lvgl/ProfileView.h diff --git a/examples/companion_radio/ui-lvgl/ClockScreen.h b/examples/companion_radio/ui-lvgl/ClockScreen.h index 6f276901..0ebd33da 100644 --- a/examples/companion_radio/ui-lvgl/ClockScreen.h +++ b/examples/companion_radio/ui-lvgl/ClockScreen.h @@ -1,6 +1,7 @@ #pragma once -// Clock tools (Home > Clock): alarm, countdown timer and stopwatch -- ui-new's -// Tools > Clock on a touch screen. The alarm (NodePrefs alarm_*) and the +// Clock tools (Home > Clock): alarm, countdown timer, stopwatch and this +// month's calendar -- ui-new's Tools > Clock on a touch screen (the calendar +// as ui-new's e-ink has it under the clock). The alarm (NodePrefs alarm_*) and the // countdown run in the Core's ClockEngine, so they fire on any screen and while // the display sleeps; the stopwatch is view state here, as in ui-new. Whatever // fires brings up a full-screen card with Dismiss (silent: the L2 speaker has @@ -10,7 +11,7 @@ namespace clockview { -enum : uint8_t { TAB_ALARM, TAB_TIMER, TAB_STOPWATCH, TAB_COUNT }; +enum : uint8_t { TAB_ALARM, TAB_TIMER, TAB_STOPWATCH, TAB_CALENDAR, TAB_COUNT }; enum : uint8_t { ACT_START_STOP, ACT_RESET }; static uint8_t s_tab = TAB_ALARM; @@ -24,6 +25,63 @@ static lv_obj_t* s_ring = nullptr; // "Timer done" card on the top laye static lv_obj_t* s_ring_lbl = nullptr; static lv_obj_t* s_alarm_sw = nullptr; // follows a time change (setting it arms the alarm) +static struct tm s_cal_tm; // the day the calendar was drawn for (local) +static uint32_t s_cal_marks = 0; // its alarm days, bit (day - 1) +static lv_obj_t* s_cal = nullptr; + +// The month as a grid, weeks from Monday: the weekday names, today in an +// accent box, the alarm's days with an accent bar under them, the weekend +// dimmer. +static void onCalendarDraw(lv_event_t* e) { + lv_obj_t* o = (lv_obj_t*)lv_event_get_target(e); + lv_layer_t* layer = lv_event_get_layer(e); + lv_area_t a; + lv_obj_get_coords(o, &a); + const struct tm& t = s_cal_tm; + static const char* const NAMES[] = { "Mo", "Tu", "We", "Th", "Fr", "Sa", "Su" }; + const int W = a.x2 - a.x1 + 1, cw = W / 7, x0 = a.x1 + (W - 7 * cw) / 2; + const int head = 16, rows = calmath::weeks(t), ch = (a.y2 - a.y1 + 1 - head) / rows; + lv_draw_label_dsc_t td; + lv_draw_label_dsc_init(&td); + td.align = LV_TEXT_ALIGN_CENTER; + td.font = THEME_FONT_SMALL; + for (int c = 0; c < 7; c++) { + td.color = lv_color_hex(theme::TEXT_MUTED); + td.text = NAMES[c]; + lv_area_t la = { x0 + c * cw, a.y1, x0 + (c + 1) * cw - 1, a.y1 + head - 1 }; + lv_draw_label(layer, &td, &la); + } + td.font = THEME_FONT_BODY; + const int fh = lv_font_get_line_height(td.font), col0 = calmath::firstCol(t), n = calmath::daysIn(t.tm_year, t.tm_mon); + char buf[3]; + for (int day = 1; day <= n; day++) { + const int k = col0 + day - 1, cx = x0 + (k % 7) * cw, cy = a.y1 + head + (k / 7) * ch; + const bool today = day == t.tm_mday; + if (today) { + lv_draw_rect_dsc_t rd; + lv_draw_rect_dsc_init(&rd); + rd.radius = theme::RADIUS_SM; + rd.bg_color = lv_color_hex(theme::ACCENT); + const int bh = ch - 2 < fh + 2 ? ch - 2 : fh + 2, by = cy + (ch - bh) / 2; + lv_area_t ra = { cx + 4, by, cx + cw - 5, by + bh - 1 }; + lv_draw_rect(layer, &rd, &ra); + } + snprintf(buf, sizeof(buf), "%d", day); + td.text = buf; + td.color = lv_color_hex(today ? theme::BG : k % 7 >= 5 ? theme::TEXT_MUTED : theme::TEXT); + lv_area_t la = { cx, cy + (ch - fh) / 2, cx + cw - 1, cy + (ch + fh) / 2 }; + lv_draw_label(layer, &td, &la); + if (s_cal_marks & (1UL << (day - 1))) { // under the number + lv_draw_rect_dsc_t md; + lv_draw_rect_dsc_init(&md); + md.radius = 1; + md.bg_color = lv_color_hex(today ? theme::BG : theme::ACCENT); + lv_area_t ma = { cx + cw / 2 - 6, cy + ch - 2, cx + cw / 2 + 6, cy + ch - 1 }; + lv_draw_rect(layer, &md, &ma); + } + } +} + static uint32_t swElapsed() { return s_sw_accum + (s_sw_running ? millis() - s_sw_start : 0); } // Countdown: rounds up, so "00:01" shows until it actually fires. @@ -243,12 +301,11 @@ void UITask::buildClock() { lv_obj_t* body = newScreen("Clock", true); lv_obj_set_style_pad_row(body, 6, 0); lv_obj_remove_flag(body, LV_OBJ_FLAG_SCROLLABLE); - s_big = s_go_lbl = s_alarm_sw = nullptr; + s_big = s_go_lbl = s_alarm_sw = s_cal = nullptr; // Tabs lv_obj_t* tabs = row(body); - static const char* const NAMES[TAB_COUNT] = { LV_SYMBOL_BELL " Alarm", UI_SYMBOL_CLOCK " Timer", - UI_SYMBOL_STOPWATCH " Stopwatch" }; + static const char* const NAMES[TAB_COUNT] = { "Alarm", "Timer", "Stopwatch", "Calendar" }; for (int t = 0; t < TAB_COUNT; t++) { lv_obj_t* b = lv_button_create(tabs); lv_obj_set_height(b, 32); @@ -296,6 +353,29 @@ void UITask::buildClock() { return; } + if (s_tab == TAB_CALENDAR) { + struct tm t; + bool synced = localTime(_prefs, t); + if (!synced) { + label(body, "The clock isn't set yet: no date to show.", THEME_FONT_BODY, theme::TEXT_MUTED); + return; + } + static const char* const LONG_MONTHS[] = { "January", "February", "March", "April", "May", "June", "July", + "August", "September", "October", "November", "December" }; + char title[24]; // the month on the header's right: the grid gets the height + snprintf(title, sizeof(title), "%s %d", LONG_MONTHS[t.tm_mon], t.tm_year + 1900); + if (_header) lv_obj_align(label(_header, title, THEME_FONT_TITLE, theme::TEXT_MUTED), LV_ALIGN_RIGHT_MID, -theme::PAD, 0); + s_cal_tm = t; + s_cal_marks = calmath::alarmDays(t, _prefs->alarm_on, _prefs->alarm_repeat_mask, _prefs->alarm_hour, _prefs->alarm_min); + s_cal = lv_obj_create(body); + lv_obj_remove_style_all(s_cal); + lv_obj_remove_flag(s_cal, LV_OBJ_FLAG_CLICKABLE); + fillWidth(s_cal); + lv_obj_set_flex_grow(s_cal, 1); + lv_obj_add_event_cb(s_cal, onCalendarDraw, LV_EVENT_DRAW_MAIN_END, NULL); + return; + } + if (s_tab == TAB_TIMER) { s_shown_running = _core->clock.isTimerRunning(); if (s_shown_running) { @@ -343,6 +423,14 @@ void UITask::buildClock() { // From loop() while SCR_CLOCK is shown: the running readouts. void UITask::refreshClock() { using namespace clockview; + if (s_tab == TAB_CALENDAR) { // past midnight, or the alarm changed: a new day to box or mark + struct tm t; + if (s_cal && localTime(_prefs, t) + && (t.tm_mday != s_cal_tm.tm_mday || t.tm_mon != s_cal_tm.tm_mon + || calmath::alarmDays(t, _prefs->alarm_on, _prefs->alarm_repeat_mask, _prefs->alarm_hour, _prefs->alarm_min) != s_cal_marks)) + buildClock(); + return; + } if (s_tab == TAB_TIMER) { bool running = _core->clock.isTimerRunning(); if (running != s_shown_running) { buildClock(); return; } // started elsewhere / just fired diff --git a/examples/companion_radio/ui-lvgl/DiagScreen.h b/examples/companion_radio/ui-lvgl/DiagScreen.h index a2aa8afe..6ce5b843 100644 --- a/examples/companion_radio/ui-lvgl/DiagScreen.h +++ b/examples/companion_radio/ui-lvgl/DiagScreen.h @@ -1,7 +1,9 @@ #pragma once -// Home > Diagnostics -- ui-new's Tools > Diagnostics. Tabs Live / System / -// Font / Noise; the rows come from ui-core/Diagnostics.h. Live refreshes every second -// and its header button resets the counters (after a confirm). +// Home > Diagnostics -- ui-new's Tools > Diagnostics. Tabs Live / History / +// System / Font / Noise; the rows come from ui-core/Diagnostics.h. Live +// refreshes every second and its header button resets the counters (after a +// confirm). History charts the last 8 hours (ui-core/StatusHistory.h, as on +// e-ink): the battery, the noise floor, the SNR of the last packets, traffic. // // Single-TU fragment: included by ui-lvgl/UITask.cpp after DeviceScreen.h. @@ -11,7 +13,7 @@ extern RADIO_CLASS radio; // the variant's target.cpp (the noise sweep retunes namespace diagview { -enum : uint8_t { TAB_LIVE, TAB_SYSTEM, TAB_FONT, TAB_NOISE, TAB_COUNT }; +enum : uint8_t { TAB_LIVE, TAB_HISTORY, TAB_SYSTEM, TAB_FONT, TAB_NOISE, TAB_COUNT }; static uint8_t s_tab = TAB_LIVE; // kept across visits static lv_obj_t* s_list = nullptr; static const int EXTRA = 4; // GPS, power, last reset, last crash (L2 only) @@ -21,6 +23,118 @@ struct LiveVal { lv_obj_t* val; lv_obj_t* in; lv_obj_t* out; }; static LiveVal s_vals[diag::MAX_ROWS + EXTRA]; static int s_rows = 0; +// The history, a sample every 5 minutes from boot (UITask::sampleHistory()). +static uicore::StatusHistory<96, 300000UL> s_hist; +static uint32_t s_hist_shown = 0; // the history's version the charts show +static lv_obj_t* s_ch_obj[4]; // History's charts and their values now +static lv_obj_t* s_ch_now[4]; + +static uint32_t totalRecv() { + uint32_t n = 0; + for (uint8_t t = 0; t < 16; t++) n += the_mesh.getNumRecvByType(t); + return n; +} + +// One chart's samples as drawn: a copy, so the draw callback needn't know +// which ring (of which size) they came from. +struct Chart { int16_t v[96]; int n, cap, min_span; bool bars; }; +static Chart s_charts[4]; +template +static Chart* chartOf(int k, const uicore::History& h, int min_span, bool bars) { + Chart& c = s_charts[k]; + c.n = h.n; c.cap = N; c.min_span = min_span; c.bars = bars; + for (int i = 0; i < h.n; i++) c.v[i] = h.at(i); + return &c; +} + +// A history in its box, newest at the right: a line over a faint fill, or +// bars from the bottom, scaled between the samples' own min and max (at +// least min_span apart, so a steady reading stays flat). +static void onChartDraw(lv_event_t* e) { + lv_obj_t* o = (lv_obj_t*)lv_event_get_target(e); + const Chart& c = *(const Chart*)lv_event_get_user_data(e); + lv_layer_t* layer = lv_event_get_layer(e); + lv_area_t a; + lv_obj_get_coords(o, &a); + const int x0 = a.x1 + 2, w = a.x2 - a.x1 - 4, y0 = a.y1 + 3, h = a.y2 - a.y1 - 5, base = a.y2 - 1; + lv_draw_line_dsc_t ld; + lv_draw_line_dsc_init(&ld); + if (c.n < (c.bars ? 1 : 2)) { // nothing yet: a dotted baseline + ld.color = lv_color_hex(theme::TEXT_MUTED); + ld.width = 1; ld.dash_width = 2; ld.dash_gap = 4; + ld.p1.x = x0; ld.p2.x = x0 + w; ld.p1.y = ld.p2.y = base - 1; + lv_draw_line(layer, &ld); + return; + } + int lo = c.bars ? 0 : c.v[0], hi = c.v[0]; + for (int i = 0; i < c.n; i++) { if (c.v[i] < lo) lo = c.v[i]; if (c.v[i] > hi) hi = c.v[i]; } + if (hi - lo < c.min_span) { + if (c.bars) hi = lo + c.min_span; + else { const int mid = (hi + lo) / 2; lo = mid - c.min_span / 2; hi = lo + c.min_span; } + } + auto X = [&](int i) { return (int32_t)(x0 + w - (c.n - 1 - i) * w / (c.cap - 1)); }; + auto Y = [&](int v) { return (int32_t)(y0 + h - (v - lo) * h / (hi - lo)); }; + ld.color = lv_color_hex(theme::ACCENT); + if (c.bars) { // a bar a sample, a pixel apart + const int pitch = (w + c.cap / 2) / c.cap, bw = pitch > 2 ? pitch - 1 : 1; + lv_draw_rect_dsc_t rd; + lv_draw_rect_dsc_init(&rd); + rd.bg_color = lv_color_hex(theme::ACCENT); + rd.bg_opa = LV_OPA_70; + for (int i = 0; i < c.n; i++) { + if (c.v[i] <= 0) continue; + lv_area_t r = { X(i) - bw + 1, Y(c.v[i]), X(i), base }; + lv_draw_rect(layer, &rd, &r); + } + return; + } + lv_draw_line_dsc_t fill; + lv_draw_line_dsc_init(&fill); + fill.color = lv_color_hex(theme::ACCENT); + fill.opa = LV_OPA_20; + fill.width = 1; + ld.width = 2; + ld.round_start = ld.round_end = 1; + for (int i = 1; i < c.n; i++) { + const int32_t xa = X(i - 1), xb = X(i), ya = Y(c.v[i - 1]), yb = Y(c.v[i]); + for (int32_t x = (i == 1 ? xa : xa + 1); x <= xb; x++) { + fill.p1.x = fill.p2.x = x; + fill.p1.y = xb > xa ? ya + (yb - ya) * (x - xa) / (xb - xa) : yb; + fill.p2.y = base; + lv_draw_line(layer, &fill); + } + ld.p1.x = xa; ld.p1.y = ya; ld.p2.x = xb; ld.p2.y = yb; + lv_draw_line(layer, &ld); + } +} + +// A chart card: the title and the value now, the chart, the span under it. +static void chartCard(lv_obj_t* parent, int k, const char* title, const char* from) { + lv_obj_t* card = infoCard(parent); + lv_obj_set_style_pad_ver(card, 6, 0); + lv_obj_set_style_pad_row(card, 2, 0); + lv_obj_t* r = lv_obj_create(card); + lv_obj_remove_style_all(r); + fillWidth(r); + lv_obj_set_height(r, LV_SIZE_CONTENT); + lv_obj_align(label(r, title, THEME_FONT_SMALL, theme::TEXT_MUTED), LV_ALIGN_LEFT_MID, 0, 0); + s_ch_now[k] = label(r, "", THEME_FONT_BODY, theme::TEXT); + lv_obj_align(s_ch_now[k], LV_ALIGN_RIGHT_MID, 0, 0); + lv_obj_t* ch = lv_obj_create(card); + lv_obj_remove_style_all(ch); + lv_obj_remove_flag(ch, LV_OBJ_FLAG_CLICKABLE); + fillWidth(ch); + lv_obj_set_height(ch, 48); + lv_obj_add_event_cb(ch, onChartDraw, LV_EVENT_DRAW_MAIN_END, &s_charts[k]); + s_ch_obj[k] = ch; + lv_obj_t* ax = lv_obj_create(card); + lv_obj_remove_style_all(ax); + fillWidth(ax); + lv_obj_set_height(ax, LV_SIZE_CONTENT); + lv_obj_align(label(ax, from, THEME_FONT_SMALL, theme::TEXT_MUTED), LV_ALIGN_LEFT_MID, 0, 0); + lv_obj_align(label(ax, "now", THEME_FONT_SMALL, theme::TEXT_MUTED), LV_ALIGN_RIGHT_MID, 0, 0); +} + static void extraRow(diag::Row* rows, int& n, const char* lbl, const char* fmt, ...) { rows[n].label = lbl; va_list ap; va_start(ap, fmt); @@ -251,7 +365,7 @@ void UITask::buildDiag() { lv_obj_set_style_pad_row(body, 4, 0); if (s_tab == TAB_LIVE && _header) headerButton(_header, LV_SYMBOL_REFRESH " Reset", onDiagReset, 4, NULL); - static const char* TABS[] = { "Live", "System", "Font", "Noise", "" }; + static const char* TABS[] = { "Live", "History", "System", "Font", "Noise", "" }; lv_obj_t* tabs = segmented(body, TABS, s_tab, lv_pct(100), 34); lv_obj_set_style_bg_color(tabs, lv_color_hex(theme::SURFACE), LV_PART_ITEMS); lv_obj_add_event_cb(tabs, onDiagTab, LV_EVENT_VALUE_CHANGED, NULL); @@ -270,6 +384,14 @@ void UITask::buildDiag() { } s_noise_status = nullptr; + if (s_tab == TAB_HISTORY) { + chartCard(s_list, 0, "Battery", "8 h ago"); + chartCard(s_list, 1, "Noise floor", "8 h ago"); + chartCard(s_list, 2, "SNR, last packets", "32 packets ago"); + chartCard(s_list, 3, "Packets heard, per 5 min", "8 h ago"); + fillHistory(); + return; + } if (s_tab == TAB_NOISE) { noteLabel(s_list, "What the radio hears with nothing on air, on the mesh frequency and 1.1 MHz " "either side. Takes about 12 seconds."); @@ -365,8 +487,35 @@ void UITask::diagSection(int sec) { } } +// History's charts and values from the history as it is now. +void UITask::fillHistory() { + using namespace diagview; + s_hist_shown = s_hist.version; + chartOf(0, s_hist.batt, 20, false); + chartOf(1, s_hist.noise, 6, false); + chartOf(2, s_hist.snr, 20, false); + chartOf(3, s_hist.traffic, 4, true); + for (int k = 0; k < 4; k++) lv_obj_invalidate(s_ch_obj[k]); + setTextFmt(s_ch_now[0], "%.2f V", (_batt_mv ? _batt_mv : getBattMilliVolts()) / 1000.0f); + const int nf = (int)radio_driver.getNoiseFloor(); + if (nf) setTextFmt(s_ch_now[1], "%d dBm", nf); else setText(s_ch_now[1], "-"); + if (s_hist.snr.n) setTextFmt(s_ch_now[2], "%.1f dB", s_hist.snr.at(s_hist.snr.n - 1) / 4.0f); else setText(s_ch_now[2], "-"); + setTextFmt(s_ch_now[3], "%d", s_hist.traffic.n ? s_hist.traffic.at(s_hist.traffic.n - 1) : 0); +} + +// From loop(): the history's readings (it keeps its own pace). +void UITask::sampleHistory() { + using namespace diagview; + s_hist.tick(millis(), (int)radio_driver.getNoiseFloor(), _batt_mv ? _batt_mv : getBattMilliVolts(), + totalRecv(), radio_driver.getLastSNR()); +} + void UITask::refreshDiag() { using namespace diagview; + if (_screen == SCR_DIAG && s_tab == TAB_HISTORY && !_nav_overlay && s_hist.version != s_hist_shown) { + fillHistory(); // a new sample: the charts in place, the list stays where it was scrolled + return; + } if (_screen != SCR_DIAG || s_tab != TAB_LIVE || _nav_overlay) return; diag::Row rows[diag::MAX_ROWS + EXTRA]; int n = allRows(rows, _core->gpsEnabled()); @@ -381,6 +530,7 @@ void UITask::diagResetPopup() { void UITask::diagReset() { diag::resetCounters(); + diagview::s_hist.countersReset(); navClosePopup(); refreshDiag(); } diff --git a/examples/companion_radio/ui-lvgl/NavMap.h b/examples/companion_radio/ui-lvgl/NavMap.h index 2a77de33..e136ab0a 100644 --- a/examples/companion_radio/ui-lvgl/NavMap.h +++ b/examples/companion_radio/ui-lvgl/NavMap.h @@ -899,7 +899,7 @@ static void trailTitle(const char* name, char* out, size_t n) { // Points, distance and recorded time of a saved trail, from its file. template -static bool trailSummary(F& io, int& points, float& meters, uint32_t& secs) { +static bool trailSummary(F& io, int& points, float& meters, uint32_t& secs, trailprofile::Sampler* prof = nullptr) { uint16_t cnt = 0; uint32_t accum = 0; if (!persist::readHeader(io, TrailStore::SAVE_MAGIC, TrailStore::SAVE_VERSION, cnt, 1)) return false; @@ -910,6 +910,7 @@ static bool trailSummary(F& io, int& points, float& meters, uint32_t& secs) { if (io.read((uint8_t*)&p, sizeof(p)) != (int)sizeof(p)) break; if (points > 0 && !(p.flags & TRAIL_FLAG_SEG_START)) meters += TrailStore::haversineMeters(prev.lat_1e6, prev.lon_1e6, p.lat_1e6, p.lon_1e6); + if (prof) prof->feed(p); prev = p; points++; } @@ -963,6 +964,9 @@ void UITask::navToolsPopup() { sectionTitle(list, "TRAIL"); _nav_trail_lbl = label(list, "", THEME_FONT_SMALL, theme::TEXT); + profileview::s_prof = trailprofile::Sampler(); // its height profile, as it is now + profileview::s_prof.feedAll(_core->trail.store()); + profileChart(list, 72); lv_obj_t* r = buttonBar(list); _nav_trail_btn = toolButton(r, "", navmap::TL_TRAIL_TOGGLE, true); toolButton(r, LV_SYMBOL_SAVE " Save", navmap::TL_TRAIL_SAVE, false); @@ -1220,13 +1224,15 @@ void UITask::savedTrailPopup(int idx) { lv_obj_t* panel = navPopupPanel(title, false); int pts = 0; float m = 0; uint32_t secs = 0; bool ok = false; + trailprofile::Sampler& prof = profileview::s_prof; + prof = trailprofile::Sampler(); if (idx >= 0) { char path[64]; snprintf(path, sizeof(path), "%s/%s", navmap::TRAILS_DIR, navmap::s_st_names[idx]); - if (FILE* f = fopen(path, "rb")) { navmap::FileRW io{ f }; ok = navmap::trailSummary(io, pts, m, secs); fclose(f); } + if (FILE* f = fopen(path, "rb")) { navmap::FileRW io{ f }; ok = navmap::trailSummary(io, pts, m, secs, &prof); fclose(f); } } else if (DataStore* ds = the_mesh.getDataStore()) { File f = ds->openRead(TrailEngine::TRAIL_FILE); - if (f) { ok = navmap::trailSummary(f, pts, m, secs); f.close(); } + if (f) { ok = navmap::trailSummary(f, pts, m, secs, &prof); f.close(); } } char dist[12], dur[12], info[64]; geo::fmtDist(dist, sizeof(dist), m / 1000.0f, _prefs && _prefs->units_imperial); @@ -1234,6 +1240,7 @@ void UITask::savedTrailPopup(int idx) { if (ok) snprintf(info, sizeof(info), "%s - %s - %d points", dist, dur, pts); else snprintf(info, sizeof(info), "Can't read this file"); label(panel, info, THEME_FONT_BODY, theme::TEXT); + if (ok) profileChart(panel, 60); if (!_core->trail.store().empty()) label(panel, "Loading replaces the trail on the map.", THEME_FONT_SMALL, theme::TEXT_MUTED); lv_obj_t* r = buttonBar(panel); diff --git a/examples/companion_radio/ui-lvgl/NodeViz.h b/examples/companion_radio/ui-lvgl/NodeViz.h new file mode 100644 index 00000000..b1f67054 --- /dev/null +++ b/examples/companion_radio/ui-lvgl/NodeViz.h @@ -0,0 +1,170 @@ +#pragma once +// Node detail's picture card, over its info rows: where the node is, on a +// small compass rose (north up, an arrow towards it), and how a message gets +// there, a chain from you to it with a ring per repeater on the way. Either half shows only when +// there's something to draw (a position of both ends / a contact's path). +// ui-new draws the same on e-ink (NearbyScreen.h). +// +// Single-TU fragment: included by ui-lvgl/UITask.cpp before the node detail. + +namespace nodeviz { + +static const int ROSE_W = 64, ROSE_H = 76; // the ring, N over it +static lv_obj_t* s_card = nullptr; +static lv_obj_t* s_rose = nullptr; +static lv_obj_t* s_path_col = nullptr; +static lv_obj_t* s_chain = nullptr; +static lv_obj_t* s_path_txt = nullptr; +static int s_bearing = -1; // degrees, -1: no position +static int s_hops = -1; // 0..63, 0xFF flood, -1: not a contact + +static void onRoseDraw(lv_event_t* e) { + lv_obj_t* o = (lv_obj_t*)lv_event_get_target(e); + lv_layer_t* layer = lv_event_get_layer(e); + lv_area_t a; + lv_obj_get_coords(o, &a); + const float r = ROSE_W / 2.0f - 8, cx = (a.x1 + a.x2) / 2.0f, cy = a.y2 - r - 3; + + lv_draw_arc_dsc_t ad; // the ring + lv_draw_arc_dsc_init(&ad); + ad.center.x = (int32_t)cx; ad.center.y = (int32_t)cy; + ad.radius = (uint16_t)r; + ad.start_angle = 0; ad.end_angle = 360; + ad.width = 1; + ad.color = lv_color_hex(theme::TEXT_MUTED); + lv_draw_arc(layer, &ad); + + lv_draw_line_dsc_t ld; // a tick every 45 degrees, longer at the cardinals + lv_draw_line_dsc_init(&ld); + ld.color = lv_color_hex(theme::TEXT_MUTED); + for (int deg = 0; deg < 360; deg += 45) { + const float ang = deg * (float)M_PI / 180.0f, sx = sinf(ang), sy = -cosf(ang); + const float len = deg % 90 == 0 ? 6 : 3; + ld.width = deg % 90 == 0 ? 2 : 1; + ld.p1.x = cx + sx * r; ld.p1.y = cy + sy * r; + ld.p2.x = cx + sx * (r - len); ld.p2.y = cy + sy * (r - len); + lv_draw_line(layer, &ld); + } + + lv_draw_label_dsc_t td; // N above the ring + lv_draw_label_dsc_init(&td); + td.align = LV_TEXT_ALIGN_CENTER; + td.font = THEME_FONT_SMALL; + td.color = lv_color_hex(theme::FAIL); + td.text = "N"; + const int32_t h = lv_font_get_line_height(td.font); + lv_area_t la = { (int32_t)cx - 10, (int32_t)(cy - r) - h - 1, (int32_t)cx + 10, (int32_t)(cy - r) - 1 }; + lv_draw_label(layer, &td, &la); + + if (s_bearing < 0) return; + const float ang = s_bearing * (float)M_PI / 180.0f, sx = sinf(ang), sy = -cosf(ang); + const float tip = r - 4, base = tip - 12; + ld.color = lv_color_hex(theme::ACCENT); // the arrow: a shaft from the centre, a head at the ring + ld.width = 3; + ld.round_start = ld.round_end = 1; + ld.p1.x = cx - sx * (r * 0.35f); ld.p1.y = cy - sy * (r * 0.35f); + ld.p2.x = cx + sx * base; ld.p2.y = cy + sy * base; + lv_draw_line(layer, &ld); + lv_draw_triangle_dsc_t tri; + lv_draw_triangle_dsc_init(&tri); + tri.color = lv_color_hex(theme::ACCENT); + tri.opa = LV_OPA_COVER; + tri.p[0].x = cx + sx * tip; tri.p[0].y = cy + sy * tip; + tri.p[1].x = cx + sx * base + sy * 7; tri.p[1].y = cy + sy * base - sx * 7; + tri.p[2].x = cx + sx * base - sy * 7; tri.p[2].y = cy + sy * base + sx * 7; + lv_draw_triangle(layer, &tri); +} + +// You and the node filled in the accent, the repeaters between as rings, +// dotted links; as many rings as fit, the count says the rest. +static void onChainDraw(lv_event_t* e) { + if (s_hops < 0 || s_hops == 0xFF) return; + lv_obj_t* o = (lv_obj_t*)lv_event_get_target(e); + lv_layer_t* layer = lv_event_get_layer(e); + lv_area_t a; + lv_obj_get_coords(o, &a); + const int R = 6, PITCH = 26, w = a.x2 - a.x1 + 1, cy = (a.y1 + a.y2) / 2; + const int cap = (w - 2 * R) / PITCH - 1; + const int shown = s_hops > cap ? (cap < 1 ? 1 : cap) : s_hops, n = shown + 2; + lv_draw_line_dsc_t ld; + lv_draw_line_dsc_init(&ld); + ld.color = lv_color_hex(theme::TEXT_MUTED); + ld.width = 2; + ld.dash_width = 3; ld.dash_gap = 3; + ld.p1.x = a.x1 + R; ld.p1.y = cy; + ld.p2.x = a.x1 + R + (n - 1) * PITCH; ld.p2.y = cy; + lv_draw_line(layer, &ld); + for (int k = 0; k < n; k++) { + const bool end = k == 0 || k == n - 1; + lv_draw_rect_dsc_t rd; + lv_draw_rect_dsc_init(&rd); + rd.radius = LV_RADIUS_CIRCLE; + rd.bg_color = lv_color_hex(end ? theme::ACCENT : theme::SURFACE); + rd.border_color = lv_color_hex(end ? theme::ACCENT : theme::TEXT_MUTED); + rd.border_width = 2; + const int x = a.x1 + R + k * PITCH; + lv_area_t ra = { x - R, cy - R, x + R, cy + R }; + lv_draw_rect(layer, &rd, &ra); + } +} + +} // namespace nodeviz + +// The card, over the node's info rows. Filled by nodeVizSet(). +static void nodeVizBuild(lv_obj_t* parent) { + using namespace nodeviz; + s_card = lv_obj_create(parent); + styleSurface(s_card, theme::SURFACE); + lv_obj_remove_flag(s_card, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(s_card, LV_OBJ_FLAG_CLICKABLE); + fillWidth(s_card); + lv_obj_set_height(s_card, LV_SIZE_CONTENT); + lv_obj_set_style_radius(s_card, theme::RADIUS, 0); + lv_obj_set_style_pad_all(s_card, 6, 0); + lv_obj_set_flex_flow(s_card, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(s_card, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_column(s_card, 12, 0); + + s_rose = lv_obj_create(s_card); + lv_obj_remove_style_all(s_rose); + lv_obj_remove_flag(s_rose, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(s_rose, ROSE_W, ROSE_H); + lv_obj_add_event_cb(s_rose, onRoseDraw, LV_EVENT_DRAW_MAIN_END, NULL); + + s_path_col = lv_obj_create(s_card); + lv_obj_remove_style_all(s_path_col); + lv_obj_remove_flag(s_path_col, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_width(s_path_col, 1); + lv_obj_set_flex_grow(s_path_col, 1); + lv_obj_set_height(s_path_col, LV_SIZE_CONTENT); + lv_obj_set_flex_flow(s_path_col, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(s_path_col, 4, 0); + label(s_path_col, "Path", THEME_FONT_SMALL, theme::TEXT_MUTED); + s_chain = lv_obj_create(s_path_col); + lv_obj_remove_style_all(s_chain); + lv_obj_remove_flag(s_chain, LV_OBJ_FLAG_CLICKABLE); + fillWidth(s_chain); + lv_obj_set_height(s_chain, 16); + lv_obj_add_event_cb(s_chain, onChainDraw, LV_EVENT_DRAW_MAIN_END, NULL); + s_path_txt = label(s_path_col, "", THEME_FONT_BODY, theme::TEXT); +} + +// `bearing` in degrees or -1; `hops` 0..63, 0xFF (flood) or -1 (no contact). +static void nodeVizSet(int bearing, int hops) { + using namespace nodeviz; + if (!s_card) return; + if (bearing != s_bearing) { s_bearing = bearing; lv_obj_invalidate(s_rose); } + if (hops != s_hops) { + s_hops = hops; + lv_obj_invalidate(s_chain); + char t[24]; + if (hops == 0xFF) snprintf(t, sizeof(t), "Flood, no route yet"); + else if (hops == 0) snprintf(t, sizeof(t), "Direct"); + else snprintf(t, sizeof(t), "%d hop%s", hops, hops == 1 ? "" : "s"); + setText(s_path_txt, t); + if (hops == 0xFF) lv_obj_add_flag(s_chain, LV_OBJ_FLAG_HIDDEN); else lv_obj_remove_flag(s_chain, LV_OBJ_FLAG_HIDDEN); + } + if (bearing < 0) lv_obj_add_flag(s_rose, LV_OBJ_FLAG_HIDDEN); else lv_obj_remove_flag(s_rose, LV_OBJ_FLAG_HIDDEN); + if (hops < 0) lv_obj_add_flag(s_path_col, LV_OBJ_FLAG_HIDDEN); else lv_obj_remove_flag(s_path_col, LV_OBJ_FLAG_HIDDEN); + if (bearing < 0 && hops < 0) lv_obj_add_flag(s_card, LV_OBJ_FLAG_HIDDEN); else lv_obj_remove_flag(s_card, LV_OBJ_FLAG_HIDDEN); +} diff --git a/examples/companion_radio/ui-lvgl/ProfileView.h b/examples/companion_radio/ui-lvgl/ProfileView.h new file mode 100644 index 00000000..50f3c310 --- /dev/null +++ b/examples/companion_radio/ui-lvgl/ProfileView.h @@ -0,0 +1,70 @@ +#pragma once +// A trail's height profile (ui-core/TrailProfile.h) as a chart: the height +// over the distance, a line in the accent over a faint fill, the climb and +// the highest and lowest points over it, top left. In the live trail's Map tools section and +// a saved trail's popup. ui-new draws the same on e-ink and the OLED +// (TrailScreen.h). +// +// Single-TU fragment: included by ui-lvgl/UITask.cpp before NavMap.h. + +namespace profileview { + +static trailprofile::Sampler s_prof; // what the chart on screen shows + +static void onDraw(lv_event_t* e) { + const trailprofile::Sampler& p = s_prof; + if (p.n < 2) return; + lv_obj_t* o = (lv_obj_t*)lv_event_get_target(e); + lv_layer_t* layer = lv_event_get_layer(e); + lv_area_t a; + lv_obj_get_coords(o, &a); + int lo = p.st.lo, hi = p.st.hi; + if (hi - lo < 20) { const int mid = (hi + lo) / 2; lo = mid - 10; hi = mid + 10; } + const float total = p.total > 0 ? p.total : 1.0f; + const int x0 = a.x1 + 2, w = a.x2 - a.x1 - 4, y0 = a.y1 + 20, h = a.y2 - y0 - 2, base = a.y2 - 1; // under the caption + auto X = [&](int i) { return (int32_t)(x0 + p.dist[i] / total * w); }; + auto Y = [&](int i) { return (int32_t)(y0 + h - (p.alt[i] - lo) * h / (hi - lo)); }; + + lv_draw_line_dsc_t fill; // under the line, a column at a time (triangles would leave seams) + lv_draw_line_dsc_init(&fill); + fill.color = lv_color_hex(theme::ACCENT); + fill.opa = LV_OPA_20; + fill.width = 1; + lv_draw_line_dsc_t ld; + lv_draw_line_dsc_init(&ld); + ld.color = lv_color_hex(theme::ACCENT); + ld.width = 2; + ld.round_start = ld.round_end = 1; + for (int i = 1; i < p.n; i++) { + const int32_t xa = X(i - 1), xb = X(i), ya = Y(i - 1), yb = Y(i); + for (int32_t x = (i == 1 ? xa : xa + 1); x <= xb; x++) { + fill.p1.x = fill.p2.x = x; + fill.p1.y = xb > xa ? ya + (yb - ya) * (x - xa) / (xb - xa) : yb; + fill.p2.y = base; + lv_draw_line(layer, &fill); + } + ld.p1.x = xa; ld.p1.y = ya; ld.p2.x = xb; ld.p2.y = yb; + lv_draw_line(layer, &ld); + } +} + +} // namespace profileview + +// The chart, `h` tall, into `parent`, for what s_prof holds; nothing when +// the trail has no heights. Returns the chart, or null. +static lv_obj_t* profileChart(lv_obj_t* parent, int h) { + using namespace profileview; + if (!s_prof.st.any || s_prof.n < 2) return nullptr; + lv_obj_t* c = lv_obj_create(parent); + styleSurface(c, theme::SURFACE); + lv_obj_remove_flag(c, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(c, LV_OBJ_FLAG_CLICKABLE); + fillWidth(c); + lv_obj_set_height(c, h); + lv_obj_set_style_radius(c, theme::RADIUS_SM, 0); + lv_obj_add_event_cb(c, onDraw, LV_EVENT_DRAW_MAIN_END, NULL); + char t[64]; + snprintf(t, sizeof(t), "Climb %d m - highest %d m - lowest %d m", s_prof.st.gain, s_prof.st.hi, s_prof.st.lo); + lv_obj_align(label(c, t, THEME_FONT_SMALL, theme::TEXT_MUTED), LV_ALIGN_TOP_LEFT, 6, 2); + return c; +} diff --git a/examples/companion_radio/ui-lvgl/UITask.cpp b/examples/companion_radio/ui-lvgl/UITask.cpp index 29065e61..8eac0b12 100644 --- a/examples/companion_radio/ui-lvgl/UITask.cpp +++ b/examples/companion_radio/ui-lvgl/UITask.cpp @@ -74,6 +74,9 @@ static bool s_chats_stale = false; // Messages: a refresh waits for a held row #include "../ui-core/SettingsSchema.h" #include "../ui-core/GpsAverager.h" #include "../ui-core/TrackBack.h" +#include "../ui-core/TrailProfile.h" +#include "../ui-core/StatusHistory.h" +#include "../ui-core/CalendarMath.h" #include "../ui-core/RadioControl.h" #include "../ui-core/RepeaterControl.h" #include "../ui-core/Diagnostics.h" @@ -1620,6 +1623,7 @@ extern "C" lv_result_t __wrap_lv_obj_send_event(lv_obj_t* o, lv_event_code_t c, void UITask::loop() { pollConnection(); drainCoreEvents(); + sampleHistory(); // USER (BOOT, side) button: Home's clock page; held and let go, mutes / unmutes, also with the // screen off, which it doesn't wake. WAKE (top) button: screen off / on. @@ -2607,6 +2611,8 @@ int UITask::fillPending() const { return n; } +#include "NodeViz.h" // the node detail's rose and path (newScreen() drops its card) + lv_obj_t* UITask::newScreen(const char* title, bool with_back) { s_fill_n = 0; // what was still to be built goes with the screen _home_clock = _home_date = _home_unread = nullptr; @@ -2617,6 +2623,7 @@ lv_obj_t* UITask::newScreen(const char* title, bool with_back) { _header = _body = nullptr; _nearby_list = _nearby_status = _nearby_sort_lbl = _nearby_chips = nullptr; _node_info = _node_ping = _node_delete_lbl = nullptr; + nodeviz::s_card = nullptr; _scan_overlay = _scan_list = _scan_status = nullptr; // the popup went with the old screen _map_area = _map_marks = _map_me = _map_zoom_lbl = _map_hint = _map_dl_pill = _map_center_btn = nullptr; _dl_overlay = _dl_info = _dl_sub = _dl_start_lbl = _dl_job_row = _dl_job_lbl = nullptr; @@ -3602,6 +3609,27 @@ static void nearbyText(const NearbyModel::Entry& e, uint32_t now, bool imperial, t.right_col = e.is_live ? theme::OK : theme::TEXT_MUTED; } +// A small arrow towards the node, drawn in the value label's left padding: +// its user data is the bearing + 1 (0: none). +static const int ROW_ARROW_W = 14; +static void onRowArrowDraw(lv_event_t* e) { + lv_obj_t* o = (lv_obj_t*)lv_event_get_target(e); + const int b = (int)(intptr_t)lv_obj_get_user_data(o) - 1; + if (b < 0) return; + lv_area_t a; + lv_obj_get_coords(o, &a); + const float cx = a.x1 + ROW_ARROW_W / 2 - 1, cy = (a.y1 + a.y2) / 2.0f; + const float ang = b * (float)M_PI / 180.0f, sx = sinf(ang), sy = -cosf(ang); + lv_draw_triangle_dsc_t tri; + lv_draw_triangle_dsc_init(&tri); + tri.color = lv_obj_get_style_text_color(o, LV_PART_MAIN); + tri.opa = LV_OPA_COVER; + tri.p[0].x = cx + sx * 6; tri.p[0].y = cy + sy * 6; // the tip + tri.p[1].x = cx - sx * 5 + sy * 4; tri.p[1].y = cy - sy * 5 - sx * 4; + tri.p[2].x = cx - sx * 5 - sy * 4; tri.p[2].y = cy - sy * 5 + sx * 4; + lv_draw_triangle(lv_event_get_layer(e), &tri); +} + void UITask::refreshNearbyList() { if (!_nearby_list) return; _nearby->refreshModel(); @@ -3621,13 +3649,27 @@ void UITask::refreshNearbyList() { // order); the list stays where it was scrolled. static const vlist::Kind ROW = { theme::ROW_H, - [](lv_obj_t* list) { return vRowValue(list, onNearbyRow); }, + [](lv_obj_t* list) { + lv_obj_t* row = vRowValue(list, onNearbyRow); + lv_obj_add_event_cb(lv_obj_get_child(row, 2), onRowArrowDraw, LV_EVENT_DRAW_MAIN_END, NULL); + return row; + }, [](lv_obj_t* row, intptr_t i) { const NearbyModel::Entry& e = s_ui->_nearby->at((int)i); NearbyText t; nearbyText(e, rtc_clock.getCurrentTime(), s_ui->_prefs && s_ui->_prefs->units_imperial, t); rowSet(row, t.title, t.sub, e.fav ? theme::ACCENT : theme::TEXT); rowValue(row, t.right, t.right_col); + // By the distance, an arrow towards it + int b = -1; + int32_t lat, lon; + if (e.dist_km >= 0.0f && s_ui->_nearby->ownPosition(lat, lon)) b = geo::bearingDeg(lat, lon, e.lat_e6, e.lon_e6); + lv_obj_t* v = lv_obj_get_child(row, 2); + if ((intptr_t)lv_obj_get_user_data(v) != b + 1) { + lv_obj_set_user_data(v, (void*)(intptr_t)(b + 1)); + lv_obj_set_style_pad_left(v, b >= 0 ? ROW_ARROW_W : 0, 0); + lv_obj_invalidate(v); + } }, }; vlist::begin(_nearby_list); @@ -3653,11 +3695,19 @@ void UITask::openNode(int row) { buildNode(); } +static lv_obj_t* s_nd_fav = nullptr; +static void nodeFavText() { + if (s_nd_fav) lv_label_set_text(s_nd_fav, s_node.fav ? UI_SYMBOL_STAR " Remove from favourites" : UI_SYMBOL_STAR " Add to favourites"); +} + void UITask::buildNode() { const NearbyModel::Entry& e = s_node; lv_obj_t* body = newScreen(e.name[0] ? e.name : "(unknown)", true); lv_obj_t* info = scrollList(body); // the info, scrolling above the actions at the bottom + s_nd_fav = nullptr; + nodeVizBuild(info); + nodeviz::s_bearing = nodeviz::s_hops = -2; // nothing drawn yet: the first set fills both _node_info = infoCard(info); s_nd_type = infoRow(_node_info, "Type", ""); s_nd_status = infoRow(_node_info, "Status", ""); @@ -3668,26 +3718,26 @@ void UITask::buildNode() { s_nd_id = infoRow(_node_info, "ID", ""); _node_ping = label(info, "", THEME_FONT_BODY, theme::ACCENT); + // The bar: what the node is for -- a message, its admin, adding it -- a + // ping and the way there. The rest, less often, in a group under the info. lv_obj_t* acts = buttonBar(body); bool contact = e.contact_idx >= 0; bool admin = contact && (e.type == ADV_TYPE_REPEATER || e.type == ADV_TYPE_ROOM); bool pos = e.lat_e6 != 0 || e.lon_e6 != 0; - struct Act { const char* icon; const char* text; uint8_t action; bool accent; } list[9]; - int n = 0; - if (contact && e.type == ADV_TYPE_CHAT) list[n++] = { LV_SYMBOL_ENVELOPE, " Message", NODE_MSG, true }; - if (e.has_key) list[n++] = { LV_SYMBOL_LOOP, " Ping", NODE_PING, false }; - if (pos) list[n++] = { UI_SYMBOL_COMPASS, "", NODE_NAV, false }; - if (pos) list[n++] = { UI_SYMBOL_FLAG, "", NODE_WAYPOINT, false }; // save where it was seen - if (contact) list[n++] = { UI_SYMBOL_STAR, admin ? "" : e.fav ? " Unfav" : " Fav", NODE_FAV, e.fav }; // lit while it is one - if (admin) list[n++] = { LV_SYMBOL_SETTINGS, " Admin", NODE_ADMIN, false }; - if (!contact && e.has_key && !e.is_known) list[n++] = { LV_SYMBOL_PLUS, " Add", NODE_ADD, true }; - if (contact && e.has_key) list[n++] = { UI_SYMBOL_TACK, "", NODE_PIN, favslots::findContact(_prefs, e.pub_key) >= 0 }; - if (contact) list[n++] = { LV_SYMBOL_TRASH, "", NODE_DELETE, false }; - for (int i = 0; i < n; i++) { // five or more: icons only, so every button fits one row - char t[24]; - snprintf(t, sizeof(t), "%s%s", list[i].icon, n >= 5 ? "" : list[i].text); - lv_obj_t* b = barButton(acts, t, onNodeAction, list[i].action, list[i].accent); - if (list[i].action == NODE_DELETE) _node_delete_lbl = lv_obj_get_child(b, 0); + if (contact && e.type == ADV_TYPE_CHAT) barButton(acts, LV_SYMBOL_ENVELOPE " Message", onNodeAction, NODE_MSG, true); + if (admin) barButton(acts, LV_SYMBOL_SETTINGS " Admin", onNodeAction, NODE_ADMIN, true); + if (!contact && e.has_key && !e.is_known) barButton(acts, LV_SYMBOL_PLUS " Add", onNodeAction, NODE_ADD, true); + if (e.has_key) barButton(acts, LV_SYMBOL_LOOP " Ping", onNodeAction, NODE_PING); + if (pos) barButton(acts, UI_SYMBOL_COMPASS " Navigate", onNodeAction, NODE_NAV); + + if (pos || contact) { + lv_obj_t* g = group(info, "More"); + if (pos) actionRow(g, UI_SYMBOL_FLAG " Save as waypoint", onNodeAction, (void*)(uintptr_t)NODE_WAYPOINT); + if (contact) s_nd_fav = actionRow(g, "", onNodeAction, (void*)(uintptr_t)NODE_FAV); + nodeFavText(); + if (contact && e.has_key) actionRow(g, UI_SYMBOL_TACK " Pin to Home", onNodeAction, (void*)(uintptr_t)NODE_PIN); + if (contact) _node_delete_lbl = actionRow(g, LV_SYMBOL_TRASH " Delete contact", onNodeAction, + (void*)(uintptr_t)NODE_DELETE, theme::FAIL); } refreshNode(); @@ -3717,11 +3767,13 @@ void UITask::refreshNode() { if (e.fav) tag("favourite"); if (status[0] >= 'a' && status[0] <= 'z') status[0] -= 'a' - 'A'; int32_t lat, lon; + int bearing = -1; if (e.lat_e6 != 0 || e.lon_e6 != 0) { if (_nearby->ownPosition(lat, lon) && e.dist_km >= 0.0f) { char d[16]; geo::fmtDist(d, sizeof(d), e.dist_km, _prefs && _prefs->units_imperial); int az = geo::bearingDeg(lat, lon, e.lat_e6, e.lon_e6); + bearing = az; snprintf(dist, sizeof(dist), "%s %d\xC2\xB0 %s", d, az, geo::bearingCardinal(az)); } snprintf(pos, sizeof(pos), "%.5f, %.5f", e.lat_e6 / 1e6, e.lon_e6 / 1e6); @@ -3741,6 +3793,11 @@ void UITask::refreshNode() { infoSet(s_nd_heard, heard); infoSet(s_nd_signal, sig); infoSet(s_nd_id, id); + ContactInfo ci; + int hops = -1; + if (!_node_from_scan && e.contact_idx >= 0 && the_mesh.getContactByIdx(e.contact_idx, ci)) + hops = ci.out_path_len == 0xFF ? 0xFF : (ci.out_path_len & 63); + nodeVizSet(bearing, hops); // Ping result (PingEngine releases its slot on reply; the view owns the timeout) if (_pinging) { @@ -3779,9 +3836,10 @@ void UITask::nodeAction(uint8_t action) { break; } case NODE_FAV: - if (contactctl::setFavourite(e.pub_key, !e.fav)) { + if (contactctl::setFavourite(e.pub_key, !e.fav)) { // in place: the list stays where it was scrolled e.fav = !e.fav; - buildNode(); + nodeFavText(); + refreshNode(); } break; case NODE_ADD: @@ -3819,10 +3877,7 @@ void UITask::nodeAction(uint8_t action) { pinPopup(false, 0, e.pub_key); break; case NODE_DELETE: - if (!tapConfirmed(_node_delete_lbl, LV_SYMBOL_TRASH "?")) { // "?" fits an icon-only button - showToast("Tap again to delete the contact", 2500); - break; - } + if (!tapConfirmed(_node_delete_lbl, LV_SYMBOL_TRASH " Tap again to delete")) break; if (the_mesh.deleteContactByKey(e.pub_key)) { _screen = SCR_NEARBY; buildNearby(); @@ -3833,6 +3888,7 @@ void UITask::nodeAction(uint8_t action) { static lv_obj_t* switchRow(lv_obj_t* parent, const char* text, const char* sub, uint8_t* pref); // below #include "MapScreen.h" +#include "ProfileView.h" #include "NavMap.h" #include "MapAreas.h" #include "MapRegions.h" @@ -4924,6 +4980,8 @@ extern "C" EMSCRIPTEN_KEEPALIVE void sim_open(const char* name) { if (!strcmp(name, "regions")) { s_ui->mapRegionsPopup(); return; } if (!strcmp(name, "region0")) { s_ui->mapRegionPopup(0); return; } if (!strcmp(name, "advert")) { s_ui->advertPopup(); return; } + if (!strcmp(name, "trailsave")) { s_ui->navToolAction(navmap::TL_TRAIL_SAVE); return; } // Map tools > Save + if (!strcmp(name, "savedtrail0")) { navmap::scanTrails(); s_ui->savedTrailPopup(0); return; } if (!strncmp(name, "trail@", 6)) { // "trail@name.trl" from /sdcard/trails: Load, as the saved-trail popup does navmap::scanTrails(); for (int i = 0; i < navmap::s_st_n; i++) diff --git a/examples/companion_radio/ui-lvgl/UITask.h b/examples/companion_radio/ui-lvgl/UITask.h index 32f7bd97..55129832 100644 --- a/examples/companion_radio/ui-lvgl/UITask.h +++ b/examples/companion_radio/ui-lvgl/UITask.h @@ -432,6 +432,8 @@ private: void buildDiag(); void diagSection(int sec); void refreshDiag(); + void sampleHistory(); // DiagScreen.h: History's readings, from loop() + void fillHistory(); void buildCompass(); void refreshCompass(); void favPickPopup(int slot);