From 02c2ba131383d5d3fc7d50b5bb03a0df13828367 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sun, 27 Sep 2026 00:34:34 +0200 Subject: [PATCH] feat(ui-lvgl): readable info cards for diagnostics, GPS, node detail and more Shared infoCard/infoRow/infoNote helpers: muted label left, value right, hairlines between rows, empty rows hidden. Diagnostics grouped into sections with a received/sent packet table; GPS gets a position card and constellation pills; About, OTA, Storage and map popups use the same cards. Text no longer breaks after '.', clock alarm hint unclipped. Sim-only sim_open() hook for jumping to a screen. Co-Authored-By: Claude Opus 5.5 --- .../companion_radio/ui-lvgl/ClockScreen.h | 10 +- examples/companion_radio/ui-lvgl/DiagScreen.h | 154 ++++++++++++++--- examples/companion_radio/ui-lvgl/GpsScreen.h | 133 +++++++++----- examples/companion_radio/ui-lvgl/NavMap.h | 37 ++-- examples/companion_radio/ui-lvgl/OtaScreen.h | 11 +- .../companion_radio/ui-lvgl/StorageScreen.h | 20 +-- examples/companion_radio/ui-lvgl/UITask.cpp | 162 ++++++++++++++---- examples/companion_radio/ui-lvgl/UITask.h | 1 + examples/companion_radio/ui-lvgl/lv_conf.h | 3 + 9 files changed, 381 insertions(+), 150 deletions(-) diff --git a/examples/companion_radio/ui-lvgl/ClockScreen.h b/examples/companion_radio/ui-lvgl/ClockScreen.h index 9a8e4309..297e11d7 100644 --- a/examples/companion_radio/ui-lvgl/ClockScreen.h +++ b/examples/companion_radio/ui-lvgl/ClockScreen.h @@ -148,10 +148,14 @@ void UITask::buildClock() { lv_obj_set_size(col, 120, LV_SIZE_CONTENT); lv_obj_set_flex_flow(col, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_align(col, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); - lv_obj_set_style_pad_row(col, 6, 0); + lv_obj_set_style_pad_row(col, 4, 0); lv_obj_set_style_pad_left(col, 8, 0); - label(col, "Alarm on", THEME_FONT_SMALL, theme::TEXT_MUTED); - lv_obj_t* sw = s_alarm_sw = lv_switch_create(col); + lv_obj_t* on = flexBox(col, LV_FLEX_FLOW_ROW); // the switch and its label: one line + lv_obj_set_flex_align(on, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_column(on, 8, 0); + lv_obj_set_style_pad_bottom(on, 4, 0); + lv_obj_t* sw = s_alarm_sw = lv_switch_create(on); + label(on, "Alarm on", THEME_FONT_SMALL, theme::TEXT_MUTED); lv_obj_set_size(sw, 50, 26); if (_prefs->alarm_on) lv_obj_add_state(sw, LV_STATE_CHECKED); lv_obj_add_event_cb(sw, onAlarmSwitch, LV_EVENT_VALUE_CHANGED, NULL); diff --git a/examples/companion_radio/ui-lvgl/DiagScreen.h b/examples/companion_radio/ui-lvgl/DiagScreen.h index 5a7a4b6c..88055f97 100644 --- a/examples/companion_radio/ui-lvgl/DiagScreen.h +++ b/examples/companion_radio/ui-lvgl/DiagScreen.h @@ -11,7 +11,10 @@ enum : uint8_t { TAB_LIVE, TAB_SYSTEM, TAB_FONT, 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) -static lv_obj_t* s_vals[diag::MAX_ROWS + EXTRA]; +// A Live row's value labels: `in` / `out` for a packet count ("12/3" from +// ui-core), else `val` alone. +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; static void extraRow(diag::Row* rows, int& n, const char* lbl, const char* fmt, ...) { @@ -44,6 +47,75 @@ static int allRows(diag::Row* rows, bool gps_on) { return n; } +// ui-core's labels are cut for a 128 px OLED; here each gets a section and a +// full name. Packet rows are "received/sent" pairs, shown in two columns. +enum : uint8_t { SEC_DEVICE, SEC_PACKETS, SEC_RADIO, SEC_MEMORY, SEC_COUNT }; +static const char* const SEC_TITLE[SEC_COUNT] = { "DEVICE", "PACKETS", "RADIO", "MEMORY" }; +struct Name { const char* core; uint8_t sec; const char* text; bool pair; }; +static const Name NAMES[] = { + { "Uptime", SEC_DEVICE, "Uptime", false }, { "Last start", SEC_DEVICE, "Last start", false }, + { "Last crash", SEC_DEVICE, "Last crash", false }, { "Power", SEC_DEVICE, "Power", false }, + { "GPS", SEC_DEVICE, "GPS", false }, + { "Total rx/tx", SEC_PACKETS, "All", true }, { "Msg", SEC_PACKETS, "Messages", true }, + { "Advert", SEC_PACKETS, "Adverts", true }, { "Ack/Path", SEC_PACKETS, "Acks & paths", true }, + { "Other", SEC_PACKETS, "Other", true }, { "Forwarded", SEC_PACKETS, "Forwarded", false }, + { "Noise floor", SEC_RADIO, "Noise floor", false }, { "RSSI/SNR", SEC_RADIO, "Last packet", false }, + { "Queue", SEC_RADIO, "Send queue", false }, { "Errors", SEC_RADIO, "Errors", false }, + { "RXPS wd s/h", SEC_RADIO, "RX watchdog soft / hard", false }, + { "Heap free", SEC_MEMORY, "Heap free", false }, { "Stack free", SEC_MEMORY, "Stack free", false }, + { "Pool free", SEC_MEMORY, "Packet pool free", false }, +}; +static const Name* nameOf(const char* core) { + for (const Name& n : NAMES) if (!strcmp(n.core, core)) return &n; + return nullptr; +} + +// A value as shown: "-60/40.0" -> "-60 dBm, SNR 40.0"; "12/456KB" -> "12 of 456 KB". +static void showValue(const char* core, const char* v, char* out, size_t n) { + const char* slash = strchr(v, '/'); + if (!strcmp(core, "RSSI/SNR") && slash) snprintf(out, n, "%.*s dBm, SNR %s", (int)(slash - v), v, slash + 1); + else if (!strcmp(core, "Heap free") && slash) snprintf(out, n, "%.*s of %s", (int)(slash - v), v, slash + 1); + else snprintf(out, n, "%s", v); + size_t l = strlen(out); // "KB" / "B" units get their space + if (l > 2 && !strcmp(out + l - 2, "KB") && out[l - 3] != ' ') snprintf(out + l - 2, n - (l - 2), " KB"); + else if (l > 1 && out[l - 1] == 'B' && out[l - 2] >= '0' && out[l - 2] <= '9') snprintf(out + l - 1, n - (l - 1), " B"); +} + +static void setLive(int i, const diag::Row& r) { + LiveVal& lv = s_vals[i]; + if (lv.in) { + const char* slash = strchr(r.value, '/'); + char a[12]; + snprintf(a, sizeof(a), "%.*s", slash ? (int)(slash - r.value) : (int)strlen(r.value), r.value); + lv_label_set_text(lv.in, a); + lv_label_set_text(lv.out, slash ? slash + 1 : ""); + return; + } + char v[40]; + showValue(r.label, r.value, v, sizeof(v)); + lv_label_set_text(lv.val, v); + if (!strcmp(r.label, "Errors")) lv_obj_set_style_text_color(lv.val, lv_color_hex(strcmp(r.value, "OK") ? theme::FAIL : theme::OK), 0); +} + +// A packets row: the name, then received and sent in fixed columns. +static const int COL_W = 56; +static lv_obj_t* pairLine(lv_obj_t* card, const char* name, lv_obj_t** in, lv_obj_t** out, bool head) { + lv_obj_t* r = infoLine(card); + lv_obj_t* k = label(r, name, THEME_FONT_SMALL, theme::TEXT_MUTED); + lv_obj_align(k, LV_ALIGN_LEFT_MID, 0, 0); + const lv_font_t* f = head ? THEME_FONT_SMALL : THEME_FONT_BODY; + uint32_t col = head ? theme::TEXT_MUTED : theme::TEXT; + *out = label(r, head ? "Sent" : "", f, col); + lv_obj_set_width(*out, COL_W); + lv_obj_set_style_text_align(*out, LV_TEXT_ALIGN_RIGHT, 0); + lv_obj_align(*out, LV_ALIGN_RIGHT_MID, 0, 0); + *in = label(r, head ? "Received" : "", f, col); + lv_obj_set_width(*in, COL_W + 16); + lv_obj_set_style_text_align(*in, LV_TEXT_ALIGN_RIGHT, 0); + lv_obj_align(*in, LV_ALIGN_RIGHT_MID, -COL_W, 0); + return r; +} + } // namespace diagview static void onOpenDiag(lv_event_t* e) { (void)e; s_ui->showDiag(); } @@ -75,37 +147,68 @@ void UITask::buildDiag() { 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); - s_list = lv_obj_create(body); - styleSurface(s_list, theme::SURFACE); - lv_obj_set_style_radius(s_list, theme::RADIUS, 0); - lv_obj_set_style_pad_all(s_list, theme::PAD, 0); - lv_obj_set_style_pad_row(s_list, 3, 0); - lv_obj_set_width(s_list, LV_PCT(100)); - lv_obj_set_flex_grow(s_list, 1); - lv_obj_set_flex_flow(s_list, LV_FLEX_FLOW_COLUMN); - lv_obj_set_scrollbar_mode(s_list, LV_SCROLLBAR_MODE_ACTIVE); + s_list = scrollList(body); + lv_obj_set_style_pad_row(s_list, 4, 0); s_rows = 0; if (s_tab == TAB_LIVE) { + // Section by section, in ui-core's order within each. diag::Row rows[diag::MAX_ROWS + EXTRA]; s_rows = allRows(rows, _core->gpsEnabled()); - for (int i = 0; i < s_rows; i++) { - lv_obj_t* r = lv_obj_create(s_list); - lv_obj_remove_style_all(r); - lv_obj_remove_flag(r, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_size(r, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_align(label(r, rows[i].label, THEME_FONT_BODY, theme::TEXT_MUTED), LV_ALIGN_LEFT_MID, 0, 0); - s_vals[i] = label(r, rows[i].value, THEME_FONT_BODY, theme::TEXT); - lv_obj_align(s_vals[i], LV_ALIGN_RIGHT_MID, 0, 0); + for (uint8_t sec = 0; sec < SEC_COUNT; sec++) { + lv_obj_t* card = nullptr; + for (int i = 0; i < s_rows; i++) { + const Name* nm = nameOf(rows[i].label); + if ((nm ? nm->sec : SEC_DEVICE) != sec) continue; + if (!card) { + sectionTitle(s_list, SEC_TITLE[sec]); + card = infoCard(s_list); + if (sec == SEC_PACKETS) { lv_obj_t *a, *b; pairLine(card, "", &a, &b, true); } + } + s_vals[i] = { nullptr, nullptr, nullptr }; + if (nm && nm->pair) pairLine(card, nm->text, &s_vals[i].in, &s_vals[i].out, false); + else s_vals[i].val = infoRow(card, nm ? nm->text : rows[i].label, ""); + setLive(i, rows[i]); + } } return; } - diag::Line lines[diag::MAX_LINES]; - int n = s_tab == TAB_SYSTEM ? diag::systemLines(lines) : diag::fontLines(lines); - for (int i = 0; i < n; i++) { - lv_obj_t* l = label(s_list, lines[i], s_tab == TAB_FONT ? THEME_FONT_TITLE : THEME_FONT_BODY, theme::TEXT); - lv_label_set_long_mode(l, LV_LABEL_LONG_WRAP); - lv_obj_set_width(l, LV_PCT(100)); + + lv_obj_t* card = infoCard(s_list); + if (s_tab == TAB_FONT) { // "Latin ABCabc": the script, then its sample + diag::Line lines[diag::MAX_LINES]; + int n = diag::fontLines(lines); + for (int i = 0; i < n; i++) { + char* sp = strchr(lines[i], ' '); + if (sp) *sp = '\0'; + infoRow(card, lines[i], sp ? sp + 1 : "", theme::TEXT, THEME_FONT_TITLE); + } + return; + } + // System: this build and the radio settings. + char v[48]; + diag::shortVersion(v, sizeof(v)); + infoRow(card, "Firmware", v); + infoRow(card, "Built", FIRMWARE_BUILD_DATE); +#ifdef MESHCORE_VERSION + infoRow(card, "MeshCore", MESHCORE_VERSION); +#endif + infoRow(card, "Device", board.getManufacturerName()); + infoRow(card, "Node name", the_mesh.getNodeName()); + if (NodePrefs* p = the_mesh.getNodePrefs()) { + sectionTitle(s_list, "RADIO"); + card = infoCard(s_list); + snprintf(v, sizeof(v), "%.3f MHz", p->freq); + infoRow(card, "Frequency", v); + snprintf(v, sizeof(v), "%.1f kHz", p->bw); + if (strstr(v, ".0 ")) snprintf(v, sizeof(v), "%.0f kHz", p->bw); + infoRow(card, "Bandwidth", v); + snprintf(v, sizeof(v), "%u", (unsigned)p->sf); + infoRow(card, "Spreading factor", v); + snprintf(v, sizeof(v), "4/%u", (unsigned)p->cr); + infoRow(card, "Coding rate", v); + snprintf(v, sizeof(v), "%d dBm", (int)p->tx_power_dbm); + infoRow(card, "TX power", v); } } @@ -116,8 +219,7 @@ void UITask::refreshDiag() { diag::Row rows[diag::MAX_ROWS + EXTRA]; int n = allRows(rows, _core->gpsEnabled()); if (n != s_rows) { buildDiag(); return; } - for (int i = 0; i < n; i++) - lv_label_set_text(s_vals[i], rows[i].value); // unchanged text isn't redrawn (UITask.cpp) + for (int i = 0; i < n; i++) setLive(i, rows[i]); // unchanged text isn't redrawn (UITask.cpp) } void UITask::diagResetPopup() { diff --git a/examples/companion_radio/ui-lvgl/GpsScreen.h b/examples/companion_radio/ui-lvgl/GpsScreen.h index cb8db698..99a4c228 100644 --- a/examples/companion_radio/ui-lvgl/GpsScreen.h +++ b/examples/companion_radio/ui-lvgl/GpsScreen.h @@ -19,9 +19,14 @@ static lv_obj_t* s_sky = nullptr; // sky plot (drawn) static lv_obj_t* s_bars = nullptr; // signal bars (drawn, as wide as the satellites need) static lv_obj_t* s_status = nullptr; static lv_obj_t* s_used = nullptr; -static lv_obj_t* s_info = nullptr; -static lv_obj_t* s_systems = nullptr; -static lv_obj_t* s_note = nullptr; +// Beside the plot: in view, search time / time to first fix, UTC. +static lv_obj_t *s_inview = nullptr, *s_search = nullptr, *s_ttff = nullptr, *s_utc = nullptr; +// Under the bars: the position card (hidden without a fix), one pill per +// constellation, the USB warning. +static lv_obj_t *s_fix_title = nullptr, *s_fix_card = nullptr; +static lv_obj_t *s_pos = nullptr, *s_alt = nullptr, *s_speed = nullptr, *s_dop = nullptr; +static lv_obj_t* s_sys_pill[GpsSky::SYS_COUNT]; +static lv_obj_t* s_usb = nullptr; static lv_obj_t* s_on_btn = nullptr; static const int SKY = 150; static const int BAR_PITCH = 26, BAR_W = 16, BARS_H = 104; @@ -273,7 +278,7 @@ void UITask::showGps(bool from_settings) { void UITask::buildGps() { using namespace gpsview; lv_obj_t* body = newScreen("GPS", true); - s_sky = s_bars = s_status = s_used = s_info = s_systems = s_note = s_on_btn = nullptr; + s_sky = s_bars = s_status = s_used = s_on_btn = nullptr; // Sky plot left, the fix right. lv_obj_t* top = lv_obj_create(body); @@ -298,12 +303,16 @@ void UITask::buildGps() { lv_obj_set_style_pad_top(col, 4, 0); s_status = label(col, "", THEME_FONT_LARGE, theme::TEXT); s_used = label(col, "", THEME_FONT_BODY, theme::TEXT); - s_info = label(col, "", THEME_FONT_SMALL, theme::TEXT_MUTED); - lv_obj_set_style_text_line_space(s_info, 2, 0); - for (lv_obj_t* l : { s_used, s_info }) { - lv_label_set_long_mode(l, LV_LABEL_LONG_WRAP); - lv_obj_set_width(l, LV_PCT(100)); - } + lv_label_set_long_mode(s_used, LV_LABEL_LONG_WRAP); + lv_obj_set_width(s_used, LV_PCT(100)); + lv_obj_set_style_pad_bottom(s_used, 4, 0); + lv_obj_t* facts = infoCard(col); // no card of its own: rows on the page + lv_obj_set_style_bg_opa(facts, LV_OPA_TRANSP, 0); + lv_obj_set_style_pad_hor(facts, 0, 0); + s_inview = infoRow(facts, "In view", "", theme::TEXT, THEME_FONT_SMALL); + s_search = infoRow(facts, "Searching", "", theme::TEXT, THEME_FONT_SMALL); + s_ttff = infoRow(facts, "First fix", "", theme::TEXT, THEME_FONT_SMALL); + s_utc = infoRow(facts, "UTC", "", theme::TEXT, THEME_FONT_SMALL); s_on_btn = lv_button_create(col); lv_obj_set_height(s_on_btn, 34); lv_obj_set_style_radius(s_on_btn, theme::RADIUS, 0); @@ -313,6 +322,16 @@ void UITask::buildGps() { lv_obj_add_event_cb(s_on_btn, onGpsTurnOn, LV_EVENT_CLICKED, NULL); lv_obj_add_flag(s_on_btn, LV_OBJ_FLAG_HIDDEN); + s_usb = label(body, LV_SYMBOL_WARNING " On USB power: charging disturbs GPS", THEME_FONT_SMALL, theme::ACCENT); + lv_obj_add_flag(s_usb, LV_OBJ_FLAG_HIDDEN); + + s_fix_title = sectionTitle(body, "POSITION"); + s_fix_card = infoCard(body); + s_pos = infoRow(s_fix_card, "Coordinates", ""); + s_alt = infoRow(s_fix_card, "Altitude", ""); + s_speed = infoRow(s_fix_card, "Speed", ""); + s_dop = infoRow(s_fix_card, "Precision (DOP)", ""); + sectionTitle(body, "SIGNAL (dB-Hz)"); lv_obj_t* sc = lv_obj_create(body); // scrolls sideways when the satellites don't fit styleSurface(sc, theme::BG); @@ -326,13 +345,28 @@ void UITask::buildGps() { lv_obj_set_size(s_bars, BAR_PITCH, BARS_H); lv_obj_add_event_cb(s_bars, onGpsBarsDraw, LV_EVENT_DRAW_MAIN_END, NULL); - s_systems = label(body, "", THEME_FONT_SMALL, theme::TEXT_MUTED); - lv_label_set_long_mode(s_systems, LV_LABEL_LONG_WRAP); - lv_obj_set_width(s_systems, LV_PCT(100)); - s_note = label(body, "Solid = used in the fix. Green is a strong signal (30+ dB-Hz); " - "indoors, below 25 a fix is unlikely.", THEME_FONT_SMALL, theme::TEXT_MUTED); - lv_label_set_long_mode(s_note, LV_LABEL_LONG_WRAP); - lv_obj_set_width(s_note, LV_PCT(100)); + // Constellations: used / in view, a pill each (a pill never breaks in two). + lv_obj_t* pills = lv_obj_create(body); + lv_obj_remove_style_all(pills); + lv_obj_set_size(pills, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(pills, LV_FLEX_FLOW_ROW_WRAP); + lv_obj_set_style_pad_column(pills, 6, 0); + lv_obj_set_style_pad_row(pills, 6, 0); + for (uint8_t k = 0; k < GpsSky::SYS_COUNT; k++) { + lv_obj_t* p = label(pills, "", THEME_FONT_SMALL, theme::TEXT); + lv_obj_set_style_bg_color(p, lv_color_hex(theme::SURFACE), 0); + lv_obj_set_style_bg_opa(p, LV_OPA_COVER, 0); + lv_obj_set_style_radius(p, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_pad_hor(p, 10, 0); + lv_obj_set_style_pad_ver(p, 3, 0); + lv_obj_add_flag(p, LV_OBJ_FLAG_HIDDEN); + s_sys_pill[k] = p; + } + + lv_obj_t* note = label(body, "Solid bar = used in the fix. Green is a strong signal (30+ dB-Hz); " + "indoors, below 25 a fix is unlikely.", THEME_FONT_SMALL, theme::TEXT_MUTED); + lv_label_set_long_mode(note, LV_LABEL_LONG_WRAP); + lv_obj_set_width(note, LV_PCT(100)); refreshGps(); } @@ -348,7 +382,9 @@ void UITask::refreshGps() { if (off) lv_obj_remove_flag(s_on_btn, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(s_on_btn, LV_OBJ_FLAG_HIDDEN); - char used[48] = "", info[240] = ""; + char used[48] = "", inview[16] = "", search[16] = "", ttff[16] = "", utc[16] = ""; + char pos[32] = "", alt[16] = "", speed[16] = "", dop[32] = ""; + bool fix = false; if (!g) { lv_label_set_text(s_status, "Not available"); lv_obj_set_style_text_color(s_status, lv_color_hex(theme::TEXT_MUTED), 0); @@ -359,51 +395,58 @@ void UITask::refreshGps() { lv_label_set_text(s_status, "No data"); lv_obj_set_style_text_color(s_status, lv_color_hex(theme::FAIL), 0); #if defined(SEEED_WIO_TRACKER_L2) - snprintf(info, sizeof(info), "Nothing from the receiver\n(%lu bytes so far)", (unsigned long)gps.rxChars()); + snprintf(used, sizeof(used), "Nothing from the receiver (%lu B)", (unsigned long)gps.rxChars()); #else - snprintf(info, sizeof(info), "Nothing from the receiver"); + snprintf(used, sizeof(used), "Nothing from the receiver"); #endif } else { int tracked = 0, used_n = 0; for (int i = 0; i < s_n; i++) { if (s_sats[i].snr >= 0) tracked++; if (s_used_flag[i]) used_n++; } - if (g->hasFix()) { + fix = g->hasFix(); + if (fix) { lv_label_set_text(s_status, g->fix_mode == 2 ? "2D fix" : "3D fix"); - lv_obj_set_style_text_color(s_status, lv_color_hex(0x6FCF6F), 0); + lv_obj_set_style_text_color(s_status, lv_color_hex(theme::OK), 0); } else { lv_label_set_text(s_status, "Searching"); lv_obj_set_style_text_color(s_status, lv_color_hex(theme::ACCENT), 0); } - snprintf(used, sizeof(used), "%d of %d used", g->hasFix() ? (used_n ? used_n : g->sats_used) : 0, s_n); - int o = snprintf(info, sizeof(info), "%d with a signal\n", tracked); + snprintf(used, sizeof(used), "%d of %d used", fix ? (used_n ? used_n : g->sats_used) : 0, s_n); + snprintf(inview, sizeof(inview), "%d heard", tracked); uint32_t since = g->start_ms ? (millis() - g->start_ms) / 1000 : 0; - if (g->ttff_ms) o += snprintf(info + o, sizeof(info) - o, "First fix after %lu s\n", (unsigned long)(g->ttff_ms / 1000)); - else o += snprintf(info + o, sizeof(info) - o, "Searching for %lu:%02lu\n", (unsigned long)(since / 60), (unsigned long)(since % 60)); - if (g->hasFix() && g->hdop > 0) - o += snprintf(info + o, sizeof(info) - o, "DOP H %.1f V %.1f P %.1f\n", g->hdop, g->vdop, g->pdop); + if (g->ttff_ms) snprintf(ttff, sizeof(ttff), "%lu s", (unsigned long)(g->ttff_ms / 1000)); + else snprintf(search, sizeof(search), "%lu:%02lu", (unsigned long)(since / 60), (unsigned long)(since % 60)); + if (g->utc_valid) snprintf(utc, sizeof(utc), "%02u:%02u:%02u", g->utc_h, g->utc_m, g->utc_s); int32_t lat, lon; - if (g->hasFix() && _core->course.currentLocation(lat, lon)) - o += snprintf(info + o, sizeof(info) - o, "%.5f, %.5f\n", lat / 1e6, lon / 1e6); - if (g->hasFix() && g->alt_valid) - o += snprintf(info + o, sizeof(info) - o, "%.0f m - %.1f km/h\n", g->alt_m, g->speed_kmh); - if (g->utc_valid) o += snprintf(info + o, sizeof(info) - o, "UTC %02u:%02u:%02u", g->utc_h, g->utc_m, g->utc_s); - } - if (g && !off && _board->isExternalPowered()) { // measured: on the cable the L76K never decodes indoors - size_t o = strlen(info); - snprintf(info + o, sizeof(info) - o, "%sOn USB power: charging disturbs GPS", o ? "\n" : ""); + if (fix && _core->course.currentLocation(lat, lon)) snprintf(pos, sizeof(pos), "%.5f, %.5f", lat / 1e6, lon / 1e6); + if (fix && g->alt_valid) { + snprintf(alt, sizeof(alt), "%.0f m", g->alt_m); + snprintf(speed, sizeof(speed), "%.1f km/h", g->speed_kmh); + } + if (fix && g->hdop > 0) snprintf(dop, sizeof(dop), "H %.1f V %.1f P %.1f", g->hdop, g->vdop, g->pdop); } lv_label_set_text(s_used, used); - lv_label_set_text(s_info, info); + infoSet(s_inview, inview); + infoSet(s_search, search); + infoSet(s_ttff, ttff); + infoSet(s_utc, utc); + infoSet(s_pos, pos); + infoSet(s_alt, alt); + infoSet(s_speed, speed); + infoSet(s_dop, dop); + if (fix && pos[0]) { lv_obj_remove_flag(s_fix_title, LV_OBJ_FLAG_HIDDEN); lv_obj_remove_flag(s_fix_card, LV_OBJ_FLAG_HIDDEN); } + else { lv_obj_add_flag(s_fix_title, LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(s_fix_card, LV_OBJ_FLAG_HIDDEN); } + // measured: on the cable the L76K never decodes indoors + if (g && !off && _board->isExternalPowered()) lv_obj_remove_flag(s_usb, LV_OBJ_FLAG_HIDDEN); + else lv_obj_add_flag(s_usb, LV_OBJ_FLAG_HIDDEN); // Per constellation: used / in view. - char sys[160] = ""; - int o = 0; for (uint8_t k = 0; k < GpsSky::SYS_COUNT; k++) { - int inview = 0, u = 0; - for (int i = 0; i < s_n; i++) if (s_sats[i].sys == k) { inview++; if (s_used_flag[i]) u++; } - if (inview) o += snprintf(sys + o, sizeof(sys) - o, "%s%c %s %d/%d", o ? " " : "", GpsSky::sysLetter(k), - GpsSky::sysName(k), u, inview); + int n = 0, u = 0; + for (int i = 0; i < s_n; i++) if (s_sats[i].sys == k) { n++; if (s_used_flag[i]) u++; } + if (!n) { lv_obj_add_flag(s_sys_pill[k], LV_OBJ_FLAG_HIDDEN); continue; } + lv_label_set_text_fmt(s_sys_pill[k], "%c %s %d/%d", GpsSky::sysLetter(k), GpsSky::sysName(k), u, n); + lv_obj_remove_flag(s_sys_pill[k], LV_OBJ_FLAG_HIDDEN); } - lv_label_set_text(s_systems, sys); int32_t w = s_n * BAR_PITCH; if (w < BAR_PITCH) w = BAR_PITCH; diff --git a/examples/companion_radio/ui-lvgl/NavMap.h b/examples/companion_radio/ui-lvgl/NavMap.h index abdbab8a..1f56c18e 100644 --- a/examples/companion_radio/ui-lvgl/NavMap.h +++ b/examples/companion_radio/ui-lvgl/NavMap.h @@ -594,21 +594,27 @@ void UITask::navTargetsPopup() { if (live == 0) label(list, "Nobody is sharing their position.", THEME_FONT_SMALL, theme::TEXT_MUTED); } +// A place's coordinates and, with a fix, how far and which way. +void UITask::placeCard(lv_obj_t* parent, int32_t lat, int32_t lon) { + lv_obj_t* card = infoCard(parent); + char t[40]; + snprintf(t, sizeof(t), "%.5f, %.5f", lat / 1e6, lon / 1e6); + infoRow(card, "Coordinates", t); + int32_t mlat, mlon; + if (!_core->course.currentLocation(mlat, mlon)) return; + char d[12]; + geo::fmtDist(d, sizeof(d), geo::haversineKm(mlat, mlon, lat, lon), _prefs && _prefs->units_imperial); + int az = geo::bearingDeg(mlat, mlon, lat, lon); + snprintf(t, sizeof(t), "%s %d\xC2\xB0 %s", d, az, geo::bearingCardinal(az)); + infoRow(card, "Distance", t); +} + void UITask::navWaypointMenu(int idx) { if (idx < 0 || idx >= _core->waypoints.count()) return; _nav_wp = idx; const Waypoint& w = _core->waypoints.at(idx); lv_obj_t* panel = navPopupPanel(w.label[0] ? w.label : "(unnamed)", false); - char info[80]; - int o = snprintf(info, sizeof(info), "%.5f, %.5f", w.lat_1e6 / 1e6, w.lon_1e6 / 1e6); - int32_t lat, lon; - if (_core->course.currentLocation(lat, lon)) { - char d[12]; - geo::fmtDist(d, sizeof(d), geo::haversineKm(lat, lon, w.lat_1e6, w.lon_1e6), _prefs && _prefs->units_imperial); - int az = geo::bearingDeg(lat, lon, w.lat_1e6, w.lon_1e6); - snprintf(info + o, sizeof(info) - o, "\n%s %d\xC2\xB0 %s", d, az, geo::bearingCardinal(az)); - } - label(panel, info, THEME_FONT_BODY, theme::TEXT); + placeCard(panel, w.lat_1e6, w.lon_1e6); lv_obj_t* acts = lv_obj_create(panel); styleSurface(acts, theme::BG); @@ -1387,16 +1393,7 @@ void UITask::navSpotPopup(int32_t lat, int32_t lon) { _nav_spot_lat = lat; _nav_spot_lon = lon; lv_obj_t* panel = navPopupPanel("This spot", false); - char info[64]; - int o = snprintf(info, sizeof(info), "%.5f, %.5f", lat / 1e6, lon / 1e6); - int32_t mlat, mlon; - if (_core->course.currentLocation(mlat, mlon)) { - char d[12]; - geo::fmtDist(d, sizeof(d), geo::haversineKm(mlat, mlon, lat, lon), _prefs && _prefs->units_imperial); - int az = geo::bearingDeg(mlat, mlon, lat, lon); - snprintf(info + o, sizeof(info) - o, "\n%s %d\xC2\xB0 %s", d, az, geo::bearingCardinal(az)); - } - label(panel, info, THEME_FONT_BODY, theme::TEXT); + placeCard(panel, lat, lon); lv_obj_t* r = toolRow(panel); toolButton(r, UI_SYMBOL_FLAG " Add waypoint", navmap::TL_SPOT_ADD, true); toolButton(r, UI_SYMBOL_COMPASS " Go here", navmap::TL_SPOT_GO, false); diff --git a/examples/companion_radio/ui-lvgl/OtaScreen.h b/examples/companion_radio/ui-lvgl/OtaScreen.h index ce5a5b74..13f0caa7 100644 --- a/examples/companion_radio/ui-lvgl/OtaScreen.h +++ b/examples/companion_radio/ui-lvgl/OtaScreen.h @@ -166,6 +166,8 @@ static bool startTask() { } // namespace ota +static lv_obj_t* s_ota_latest = nullptr; // the release found, once checked + static void onOpenOta(lv_event_t* e) { (void)e; s_ui->showOta(); } static void onOtaButton(lv_event_t* e) { (void)e; s_ui->otaAction(); } @@ -181,10 +183,10 @@ bool UITask::otaBusy() const { return ota::s_state == ota::CHECKING || ota::s_st void UITask::buildOta() { lv_obj_t* body = newScreen("Firmware update", true); - char t[96]; - snprintf(t, sizeof(t), "Installed: %s", FIRMWARE_VERSION); - label(body, t, THEME_FONT_BODY, theme::TEXT); - _ota_status = label(body, "", THEME_FONT_SMALL, theme::TEXT_MUTED); + lv_obj_t* card = infoCard(body); + infoRow(card, "Installed", FIRMWARE_VERSION); + s_ota_latest = infoRow(card, "Latest release", ""); + _ota_status = label(body, "", THEME_FONT_BODY, theme::TEXT); lv_label_set_long_mode(_ota_status, LV_LABEL_LONG_WRAP); lv_obj_set_width(_ota_status, LV_PCT(100)); _ota_bar = lv_bar_create(body); @@ -258,6 +260,7 @@ void UITask::refreshOta() { break; } lv_label_set_text(_ota_status, st); + if (s_ota_latest) infoSet(s_ota_latest, s_latest); lv_label_set_text(_ota_btn_lbl, btn); if (bar) lv_obj_remove_flag(_ota_bar, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(_ota_bar, LV_OBJ_FLAG_HIDDEN); if (btn_on) lv_obj_remove_flag(_ota_btn, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(_ota_btn, LV_OBJ_FLAG_HIDDEN); diff --git a/examples/companion_radio/ui-lvgl/StorageScreen.h b/examples/companion_radio/ui-lvgl/StorageScreen.h index 782d6807..cf57bd29 100644 --- a/examples/companion_radio/ui-lvgl/StorageScreen.h +++ b/examples/companion_radio/ui-lvgl/StorageScreen.h @@ -163,19 +163,6 @@ static void showUsage(lv_obj_t* bar, lv_obj_t* lbl, uint64_t total, uint64_t use lv_label_set_text(lbl, line); } -// Name left, size right. -static lv_obj_t* sizeRow(lv_obj_t* parent, const char* name) { - lv_obj_t* row = lv_obj_create(parent); - styleSurface(row, theme::SURFACE); - lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_size(row, LV_PCT(100), 34); - lv_obj_set_style_radius(row, theme::RADIUS, 0); - lv_obj_align(label(row, name, THEME_FONT_BODY, theme::TEXT), LV_ALIGN_LEFT_MID, theme::PAD, 0); - lv_obj_t* v = label(row, "...", THEME_FONT_BODY, theme::ACCENT); - lv_obj_align(v, LV_ALIGN_RIGHT_MID, -theme::PAD, 0); - return v; -} - static void refreshSizes() { for (int c = 0; c < C_COUNT; c++) { if (!s_cat_val[c]) continue; @@ -185,7 +172,7 @@ static void refreshSizes() { } if (s_status) { char t[48]; - snprintf(t, sizeof(t), s_walking ? "Counting files... %lu" : "%lu files", (unsigned long)s_files); + snprintf(t, sizeof(t), s_walking ? "counting... %lu" : "%lu", (unsigned long)s_files); lv_label_set_text(s_status, t); } } @@ -220,8 +207,9 @@ void UITask::buildStorage() { } else { s_sd_bar = usageBar(body, &s_sd_lbl); lv_label_set_text(s_sd_lbl, "Reading the card..."); - for (int c = 0; c < C_COUNT; c++) s_cat_val[c] = sizeRow(body, CAT_NAME[c]); - s_status = label(body, "", THEME_FONT_SMALL, theme::TEXT_MUTED); + lv_obj_t* cats = infoCard(body); // what takes the space + for (int c = 0; c < C_COUNT; c++) s_cat_val[c] = infoRow(cats, CAT_NAME[c], "...", theme::ACCENT); + s_status = infoRow(cats, "Files", ""); s_sd_read = false; // read in pollStorage(): the first read of a big card takes a moment startWalk(); } diff --git a/examples/companion_radio/ui-lvgl/UITask.cpp b/examples/companion_radio/ui-lvgl/UITask.cpp index ba1135c5..9aa7c416 100644 --- a/examples/companion_radio/ui-lvgl/UITask.cpp +++ b/examples/companion_radio/ui-lvgl/UITask.cpp @@ -168,6 +168,76 @@ static lv_obj_t* settingRow(lv_obj_t* parent, const char* text, const char* hint return row; } +// ── Info cards ── +// Label / value rows on a card (Diagnostics, GPS, node detail, About): the +// label small and muted on the left, the value on the right -- wrapping, +// right-aligned, when it's long -- and a hairline between rows. infoRow() +// returns the value label to update in place; infoSet() also hides its row +// while there's nothing to show. +static lv_obj_t* infoCard(lv_obj_t* parent) { + 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); + lv_obj_set_size(c, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_radius(c, theme::RADIUS, 0); + lv_obj_set_style_pad_hor(c, theme::PAD, 0); + lv_obj_set_flex_flow(c, LV_FLEX_FLOW_COLUMN); + return c; +} + +static lv_obj_t* infoLine(lv_obj_t* card) { // one row's box, the hairline above all but the first + lv_obj_t* r = lv_obj_create(card); + lv_obj_remove_style_all(r); + lv_obj_remove_flag(r, LV_OBJ_FLAG_CLICKABLE); + lv_obj_remove_flag(r, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(r, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_pad_ver(r, 5, 0); + if (lv_obj_get_index(r) > 0) { + lv_obj_set_style_border_side(r, LV_BORDER_SIDE_TOP, 0); + lv_obj_set_style_border_width(r, 1, 0); + lv_obj_set_style_border_color(r, lv_color_hex(theme::SURFACE_2), 0); + } + return r; +} + +static lv_obj_t* infoRow(lv_obj_t* card, const char* key, const char* value, uint32_t col = theme::TEXT, + const lv_font_t* font = THEME_FONT_BODY) { + lv_obj_t* r = infoLine(card); + lv_obj_set_flex_flow(r, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(r, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_column(r, 8, 0); + lv_obj_t* k = label(r, key, THEME_FONT_SMALL, theme::TEXT_MUTED); + lv_label_set_long_mode(k, LV_LABEL_LONG_WRAP); + lv_obj_set_width(k, 1); + lv_obj_set_flex_grow(k, 1); + lv_obj_t* v = label(r, value, font, col); + lv_label_set_long_mode(v, LV_LABEL_LONG_WRAP); + lv_obj_set_width(v, LV_SIZE_CONTENT); + lv_obj_set_style_max_width(v, LV_PCT(66), 0); + lv_obj_set_style_text_align(v, LV_TEXT_ALIGN_RIGHT, 0); + return v; +} + +// A row whose text is a sentence (credits, notes): the label over it. +static lv_obj_t* infoNote(lv_obj_t* card, const char* key, const char* text) { + lv_obj_t* r = infoLine(card); + lv_obj_set_flex_flow(r, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(r, 2, 0); + label(r, key, THEME_FONT_SMALL, theme::TEXT_MUTED); + lv_obj_t* t = label(r, text, THEME_FONT_SMALL, theme::TEXT); + lv_label_set_long_mode(t, LV_LABEL_LONG_WRAP); + lv_obj_set_width(t, LV_PCT(100)); + return t; +} + +static void infoSet(lv_obj_t* value, const char* text) { + lv_obj_t* row = lv_obj_get_parent(value); + if (!text || !text[0]) { lv_obj_add_flag(row, LV_OBJ_FLAG_HIDDEN); return; } + lv_obj_remove_flag(row, LV_OBJ_FLAG_HIDDEN); + lv_label_set_text(value, text); +} + // A bare flex row / column sized to its content, taps passing through. static lv_obj_t* flexBox(lv_obj_t* parent, lv_flex_flow_t flow) { lv_obj_t* b = lv_obj_create(parent); @@ -1434,6 +1504,8 @@ void UITask::buildContacts() { // model, since it is a different set: who is in range, not who is known. static NearbyModel::Entry s_node; // the node open in SCR_NODE (a copy: the list re-sorts) +// Its info card's values (UITask::_node_info is the card). +static lv_obj_t *s_nd_type, *s_nd_status, *s_nd_dist, *s_nd_pos, *s_nd_heard, *s_nd_signal, *s_nd_id; enum : uint8_t { NODE_MSG, NODE_PING, NODE_FAV, NODE_ADD, NODE_DELETE, NODE_NAV, NODE_ADMIN, NODE_WAYPOINT, NODE_PIN }; static void onNearbyChip(lv_event_t* e) { s_ui->setNearbyFilter((uint8_t)(uintptr_t)lv_event_get_user_data(e)); } @@ -1708,16 +1780,16 @@ void UITask::buildNode() { const NearbyModel::Entry& e = s_node; lv_obj_t* body = newScreen(e.name[0] ? e.name : "(unknown)", true); - _node_info = label(body, "", THEME_FONT_BODY, theme::TEXT); - lv_label_set_long_mode(_node_info, LV_LABEL_LONG_WRAP); - lv_obj_set_width(_node_info, LV_PCT(100)); - lv_obj_set_style_text_line_space(_node_info, 3, 0); - _node_ping = label(body, "", THEME_FONT_BODY, theme::ACCENT); - - lv_obj_t* spacer = lv_obj_create(body); // pushes the actions to the bottom - lv_obj_remove_style_all(spacer); - lv_obj_set_width(spacer, 1); - lv_obj_set_flex_grow(spacer, 1); + lv_obj_t* info = scrollList(body); // the info, scrolling above the actions at the bottom + _node_info = infoCard(info); + s_nd_type = infoRow(_node_info, "Type", ""); + s_nd_status = infoRow(_node_info, "Status", ""); + s_nd_dist = infoRow(_node_info, "Distance", ""); + s_nd_pos = infoRow(_node_info, "Position", ""); + s_nd_heard = infoRow(_node_info, "Heard", ""); + s_nd_signal = infoRow(_node_info, "Signal", ""); + s_nd_id = infoRow(_node_info, "ID", ""); + _node_ping = label(info, "", THEME_FONT_BODY, theme::ACCENT); lv_obj_t* acts = lv_obj_create(body); styleSurface(acts, theme::BG); @@ -1763,36 +1835,40 @@ void UITask::refreshNode() { if (same) { s_node = m; break; } } - char buf[320]; - int o = 0; // A scan row carries no contact index; is_known says whether it's in the contacts. bool known = e.contact_idx >= 0 || (_node_from_scan && e.is_known); - o += snprintf(buf + o, sizeof(buf) - o, "%s%s%s%s", NearbyModel::typeName(e.type), - known ? "" : " - not a contact", - e.is_live ? (e.live_verified ? " - live position" : " - live position (channel)") : "", - e.fav ? " - favourite" : ""); + char status[64] = "", dist[40] = "", pos[32] = "", heard[16] = "", sig[40] = "", id[12] = ""; + 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.fav) tag("favourite"); + if (status[0] >= 'a' && status[0] <= 'z') status[0] -= 'a' - 'A'; int32_t lat, lon; - bool gps = _nearby->ownPosition(lat, lon); if (e.lat_e6 != 0 || e.lon_e6 != 0) { - if (gps && e.dist_km >= 0.0f) { + 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); - o += snprintf(buf + o, sizeof(buf) - o, "\n%s %d\xC2\xB0 %s", d, az, geo::bearingCardinal(az)); + snprintf(dist, sizeof(dist), "%s %d\xC2\xB0 %s", d, az, geo::bearingCardinal(az)); } - o += snprintf(buf + o, sizeof(buf) - o, "\n%.5f, %.5f", e.lat_e6 / 1e6, e.lon_e6 / 1e6); + snprintf(pos, sizeof(pos), "%.5f, %.5f", e.lat_e6 / 1e6, e.lon_e6 / 1e6); } else if (!_node_from_scan) { - o += snprintf(buf + o, sizeof(buf) - o, "\nNo position shared"); + snprintf(pos, sizeof(pos), "Not shared"); } char age[8]; geo::fmtAgeShort(age, sizeof(age), rtc_clock.getCurrentTime(), e.lastmod); - if (age[0]) o += snprintf(buf + o, sizeof(buf) - o, "\nHeard %s ago", age); + if (age[0]) snprintf(heard, sizeof(heard), "%s ago", age); if (_node_from_scan) - o += snprintf(buf + o, sizeof(buf) - o, "\nRSSI %d dBm - SNR %.1f / %.1f dB", e.rssi, - e.snr_x4 / 4.0f, e.remote_snr_x4 / 4.0f); - if (e.has_prefix) - o += snprintf(buf + o, sizeof(buf) - o, "\nID %02X%02X%02X%02X", e.pub_key[0], e.pub_key[1], e.pub_key[2], e.pub_key[3]); - lv_label_set_text(_node_info, buf); + snprintf(sig, sizeof(sig), "%d dBm, SNR %.1f / %.1f", e.rssi, e.snr_x4 / 4.0f, e.remote_snr_x4 / 4.0f); + if (e.has_prefix) snprintf(id, sizeof(id), "%02X%02X%02X%02X", e.pub_key[0], e.pub_key[1], e.pub_key[2], e.pub_key[3]); + infoSet(s_nd_type, NearbyModel::typeName(e.type)); + infoSet(s_nd_status, status); + infoSet(s_nd_dist, dist); + infoSet(s_nd_pos, pos); + infoSet(s_nd_heard, heard); + infoSet(s_nd_signal, sig); + infoSet(s_nd_id, id); // Ping result (PingEngine releases its slot on reply; the view owns the timeout) if (_pinging) { @@ -2721,15 +2797,13 @@ void UITask::buildSettings() { } sectionTitle(body, "ABOUT"); - char about[300], built[24] = ""; - if (!strstr(FIRMWARE_VERSION, FIRMWARE_BUILD_DATE)) snprintf(built, sizeof(built), " (%s)", FIRMWARE_BUILD_DATE); - snprintf(about, sizeof(about), "%s\nFirmware %s%s\n\nMap data: %s\nEmoji: Twemoji \xC2\xA9 Twitter, Inc. and contributors (CC\xE2\x80\x91" "BY 4.0)", - the_mesh.getNodeName(), FIRMWARE_VERSION, built, - (lvport::mountStorage() && mapview::s_provider->available()) ? mapview::s_provider->attribution() - : "\xC2\xA9 OpenStreetMap contributors"); - lv_obj_t* a = label(body, about, THEME_FONT_SMALL, theme::TEXT); - lv_label_set_long_mode(a, LV_LABEL_LONG_WRAP); - lv_obj_set_width(a, LV_PCT(100)); + lv_obj_t* about = infoCard(body); + infoRow(about, "Node", the_mesh.getNodeName()); + infoRow(about, "Firmware", FIRMWARE_VERSION); + if (!strstr(FIRMWARE_VERSION, FIRMWARE_BUILD_DATE)) infoRow(about, "Built", FIRMWARE_BUILD_DATE); + infoNote(about, "Map data", (lvport::mountStorage() && mapview::s_provider->available()) + ? mapview::s_provider->attribution() : "\xC2\xA9 OpenStreetMap contributors"); + infoNote(about, "Emoji", "Twemoji \xC2\xA9 Twitter, Inc. and contributors (CC\xE2\x80\x91" "BY\xC2\xA0" "4.0)"); } void UITask::setKeyboardAlphabets(int main_idx, int alt_sel) { @@ -2751,3 +2825,19 @@ void UITask::setKeyboardAlphabets(int main_idx, int alt_sel) { #include "OtaScreen.h" #include "StorageScreen.h" #include "Splash.h" + +#if defined(SIM_PLATFORM) && defined(__EMSCRIPTEN__) +// Sim tests: straight to a screen by name. +extern "C" EMSCRIPTEN_KEEPALIVE void sim_open(const char* name) { + if (!s_ui) return; + struct { const char* n; void (UITask::*fn)(); } const SCREENS[] = { + { "home", &UITask::showHome }, { "chats", &UITask::showChats }, { "settings", &UITask::showSettings }, + { "nodes", &UITask::showNearby }, { "ota", &UITask::showOta }, { "wifi", &UITask::showWifi }, + { "clock", &UITask::showClock }, { "radio", &UITask::showRadio }, { "scopes", &UITask::showScopes }, + { "repeater", &UITask::showRepeater }, { "bot", &UITask::showBot }, { "storage", &UITask::showStorage }, + { "diag", &UITask::showDiag }, { "compass", &UITask::showCompass }, { "admin", &UITask::showAdminPick }, + { "quick", &UITask::showQuickMsgs }, + }; + for (auto& s : SCREENS) if (!strcmp(s.n, name)) { (s_ui->*s.fn)(); return; } +} +#endif diff --git a/examples/companion_radio/ui-lvgl/UITask.h b/examples/companion_radio/ui-lvgl/UITask.h index c65eef0d..761df951 100644 --- a/examples/companion_radio/ui-lvgl/UITask.h +++ b/examples/companion_radio/ui-lvgl/UITask.h @@ -303,6 +303,7 @@ private: void navAddWaypoint(int32_t lat, int32_t lon); void navDropAt(int x, int y); void navSpotPopup(int32_t lat, int32_t lon); + void placeCard(lv_obj_t* parent, int32_t lat, int32_t lon); void refreshNavTools(); void navPollAveraging(); void navPollTrackBack(); diff --git a/examples/companion_radio/ui-lvgl/lv_conf.h b/examples/companion_radio/ui-lvgl/lv_conf.h index ee34c2c6..9d2e365f 100644 --- a/examples/companion_radio/ui-lvgl/lv_conf.h +++ b/examples/companion_radio/ui-lvgl/lv_conf.h @@ -39,6 +39,9 @@ // Image glyphs (the emoji) are drawn only with this on; lv_imgfont itself is unused. #define LV_USE_IMGFONT 1 #define LV_FONT_DEFAULT &ui_font_14 +// Where a line may break: not after '.', which would split "4.0", "v1.23" or +// a coordinate across two lines. +#define LV_TXT_BREAK_CHARS " ,;:-_)]}" #define LV_USE_FONT_COMPRESSED 0 // fonts are generated uncompressed: no per-glyph decompression on every draw // PNG decoder for raster map tiles (map/TileProvider.h calls lodepng directly).